@selvajs/visualization 1.6.0-beta.1 → 1.6.0-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/gpu-dispose-BKp7iijv.js +355 -0
  2. package/dist/gpu-dispose-BKp7iijv.js.map +1 -0
  3. package/dist/gpu-dispose-Cxu3dFmA.cjs +469 -0
  4. package/dist/gpu-dispose-Cxu3dFmA.cjs.map +1 -0
  5. package/dist/gpu-ownership-ADRZBxxs.cjs +102 -0
  6. package/dist/gpu-ownership-ADRZBxxs.cjs.map +1 -0
  7. package/dist/gpu-ownership-Bs9l7hrU.js +40 -0
  8. package/dist/gpu-ownership-Bs9l7hrU.js.map +1 -0
  9. package/dist/index.js +1 -1
  10. package/dist/merged-draw-CO4rr2Co.js +129 -0
  11. package/dist/merged-draw-CO4rr2Co.js.map +1 -0
  12. package/dist/merged-draw-ma_2r4N8.cjs +171 -0
  13. package/dist/merged-draw-ma_2r4N8.cjs.map +1 -0
  14. package/dist/parse.cjs +2789 -11
  15. package/dist/parse.cjs.map +1 -1
  16. package/dist/parse.d.cts +15 -18
  17. package/dist/parse.d.ts +15 -18
  18. package/dist/parse.js +2780 -11
  19. package/dist/parse.js.map +1 -1
  20. package/dist/render.cjs +4302 -6
  21. package/dist/render.cjs.map +1 -1
  22. package/dist/render.d.cts +62 -43
  23. package/dist/render.d.ts +62 -43
  24. package/dist/render.js +4281 -6
  25. package/dist/render.js.map +1 -1
  26. package/dist/scene.cjs +343 -1
  27. package/dist/scene.cjs.map +1 -1
  28. package/dist/scene.d.cts +0 -12
  29. package/dist/scene.d.ts +0 -12
  30. package/dist/scene.js +336 -1
  31. package/dist/scene.js.map +1 -1
  32. package/dist/{types-DjPelPRM.d.ts → types-DW_qU4-Y.d.cts} +22 -5
  33. package/dist/{types-DjPelPRM.d.cts → types-DW_qU4-Y.d.ts} +22 -5
  34. package/package.json +18 -11
  35. package/dist/gpu-dispose-Dich0oW7.js +0 -2
  36. package/dist/gpu-dispose-Dich0oW7.js.map +0 -1
  37. package/dist/gpu-dispose-GZmNW-gK.cjs +0 -2
  38. package/dist/gpu-dispose-GZmNW-gK.cjs.map +0 -1
  39. package/dist/merged-draw-B75jHIzV.cjs +0 -2
  40. package/dist/merged-draw-B75jHIzV.cjs.map +0 -1
  41. package/dist/merged-draw-BPhBYH-v.js +0 -2
  42. package/dist/merged-draw-BPhBYH-v.js.map +0 -1
  43. package/dist/rolldown-runtime-BocRIvOZ.cjs +0 -1
package/dist/render.js CHANGED
@@ -1,12 +1,1618 @@
1
- import{a as e,c as t,d as n,f as r,g as i,h as a,l as o,m as s,p as c,r as l,s as u,t as d,u as f}from"./gpu-dispose-Dich0oW7.js";import{i as p,n as m,o as h,r as g,t as _}from"./merged-draw-BPhBYH-v.js";import*as v from"three";import{Line2 as y}from"three/addons/lines/Line2.js";import{LineGeometry as b}from"three/addons/lines/LineGeometry.js";import{LineMaterial as x}from"three/addons/lines/LineMaterial.js";import{RoomEnvironment as S}from"three/addons/environments/RoomEnvironment.js";import{LineSegments2 as C}from"three/addons/lines/LineSegments2.js";import{LineSegmentsGeometry as w}from"three/addons/lines/LineSegmentsGeometry.js";import{CSS2DObject as T,CSS2DRenderer as E}from"three/addons/renderers/CSS2DRenderer.js";import{ViewHelper as D}from"three/addons/helpers/ViewHelper.js";import{EffectComposer as O}from"three/addons/postprocessing/EffectComposer.js";import{RenderPass as k}from"three/addons/postprocessing/RenderPass.js";import{GTAOPass as A}from"three/addons/postprocessing/GTAOPass.js";import{SMAAPass as j}from"three/addons/postprocessing/SMAAPass.js";import{OutputPass as M}from"three/addons/postprocessing/OutputPass.js";import{FullScreenQuad as ee,Pass as N}from"three/addons/postprocessing/Pass.js";import{OrbitControls as P}from"three/addons/controls/OrbitControls.js";import{HDRLoader as F}from"three/addons/loaders/HDRLoader.js";const te=[`technical`,`studio`,`showcase`,`arctic`,`xray`,`lineart`,`wireframe`],ne=`compute`,re=`user`,I=`app:`;function L(e){return`${I}${e}`}function R(e){return typeof e!=`string`||!e.startsWith(I)?null:e.slice(4)||null}function z(e){let t=e.userData?.source;return t===`user`||R(t)!==null}function ie(e,t){return t===void 0?z(e):e.userData?.source===L(t)}const B=`selvaReflectionEnvironment`;function V(e,t,n){let r=new v.PMREMGenerator(t),i=new S,a=r.fromScene(i,.04).texture;i.dispose(),r.dispose(),ae(e,a,n)}function ae(e,t,n){let r={texture:t,rotation:n.clone()};e.userData[B]=r,H(e,e)}function oe(e){e.userData[B]?.texture.dispose(),delete e.userData[B]}function H(e,t){let n=t.userData[B];n&&e.traverse(e=>{if(e.userData.source!==`compute`)return;let t=e,r=Array.isArray(t.material)?t.material:t.material?[t.material]:[];for(let e of r){if(!(e instanceof v.MeshStandardMaterial)||e.envMap&&e.envMap!==n.texture)continue;let t=e.metalness>.5?n.texture:null;e.envMap!==t&&(e.envMap=t,e.envMapRotation.copy(n.rotation),e.needsUpdate=!0)}})}function U(e){let t=e.clone().normalize(),n=new v.Vector3(0,0,1),r=new v.Vector3(0,1,0),i=Math.abs(t.dot(n))>.9?r:n,a=new v.Vector3().crossVectors(i,t).normalize();return{up:t,forward:new v.Vector3().crossVectors(t,a).normalize(),right:a}}function se(e,t){let{forward:n,right:r,up:i}=U(e);return n.clone().multiplyScalar(-1).add(r.clone().multiplyScalar(-1)).add(i).normalize().multiplyScalar(t)}function ce(e,t,n){let{forward:r,right:i,up:a}=U(e);return i.clone().multiplyScalar(t).add(r.clone().multiplyScalar(t)).add(a.clone().multiplyScalar(n))}function le(e){let t=e.clone().normalize(),n=new v.Vector3(0,1,0);if(t.dot(n)>.9999)return new v.Euler;if(t.dot(n)<-.9999)return new v.Euler(Math.PI,0,0);let r=new v.Quaternion().setFromUnitVectors(n,t);return new v.Euler().setFromQuaternion(r)}function ue(e){let t=Math.abs(e.x),n=Math.abs(e.y),r=Math.abs(e.z);return t>=n&&t>=r?`x`:n>=r?`y`:`z`}const W={HUGE_THRESHOLD:1e4,LARGE_THRESHOLD:1e3,SCALE_RATIO_THRESHOLD:100,NEAR_PLANE_FACTOR:{TINY:1e-4,SMALL:.001,NORMAL:.01},FAR_PLANE_FACTOR:{HUGE:100,LARGE:50,NORMAL:20},INITIAL_DISTANCE_MULTIPLIER:4};function de(t,n,r,i,a){if(ge(t),n.length===0)return;n.forEach(e=>{t.add(e),H(e,t)});let o=e(n),s=o.getCenter(new v.Vector3),c=o.getSize(new v.Vector3),l=Math.max(c.x,c.y,c.z);if(l/Math.min(c.x||1,c.y||1,c.z||1)>W.SCALE_RATIO_THRESHOLD||l>W.HUGE_THRESHOLD?(r.near=l*W.NEAR_PLANE_FACTOR.TINY,r.far=l*W.FAR_PLANE_FACTOR.HUGE):l>W.LARGE_THRESHOLD?(r.near=l*W.NEAR_PLANE_FACTOR.SMALL,r.far=l*W.FAR_PLANE_FACTOR.LARGE):(r.near=Math.max(.01,l*W.NEAR_PLANE_FACTOR.NORMAL),r.far=Math.max(2e3,l*W.FAR_PLANE_FACTOR.NORMAL)),r.updateProjectionMatrix(),!a){let e=l*W.INITIAL_DISTANCE_MULTIPLIER;r.position.copy(s).add(se(r.up,e)),i.target.copy(s),i.update()}}const fe=new Set([`grid`,`floor`,`label-layer`,`measure`]);function pe(e){return typeof e.userData.id==`string`&&fe.has(e.userData.id)}function me(e){let t=e;for(;t;){if(pe(t))return!0;t=t.parent}return!1}function G(e){e.updateMatrixWorld(!0);let t=new v.Box3;return e.traverse(e=>{let n=e;e.visible&&!me(e)&&n.geometry&&t.expandByObject(e)}),t}const he=new Set([`floor`,`grid`,`label-layer`]);function ge(e){[...e.children].forEach(e=>{he.has(e.userData.id)||z(e)||(d(e),e.removeFromParent())})}function _e(e){let{up:t,forward:n,right:r}=U(e),i=n.clone().negate(),a=r.clone();return{top:t.clone(),bottom:t.clone().negate(),front:i.clone(),back:i.clone().negate(),right:a.clone(),left:a.clone().negate(),iso:i.clone().multiplyScalar(1.2).add(a.clone()).add(t.clone()).normalize()}}function ve(e){let{scene:t,perspective:n,controls:r,onActiveCameraChange:i}=e,a=(e.up??n.up).clone().normalize(),o=_e(a),s=new v.OrthographicCamera(-1,1,1,-1,n.near,n.far);s.up.copy(a);let c=`perspective`,l=n.aspect,u=()=>c===`perspective`?n:s,d=null,f=()=>{d?.cancel(),d=null},p=()=>{let e=(c===`orthographic`?s:n).position.distanceTo(r.target)*Math.tan(n.fov*Math.PI/360),t=e*l;s.left=-t,s.right=t,s.top=e,s.bottom=-e,s.near=n.near,s.far=n.far,s.updateProjectionMatrix()},m=e=>{if(e!==c){if(f(),e===`orthographic`)s.position.copy(n.position),s.up.copy(n.up),s.lookAt(r.target),s.zoom=1,p();else{let e=(s.top-s.bottom)/(2*s.zoom)/Math.tan(n.fov*Math.PI/360),t=s.position.clone().sub(r.target);t.lengthSq()<1e-12&&t.copy(a),t.normalize(),n.position.copy(r.target).add(t.multiplyScalar(e))}c=e,r.object=u(),r.update(),i(u())}},h=(e,t,i,o)=>{let l=n.fov*(Math.PI/180),m=t/(2*Math.tan(l/2))*1.5,h=ye(i,a),g=e.clone().add(h.clone().multiplyScalar(m)),_=u();c===`orthographic`&&(s.zoom=1),f(),o?d=xe(_,r,g,e,()=>{c===`orthographic`&&p()}):(_.position.copy(g),r.target.copy(e),c===`orthographic`&&p(),r.update())},g=(e,n=!0)=>{let i=G(t),a=i.isEmpty()?r.target.clone():i.getCenter(new v.Vector3),o=i.isEmpty()?new v.Vector3(1,1,1):i.getSize(new v.Vector3),s=Math.max(o.x,o.y,o.z)||1;h(a,s,e,n)};return{getActiveCamera:u,getProjection:()=>c,setProjection:m,toggleProjection:()=>(m(c===`perspective`?`orthographic`:`perspective`),c),setView:(e,t=!0)=>{g(o[e],t)},setViewDirection:g,frameBounds:(e,t=!0)=>{if(e.isEmpty())return;let n=e.getCenter(new v.Vector3),i=e.getSize(new v.Vector3),a=Math.max(i.x,i.y,i.z)||1,s=u().position.clone().sub(r.target);s.lengthSq()<1e-12&&s.copy(o.iso),h(n,a,s.normalize(),t)},setRotateEnabled:e=>{r.enableRotate=e},isRotateEnabled:()=>r.enableRotate,updateAspect:(e,t)=>{l=t===0?l:e/t,c===`orthographic`&&p()},dispose:f}}function ye(e,t){let{up:n,forward:r}=U(t),i=e.clone().normalize();if(Math.abs(i.dot(n))<.9999)return e;let a=r.clone().negate(),o=.5*Math.PI/180;return i.multiplyScalar(Math.cos(o)).add(a.multiplyScalar(Math.sin(o))).normalize()}const be=e=>1-(1-e)**3;function xe(e,t,n,r,i,a=250){let o=e.position.clone(),s=t.target.clone(),c=performance.now(),l=null,u=()=>{l=null;let d=be(Math.min((performance.now()-c)/a,1));e.position.lerpVectors(o,n,d),t.target.lerpVectors(s,r,d),i(),t.update(),d<1&&(l=requestAnimationFrame(u))};return l=requestAnimationFrame(u),{cancel:()=>{l!==null&&(cancelAnimationFrame(l),l=null)}}}function Se(e){let t=Math.floor(e.length/6);if(t===0)return 1/0;let n=Math.max(1,Math.ceil(t/4096)),r=[];for(let i=0;i<t;i+=n){let t=i*6,n=Math.hypot(e[t+3]-e[t],e[t+4]-e[t+1],e[t+5]-e[t+2]);n>0&&r.push(n)}return r.length===0?1/0:(r.sort((e,t)=>e-t),r[Math.min(r.length-1,Math.floor(r.length*.15))])}function Ce(e){let t=new w;return t.setPositions(e),{geometry:t,segmentCount:e.length/6,edgeSpacing:Se(e)}}function K(e,t,n){let r=1e4,i=67108864,a=Math.cos(Math.PI/180*n),o=e.length/3;if(o>=i)throw Error(`extractEdgeSegments: ${o} vertices exceeds 2^26 limit`);let s=new Float64Array(o),c=new Float64Array(o),l=new Float64Array(o);for(let t=0;t<o;t++)s[t]=Math.round(e[3*t]*r),c[t]=Math.round(e[3*t+1]*r),l[t]=Math.round(e[3*t+2]*r);let u=16;for(;u<o*2;)u<<=1;let d=u-1,f=new Int32Array(u).fill(-1),p=new Int32Array(o);for(let e=0;e<o;e++){let t=(Math.imul(s[e]|0,73856093)^Math.imul(c[e]|0,19349663)^Math.imul(l[e]|0,83492791))&d;for(;;){let n=f[t];if(n===-1){f[t]=e,p[e]=e;break}if(s[n]===s[e]&&c[n]===c[e]&&l[n]===l[e]){p[e]=n;break}t=t+1&d}}let m=new Float32Array(6144),h=0,g=new Uint32Array(1024),_=(t,n,r)=>{if(h+6>m.length){let e=new Float32Array(m.length*2);e.set(m),m=e;let t=new Uint32Array(m.length/6);t.set(g),g=t}g[h/6]=r,m[h++]=e[3*t],m[h++]=e[3*t+1],m[h++]=e[3*t+2],m[h++]=e[3*n],m[h++]=e[3*n+1],m[h++]=e[3*n+2]},v=new Map,y=[],b=[],x=[],S=[],C=(t?t.length:o)/3;for(let n=0;n<C;n++){let r=t?t[3*n]:3*n,o=t?t[3*n+1]:3*n+1,s=t?t[3*n+2]:3*n+2,c=p[r],l=p[o],u=p[s];if(c===l||l===u||u===c)continue;let d=e[3*s]-e[3*o],f=e[3*s+1]-e[3*o+1],m=e[3*s+2]-e[3*o+2],h=e[3*r]-e[3*o],g=e[3*r+1]-e[3*o+1],C=e[3*r+2]-e[3*o+2],w=f*C-m*g,T=m*h-d*C,E=d*g-f*h,D=w*w+T*T+E*E;if(D>0){let e=1/Math.sqrt(D);w*=e,T*=e,E*=e}else w=0,T=0,E=0;for(let e=0;e<3;e++){let t,d,f,p;e===0?(t=r,d=o,f=c,p=l):e===1?(t=o,d=s,f=l,p=u):(t=s,d=r,f=u,p=c);let m=p*i+f,h=v.get(m);if(h!==void 0&&h!==-1)w*x[3*h]+T*x[3*h+1]+E*x[3*h+2]<=a&&_(t,d,n),v.set(m,-1);else{let e=f*i+p;if(!v.has(e)){let r=y.length;v.set(e,r),y.push(t),b.push(d),x.push(w,T,E),S.push(n)}}}}for(let e of v.values())e!==-1&&_(y[e],b[e],S[e]);return{segments:m.slice(0,h),faces:g.slice(0,h/6)}}function we(){return[`const extract = ${K.toString()};`,`self.onmessage = (event) => {`,` const { id, positions, index, thresholdAngle } = event.data;`,` try {`,` const { segments, faces } = extract(positions, index, thresholdAngle);`,` self.postMessage({ id, segments, faces }, [segments.buffer, faces.buffer]);`,` } catch (error) {`,` self.postMessage({ id, error: String((error && error.message) || error) });`,` }`,`};`].join(`
2
- `)}const Te=`edge-overlay`;function Ee(e){return{forcedColor:e.color==null?null:new v.Color(e.color),darken:v.MathUtils.clamp(e.darken??.75,0,1),width:e.width??1.5,thresholdAngle:e.thresholdAngle??44,distanceFade:e.distanceFade??!0,maxTriangles:e.maxTriangles??4e6,maxSegments:e.maxSegments??2e6,maxOverlays:e.maxOverlays??1500}}function De(e){let t=e.getAttribute(`position`);return t?(e.index?e.index.count:t.count)/3:0}function Oe(e){let t=e.getAttribute(`position`);if(!t||t.isInterleavedBufferAttribute||t.itemSize!==3||!(t.array instanceof Float32Array)||t.count>=67108864)return null;let n=e.index;return n&&!(n.array instanceof Uint32Array)&&!(n.array instanceof Uint16Array)?null:{positions:t.array,index:n?n.array:null}}function ke(e,t){let n=4096,r=2166136261,i=e=>{r^=e,r=Math.imul(r,16777619)},a=new Uint32Array(e.positions.buffer,e.positions.byteOffset,e.positions.length),o=Math.min(n,a.length);for(let e=0;e<o;e++)i(a[e]);for(let e=Math.max(o,a.length-n);e<a.length;e++)i(a[e]);let s=0;if(e.index){s=e.index.length;let t=Math.min(n,s);for(let n=0;n<t;n++)i(e.index[n]);for(let r=Math.max(t,s-n);r<s;r++)i(e.index[r])}return`${t}:${e.positions.length}:${s}:${r>>>0}`}function Ae(e,t){let n=new v.EdgesGeometry(e,t),r=n.attributes.position?n.attributes.position.array:new Float32Array;return n.dispose(),{segments:r,faces:null}}function je(e,t){let n=Oe(e);return n?K(n.positions,n.index,t):Ae(e,t)}let q;const J=new Map;let Me=1;function Ne(){if(q!==void 0)return q;if(typeof Worker>`u`||typeof Blob>`u`||typeof URL>`u`||typeof URL.createObjectURL!=`function`)return q=null,null;try{let e=URL.createObjectURL(new Blob([we()],{type:`text/javascript`})),t=new Worker(e);t.onmessage=e=>{let{id:t,segments:n,faces:r,error:i}=e.data,a=J.get(t);a&&(J.delete(t),n?a.resolve({segments:n,faces:r??null}):a.reject(Error(i??`edge extraction failed in worker`)))},t.onerror=()=>{for(let e of J.values())e.reject(Error(`edge extraction worker crashed`));J.clear(),t.terminate(),q=null},q=t}catch{q=null}return q}function Pe(e,t,n){return new Promise((r,i)=>{let a=Me++;J.set(a,{resolve:r,reject:i});let o=t.positions.slice(),s=t.index?t.index.slice():null,c=[o.buffer];s&&c.push(s.buffer),e.postMessage({id:a,positions:o,index:s,thresholdAngle:n},c)})}const Y=new Map;function Fe(e,t){let n=Oe(e);if(!n||De(e)<25e3)return Promise.resolve(je(e,t));let r=ke(n,t),i=Y.get(r);if(i)return i;let a=Ne();if(!a)return Promise.resolve(je(e,t));let o=Pe(a,n,t).catch(()=>K(n.positions,n.index,t)).finally(()=>{Y.delete(r)});return Y.set(r,o),o}function X(e){let t=e.userData?.members;return Array.isArray(t)&&t.length>0?t:null}function Ie(e){let t=X(e.object);if(!t||e.faceIndex==null)return null;let n=e.faceIndex*3,r=0,i=t.length-1;for(;r<=i;){let e=r+i>>1,a=t[e];if(a.indexStart==null||a.indexCount==null)return null;if(n<a.indexStart)i=e-1;else if(n>=a.indexStart+a.indexCount)r=e+1;else return{member:a,index:e}}return null}function Le(e,t){let n=new v.Box3,r=e.geometry.getIndex(),i=e.geometry.getAttribute(`position`);if(!r||!i||t.indexStart==null||t.indexCount==null)return n;e.updateMatrixWorld();let a=new v.Vector3,o=Math.min(t.indexStart+t.indexCount,r.count);for(let s=t.indexStart;s<o;s++)a.fromBufferAttribute(i,r.getX(s)).applyMatrix4(e.matrixWorld),n.expandByPoint(a);return n}function Re(e,t,n){let r=X(e)?.indexOf(t)??-1;return r<0||t.indexStart==null||t.indexCount==null?()=>{}:(h(e,r,n),()=>_(e))}function ze(e,t){let n=X(e);if(!n)return null;let r=[];for(let e of n){if(e.indexStart==null)return null;r.push(e.indexStart)}let i=new Uint32Array(t.length);for(let e=0;e<t.length;e++){let n=t[e]*3,a=0,o=r.length-1;for(;a<o;){let e=a+o+1>>1;r[e]<=n?a=e:o=e-1}i[e]=a}return i}function Be(e,t,n){let r=0;for(let e=0;e<t.length;e++)n.has(t[e])||r++;let i=new Float32Array(r*6),a=0;for(let r=0;r<t.length;r++)n.has(t[r])||(i.set(e.subarray(r*6,r*6+6),a),a+=6);return i}function Ve(e,t,n){let{segments:r,faces:i}=n;if(!i||!X(t))return;let a,o=!1,s=()=>{let n=g(t);if(n.size===0&&!o||(a??=ze(t,i),!a))return;let s=n.size===0?r:Be(r,a,n);e.geometry.dispose(),e.geometry=Ce(s).geometry,o=n.size>0};s();let c=p(t,s);e.addEventListener(`removed`,c)}function He(e,t){let n=(Array.isArray(e.material)?e.material[0]:e.material)?.color;return n?n.clone().multiplyScalar(1-t):new v.Color(2236962)}var Ue=class{options;byKey=new Map;constructor(e){this.options=e}for(e,t){let n=this.options.forcedColor??He(e,this.options.darken),r=n.getHex()*2+ +!!t,i=this.byKey.get(r);return i||(i=We(n,this.options.width,t),this.byKey.set(r,i)),i}disposeUnused(e){let t=new Set(e.map(e=>e.material));for(let e of this.byKey.values())t.has(e)||e.dispose()}};function We(e,t,n){let r=new x({color:e});return r.linewidth=t,r.polygonOffset=!0,r.polygonOffsetFactor=0,r.polygonOffsetUnits=-1,n&&(r.transparent=!0),r}function Ge(e,t,n){let r=new C(e.geometry,t);return r.userData.kind=Te,r.raycast=()=>{},n&&Ye(r,e.edgeSpacing),r}const Ke=new v.Vector3,qe=new v.Vector3;function Je(e,t,n){e.geometry.boundingSphere||e.geometry.computeBoundingSphere();let r=e.geometry.boundingSphere;if(!r)return 1/0;if(t.isPerspectiveCamera){let i=t;Ke.copy(r.center).applyMatrix4(e.matrixWorld);let a=qe.setFromMatrixPosition(t.matrixWorld).distanceTo(Ke);if(a<=r.radius*e.matrixWorld.getMaxScaleOnAxis())return 1/0;let o=Math.tan(v.MathUtils.degToRad(i.fov)*.5),s=2*a*o;return s>0?n/s:1/0}if(t.isOrthographicCamera){let e=t,r=(e.top-e.bottom)/e.zoom;return r>0?n/r:1/0}return 1/0}function Ye(e,t){e.onBeforeRender=(n,r,i)=>{C.prototype.onBeforeRender.call(e,n);let a=e.material,o=t*Je(e,i,a.resolution.y);a.opacity=v.MathUtils.clamp((o-1)/3,0,1)}}function Xe(e){return e.userData?.kind===Te}function Ze(e,t){let n=[],r=0;e.traverse(e=>{if(e instanceof v.Mesh&&e.userData.id!==`floor`&&e.userData.id!==`grid`&&e.userData.kind!==`edge-overlay`){if(e.children.some(e=>e.userData?.kind===`edge-overlay`)){r++;return}if(e.geometry){if(De(e.geometry)>t.maxTriangles){e.userData.edgesSkipped=`triangle-cap`;return}n.push(e)}}});let i=Math.max(0,t.maxOverlays-r),a=n.slice(0,i);for(let e of a)delete e.userData.edgesSkipped;for(let e of n.slice(i))e.userData.edgesSkipped=`overlay-budget`;return n.length>i&&console.debug(`[edges] overlay budget reached: ${n.length} meshes want overlays, ${i} allowed — the rest fall back to the screen-space edge pass.`),a}function Qe(e,t,n,r){let i=Ce(t.segments),a=r.distanceFade&&i.segmentCount<=r.maxSegments,o=Ge(i,n.for(e,a),a);return e.add(o),Ve(o,e,t),o}const $e=new WeakMap;function Z(e){return $e.get(e)??0}function et(e,t){for(let n=e;n;n=n.parent)if(n===t)return!0;return!1}async function tt(e,t={}){let n=Ee(t),r=new Ue(n),i=Z(e),a=[],o=Ze(e,n).map(async t=>{let o=await Fe(t.geometry,n.thresholdAngle);Z(e)===i&&et(t,e)&&(t.children.some(e=>e.userData?.kind===`edge-overlay`)||a.push(Qe(t,o,r,n)))});return await Promise.all(o),r.disposeUnused(a),a}function nt(e){$e.set(e,Z(e)+1);let t=[];e.traverse(e=>{e instanceof C&&Xe(e)&&t.push(e)});let n=new Set;for(let e of t)e.geometry.dispose(),n.add(e.material),e.removeFromParent();return n.forEach(e=>e.dispose()),t.length}function rt(e){if(!(e>0)||!Number.isFinite(e))return 1;let t=10**Math.floor(Math.log10(e)),n=e/t;return(n>=5?5:n>=2?2:1)*t}function it(e={}){let{cellSize:t=1,majorEvery:n=10,cellColor:r=8947848,majorColor:i=4473924,fadeDistance:a=100,plane:o=`y`}=e,s=o===`y`?new v.Vector2(0,2):o===`z`?new v.Vector2(0,1):new v.Vector2(1,2),c=2.5,l=new v.PlaneGeometry(1,1);o===`y`?l.rotateX(-Math.PI/2):o===`x`&&l.rotateY(Math.PI/2);let u=new v.ShaderMaterial({vertexShader:`
1
+ import { _ as setLogger, a as computeCombinedBoundingBox, c as EMISSIVE_LIGHT, d as OWN_ENV_MAP_INTENSITY, f as lookEnvMapIntensity, g as getLogger, h as enableDebugLogging, l as LOOK_PRESETS, m as materialAppearanceForLook, p as lookPreset, r as publishMaxAnisotropy, s as DEFAULT_LOOK, t as disposeObjectTree, v as ErrorCodes, y as VisualizationError } from "./gpu-dispose-BKp7iijv.js";
2
+ import { n as cachedSourceOf } from "./gpu-ownership-Bs9l7hrU.js";
3
+ import { a as onMemberDrawChange, i as getHiddenMembers, n as cloneForTint, r as getBaseMaterial, s as setMemberHighlight, t as clearMemberHighlight } from "./merged-draw-CO4rr2Co.js";
4
+ import * as THREE from "three";
5
+ import { Line2 } from "three/addons/lines/Line2.js";
6
+ import { LineGeometry } from "three/addons/lines/LineGeometry.js";
7
+ import { LineMaterial } from "three/addons/lines/LineMaterial.js";
8
+ import { LineSegments2 } from "three/addons/lines/LineSegments2.js";
9
+ import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js";
10
+ import { CSS2DObject, CSS2DRenderer } from "three/addons/renderers/CSS2DRenderer.js";
11
+ import { MeshBVH } from "three-mesh-bvh";
12
+ import { ViewHelper } from "three/addons/helpers/ViewHelper.js";
13
+ import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
14
+ import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
15
+ import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
16
+ import { GTAOPass } from "three/addons/postprocessing/GTAOPass.js";
17
+ import { SMAAPass } from "three/addons/postprocessing/SMAAPass.js";
18
+ import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
19
+ import { FullScreenQuad, Pass } from "three/addons/postprocessing/Pass.js";
20
+ import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
21
+ import { OrbitControls } from "three/addons/controls/OrbitControls.js";
22
+ import { HDRLoader } from "three/addons/loaders/HDRLoader.js";
23
+ // src/shared/types.ts
24
+ /** Source of truth for {@link Look} — lets consumers (e.g. a style picker) iterate instead of hardcoding names. */
25
+ const LOOKS = [
26
+ "technical",
27
+ "studio",
28
+ "showcase",
29
+ "arctic",
30
+ "xray",
31
+ "lineart",
32
+ "wireframe",
33
+ "night"
34
+ ];
35
+ // src/render/scene-ownership.ts
36
+ /** Geometry produced by a solve. Replaced wholesale on the next one. */
37
+ const SOURCE_COMPUTE = "compute";
38
+ /**
39
+ * Host-added geometry with no owner id. Predates scoped ownership and still honoured everywhere
40
+ * an `app:` scope is — new code should prefer {@link appSource}.
41
+ */
42
+ const SOURCE_USER = "user";
43
+ const APP_PREFIX = "app:";
44
+ /** The `userData.source` tag for geometry owned by app `id` (`'pointcloud'` → `'app:pointcloud'`). */
45
+ function appSource(id) {
46
+ return `${APP_PREFIX}${id}`;
47
+ }
48
+ /** The app id from a source tag, or null if the tag isn't app-owned. */
49
+ function appIdFromSource(source) {
50
+ if (typeof source !== "string" || !source.startsWith(APP_PREFIX)) return null;
51
+ return source.slice(4) || null;
52
+ }
53
+ /**
54
+ * True for anything a host added rather than the solve — `'user'` or any `app:` scope. This is
55
+ * the predicate `clearScene` uses, so it decides what survives a solve.
56
+ */
57
+ function isHostOwned(object) {
58
+ const source = object.userData?.source;
59
+ return source === "user" || appIdFromSource(source) !== null;
60
+ }
61
+ /**
62
+ * True for objects owned by `id`. Passing no id matches every host-owned object, which is what
63
+ * `clearUserGeometry()` does.
64
+ */
65
+ function isOwnedBy(object, id) {
66
+ if (id === void 0) return isHostOwned(object);
67
+ return object.userData?.source === appSource(id);
68
+ }
69
+ // src/render/scene-setup/studio-environment.ts
70
+ /** What a softbox costs to set up, in radiance: only their ratio to the room matters to a reflection. */
71
+ const OVERHEAD = 8;
72
+ const KEY_STRIP = 11;
73
+ const FILL_STRIP = 4.5;
74
+ const RIM = 3.5;
75
+ const WALLS = .14;
76
+ const FLOOR = .05;
77
+ const ROOM_SIZE = 10;
78
+ function softbox(radiance, width, height, position) {
79
+ const panel = new THREE.Mesh(new THREE.PlaneGeometry(width, height), new THREE.MeshBasicMaterial({
80
+ color: new THREE.Color().setScalar(radiance),
81
+ side: THREE.DoubleSide
82
+ }));
83
+ panel.position.set(...position);
84
+ panel.lookAt(0, 0, 0);
85
+ return panel;
86
+ }
87
+ /**
88
+ * A photographer's table, for PMREM to bake into a reflection probe: softboxes on a dark room.
89
+ * Shape comes from the gaps between lights. A surface that reflects light everywhere has no contour,
90
+ * which is why three's `RoomEnvironment`, bright on all sides, leaves metal looking like flat paint.
91
+ * The floor stays dark so an underside reads as turned away from the light.
92
+ */
93
+ function createStudioScene() {
94
+ const scene = new THREE.Scene();
95
+ const room = new THREE.Mesh(new THREE.BoxGeometry(ROOM_SIZE, ROOM_SIZE, ROOM_SIZE), new THREE.MeshBasicMaterial({
96
+ color: new THREE.Color().setScalar(WALLS),
97
+ side: THREE.BackSide
98
+ }));
99
+ const floor = new THREE.Mesh(new THREE.PlaneGeometry(ROOM_SIZE, ROOM_SIZE), new THREE.MeshBasicMaterial({ color: new THREE.Color().setScalar(FLOOR) }));
100
+ floor.rotation.x = -Math.PI / 2;
101
+ floor.position.y = -4;
102
+ scene.add(room, floor, softbox(OVERHEAD, 6.5, 4.5, [
103
+ 0,
104
+ 4.8,
105
+ .6
106
+ ]), softbox(KEY_STRIP, 1.4, 5.5, [
107
+ -4.7,
108
+ .8,
109
+ 1.6
110
+ ]), softbox(FILL_STRIP, .9, 5, [
111
+ 4.7,
112
+ .5,
113
+ 2.4
114
+ ]), softbox(RIM, 5, .8, [
115
+ .6,
116
+ 2.2,
117
+ -4.7
118
+ ]));
119
+ return scene;
120
+ }
121
+ /** Frees what {@link createStudioScene} built, once PMREM has baked it. */
122
+ function disposeStudioScene(scene) {
123
+ scene.traverse((object) => {
124
+ const mesh = object;
125
+ mesh.geometry?.dispose();
126
+ mesh.material?.dispose();
127
+ });
128
+ }
129
+ // src/render/scene-setup/reflection-environment.ts
130
+ const METAL = .5;
131
+ /** Above PMREM's blur for a mirror, so a softbox has an edge in chrome without showing as a shape. */
132
+ const STUDIO_BLUR = .03;
133
+ const KEY = "selvaReflectionEnvironment";
134
+ /**
135
+ * Builds the studio probe once per scene. `rotation` turns its Y-up room to the scene's up, the same
136
+ * rotation the HDR gets.
137
+ */
138
+ function createReflectionEnvironment(scene, renderer, rotation) {
139
+ const pmrem = new THREE.PMREMGenerator(renderer);
140
+ const studio = createStudioScene();
141
+ const texture = pmrem.fromScene(studio, STUDIO_BLUR).texture;
142
+ disposeStudioScene(studio);
143
+ pmrem.dispose();
144
+ installReflectionEnvironment(scene, texture, rotation);
145
+ }
146
+ /** The GPU-free half of {@link createReflectionEnvironment}; tests call it with a plain texture. */
147
+ function installReflectionEnvironment(scene, texture, rotation) {
148
+ const environment = {
149
+ texture,
150
+ rotation: rotation.clone()
151
+ };
152
+ scene.userData[KEY] = environment;
153
+ applyReflectionEnvironment(scene, scene);
154
+ }
155
+ function disposeReflectionEnvironment(scene) {
156
+ scene.userData[KEY]?.texture.dispose();
157
+ delete scene.userData[KEY];
158
+ }
159
+ /**
160
+ * Points every metallic solve material under `root` at the studio probe, and every other one back
161
+ * at `scene.environment`. Run after meshes are added and after a look changes metalness.
162
+ *
163
+ * A material's own `envMap` also makes three honour its `envMapIntensity`: with only
164
+ * `scene.environment`, three overwrites it with `scene.environmentIntensity` on every draw.
165
+ */
166
+ function applyReflectionEnvironment(root, scene) {
167
+ const environment = scene.userData[KEY];
168
+ if (!environment) return;
169
+ root.traverse((object) => {
170
+ if (object.userData.source !== "compute") return;
171
+ const mesh = object;
172
+ const materials = Array.isArray(mesh.material) ? mesh.material : mesh.material ? [mesh.material] : [];
173
+ for (const material of materials) {
174
+ if (!(material instanceof THREE.MeshStandardMaterial)) continue;
175
+ if (material.envMap && material.envMap !== environment.texture) continue;
176
+ const wanted = material.metalness > METAL ? environment.texture : null;
177
+ if (material.envMap === wanted) continue;
178
+ material.envMap = wanted;
179
+ material.envMapRotation.copy(environment.rotation);
180
+ material.needsUpdate = true;
181
+ }
182
+ });
183
+ }
184
+ // src/render/up-axis.ts
185
+ /** Ground-plane axes for a given up vector: Z-up yields forward = +Y, right = +X (Rhino's Front/Right). */
186
+ function buildUpBasis(up) {
187
+ const u = up.clone().normalize();
188
+ const worldZ = new THREE.Vector3(0, 0, 1);
189
+ const worldY = new THREE.Vector3(0, 1, 0);
190
+ const seed = Math.abs(u.dot(worldZ)) > .9 ? worldY : worldZ;
191
+ const right = new THREE.Vector3().crossVectors(seed, u).normalize();
192
+ return {
193
+ up: u,
194
+ forward: new THREE.Vector3().crossVectors(u, right).normalize(),
195
+ right
196
+ };
197
+ }
198
+ /** Default 3/4 iso camera offset from the target (behind-left, above), scaled to `distance`. */
199
+ function isoOffset(up, distance) {
200
+ const { forward, right, up: u } = buildUpBasis(up);
201
+ return forward.clone().multiplyScalar(-1).add(right.clone().multiplyScalar(-1)).add(u).normalize().multiplyScalar(distance);
202
+ }
203
+ /** Default sun position: high above the model, offset to one side for a directional gradient. */
204
+ function sunOffset(up, sideDistance, height) {
205
+ const { forward, right, up: u } = buildUpBasis(up);
206
+ return right.clone().multiplyScalar(sideDistance).add(forward.clone().multiplyScalar(sideDistance)).add(u.clone().multiplyScalar(height));
207
+ }
208
+ /**
209
+ * Rotates an equirectangular environment map's horizon onto the scene's ground plane.
210
+ *
211
+ * Three's equirect mapping is hardcoded to Y-up: the HDR's horizon is assumed to lie in the XZ
212
+ * plane with zenith along +Y. In a Z-up scene that leaves the environment on its side — horizon
213
+ * vertical, lighting arriving from +Y instead of overhead. A neutral studio HDR hides this; any
214
+ * sky/ground HDR makes it obvious.
215
+ *
216
+ * Returns the Euler rotating the map's native +Y zenith onto `up` (identity for Y-up). Apply to
217
+ * BOTH `scene.environmentRotation` and `scene.backgroundRotation` — they're independent, and
218
+ * setting only one desyncs background from lighting.
219
+ */
220
+ function environmentRotationFor(up) {
221
+ const u = up.clone().normalize();
222
+ const mapZenith = new THREE.Vector3(0, 1, 0);
223
+ if (u.dot(mapZenith) > .9999) return new THREE.Euler();
224
+ if (u.dot(mapZenith) < -.9999) return new THREE.Euler(Math.PI, 0, 0);
225
+ const quaternion = new THREE.Quaternion().setFromUnitVectors(mapZenith, u);
226
+ return new THREE.Euler().setFromQuaternion(quaternion);
227
+ }
228
+ /** Which world axis the up vector most closely aligns with. */
229
+ function upToAxis(up) {
230
+ const ax = Math.abs(up.x);
231
+ const ay = Math.abs(up.y);
232
+ const az = Math.abs(up.z);
233
+ if (ax >= ay && ax >= az) return "x";
234
+ if (ay >= az) return "y";
235
+ return "z";
236
+ }
237
+ // src/render/scene-setup/defaults.ts
238
+ /** Rhino's convention, and the frame all geometry arrives in — Selva is Z-up end to end. */
239
+ const defaultUp = new THREE.Vector3(0, 0, 1);
240
+ /**
241
+ * Decided once, from the device: reacting to slow frames would change the light count, which
242
+ * recompiles every shader on the device least able to. A coarse pointer alone would catch tablets;
243
+ * 600px is the narrowest tablet.
244
+ */
245
+ function isPhone() {
246
+ return (window.matchMedia?.("(pointer: coarse)").matches ?? false) && Math.min(window.screen.width, window.screen.height) < 600;
247
+ }
248
+ function applyDefaults(options) {
249
+ const scale = options.sceneScale || "m";
250
+ const defaults = {
251
+ mm: {
252
+ cameraDistance: 20,
253
+ near: .1,
254
+ far: 2e3,
255
+ floorSize: 100,
256
+ lightDistance: 10,
257
+ lightHeight: 20,
258
+ minDistance: .1,
259
+ shadowSize: 100,
260
+ scaleFactor: 1e3
261
+ },
262
+ cm: {
263
+ cameraDistance: 20,
264
+ near: .1,
265
+ far: 2e3,
266
+ floorSize: 100,
267
+ lightDistance: 25,
268
+ lightHeight: 50,
269
+ minDistance: .1,
270
+ shadowSize: 100,
271
+ scaleFactor: 100
272
+ },
273
+ m: {
274
+ cameraDistance: 10,
275
+ near: .01,
276
+ far: 2e3,
277
+ floorSize: 50,
278
+ lightDistance: 25,
279
+ lightHeight: 50,
280
+ minDistance: .001,
281
+ shadowSize: 100,
282
+ scaleFactor: 1
283
+ },
284
+ inches: {
285
+ cameraDistance: 15,
286
+ near: .1,
287
+ far: 2e3,
288
+ floorSize: 80,
289
+ lightDistance: 20,
290
+ lightHeight: 40,
291
+ minDistance: .1,
292
+ shadowSize: 80,
293
+ scaleFactor: 39.37
294
+ },
295
+ feet: {
296
+ cameraDistance: 8,
297
+ near: .1,
298
+ far: 2e3,
299
+ floorSize: 40,
300
+ lightDistance: 15,
301
+ lightHeight: 30,
302
+ minDistance: .1,
303
+ shadowSize: 60,
304
+ scaleFactor: 3.28084
305
+ }
306
+ }[scale];
307
+ const look = options.look ?? "technical";
308
+ const lowPower = options.render?.lowPower ?? isPhone();
309
+ const preset = lookPreset(look, lowPower);
310
+ return {
311
+ sceneScale: scale,
312
+ look,
313
+ camera: {
314
+ position: options.camera?.position || isoOffset(options.environment?.sceneUp ?? defaultUp, defaults.cameraDistance * Math.sqrt(3)),
315
+ fov: options.camera?.fov || 20,
316
+ near: options.camera?.near || defaults.near,
317
+ far: options.camera?.far || defaults.far,
318
+ target: options.camera?.target || new THREE.Vector3(0, 0, 0),
319
+ dynamicNear: options.camera?.dynamicNear ?? true
320
+ },
321
+ lighting: {
322
+ enableSunlight: options.lighting?.enableSunlight ?? true,
323
+ sunlightIntensity: options.lighting?.sunlightIntensity ?? preset.sunlightIntensity,
324
+ sunlightPosition: options.lighting?.sunlightPosition || sunOffset(options.environment?.sceneUp ?? defaultUp, defaults.lightDistance, defaults.lightHeight),
325
+ ambientLightColor: options.lighting?.ambientLightColor || new THREE.Color(4210752),
326
+ ambientLightIntensity: options.lighting?.ambientLightIntensity ?? preset.ambientIntensity,
327
+ sunlightColor: options.lighting?.sunlightColor || 16777215,
328
+ enableHemisphereLight: options.lighting?.enableHemisphereLight ?? preset.hemisphereIntensity > 0,
329
+ hemisphereSkyColor: options.lighting?.hemisphereSkyColor ?? 14673663,
330
+ hemisphereGroundColor: options.lighting?.hemisphereGroundColor ?? 7036754,
331
+ hemisphereIntensity: options.lighting?.hemisphereIntensity ?? preset.hemisphereIntensity
332
+ },
333
+ environment: {
334
+ hdrPath: options.environment?.hdrPath || "/baseHDR.hdr",
335
+ backgroundColor: options.environment?.backgroundColor || new THREE.Color(15790320),
336
+ enableEnvironmentLighting: options.environment?.enableEnvironmentLighting ?? true,
337
+ sceneUp: options.environment?.sceneUp || defaultUp,
338
+ showEnvironment: options.environment?.showEnvironment ?? false,
339
+ environmentIntensity: options.environment?.environmentIntensity ?? preset.environmentIntensity
340
+ },
341
+ floor: {
342
+ enabled: options.floor?.enabled ?? false,
343
+ size: options.floor?.size || defaults.floorSize,
344
+ color: options.floor?.color || new THREE.Color(8421504),
345
+ roughness: options.floor?.roughness ?? .7,
346
+ metalness: options.floor?.metalness ?? 0,
347
+ receiveShadow: options.floor?.receiveShadow ?? true
348
+ },
349
+ render: {
350
+ enableShadows: options.render?.enableShadows ?? true,
351
+ shadowMapSize: options.render?.shadowMapSize || 1024,
352
+ antialias: options.render?.antialias ?? true,
353
+ pixelRatio: options.render?.pixelRatio || Math.min(window.devicePixelRatio, 2),
354
+ toneMapping: options.render?.toneMapping ?? preset.toneMapping,
355
+ toneMappingExposure: options.render?.toneMappingExposure ?? preset.toneMappingExposure,
356
+ preserveDrawingBuffer: options.render?.preserveDrawingBuffer ?? false,
357
+ lowPower,
358
+ idleRefineFrames: options.render?.idleRefineFrames ?? (lowPower ? 0 : 32),
359
+ lightOrbitFrameMs: options.render?.lightOrbitFrameMs ?? 22,
360
+ ambientOcclusion: options.render?.ambientOcclusion ?? preset.ambientOcclusion,
361
+ aoIntensity: options.render?.aoIntensity ?? 1,
362
+ bloom: options.render?.bloom ?? preset.bloom,
363
+ aoPixelRatio: options.render?.aoPixelRatio ?? 1,
364
+ onDemand: options.render?.onDemand ?? true
365
+ },
366
+ controls: {
367
+ enableDamping: options.controls?.enableDamping ?? false,
368
+ dampingFactor: options.controls?.dampingFactor || .05,
369
+ autoRotate: options.controls?.autoRotate ?? false,
370
+ autoRotateSpeed: options.controls?.autoRotateSpeed || .5,
371
+ enableZoom: options.controls?.enableZoom ?? true,
372
+ enablePan: options.controls?.enablePan ?? true,
373
+ minDistance: options.controls?.minDistance || defaults.minDistance,
374
+ maxDistance: options.controls?.maxDistance || Infinity
375
+ },
376
+ grid: {
377
+ enabled: options.grid?.enabled ?? false,
378
+ cellSize: options.grid?.cellSize ?? 1,
379
+ majorEvery: options.grid?.majorEvery ?? 10,
380
+ cellColor: options.grid?.cellColor ?? 8947848,
381
+ majorColor: options.grid?.majorColor ?? 4473924,
382
+ fadeDistance: options.grid?.fadeDistance ?? 100,
383
+ plane: options.grid?.plane ?? upToAxis(options.environment?.sceneUp ?? defaultUp)
384
+ },
385
+ gizmo: { enabled: options.gizmo?.enabled ?? false },
386
+ edges: {
387
+ enabled: options.edges?.enabled ?? false,
388
+ color: options.edges?.color,
389
+ darken: options.edges?.darken,
390
+ width: options.edges?.width ?? 1.5,
391
+ thresholdAngle: options.edges?.thresholdAngle ?? 44,
392
+ distanceFade: options.edges?.distanceFade ?? true,
393
+ maxTriangles: options.edges?.maxTriangles,
394
+ maxSegments: options.edges?.maxSegments,
395
+ maxOverlays: options.edges?.maxOverlays,
396
+ screenSpaceFallback: options.edges?.screenSpaceFallback
397
+ },
398
+ measure: {
399
+ enabled: options.measure?.enabled ?? false,
400
+ snapPixels: options.measure?.snapPixels,
401
+ color: options.measure?.color,
402
+ labelClassName: options.measure?.labelClassName,
403
+ displayUnit: options.measure?.displayUnit,
404
+ format: options.measure?.format
405
+ },
406
+ events: {
407
+ onBackgroundClicked: options.events?.onBackgroundClicked,
408
+ onObjectSelected: options.events?.onObjectSelected,
409
+ onMeshMetadataClicked: options.events?.onMeshMetadataClicked,
410
+ onMeshDoubleClicked: options.events?.onMeshDoubleClicked,
411
+ selectionColor: options.events?.selectionColor || "#ff0000",
412
+ enableEventHandlers: options.events?.enableEventHandlers ?? true,
413
+ enableKeyboardControls: options.events?.enableKeyboardControls ?? true,
414
+ enableClickToFocus: options.events?.enableClickToFocus ?? true,
415
+ enableDoubleClickZoom: options.events?.enableDoubleClickZoom ?? true,
416
+ onReady: options.events?.onReady,
417
+ onFrame: options.events?.onFrame
418
+ },
419
+ onMaxAnisotropy: options.onMaxAnisotropy
420
+ };
421
+ }
422
+ // src/render/scene-setup/idle-refine.ts
423
+ /** Long enough to skip the pauses inside an orbit, or a drag would keep restarting the average. */
424
+ const SETTLE_MS = 150;
425
+ /** Ambient occlusion is the largest cost of a frame, and the only one that can fade without a pop. */
426
+ const AO_FADE_FRAMES = 8;
427
+ /** Enough moving frames that one hitch can't decide it. */
428
+ const ORBIT_SAMPLES = 12;
429
+ /** Low-discrepancy sequence in [0, 1): N samples cover the range far more evenly than random ones. */
430
+ function halton(index, base) {
431
+ let result = 0;
432
+ let fraction = 1 / base;
433
+ for (let i = index; i > 0; i = Math.floor(i / base)) {
434
+ result += i % base * fraction;
435
+ fraction /= base;
436
+ }
437
+ return result;
438
+ }
439
+ /** Counts what a forgotten `invalidate()` most likely changed: visibility and materials. */
440
+ function sceneStamp(scene) {
441
+ let stamp = 0;
442
+ scene.traverseVisible((object) => {
443
+ stamp += 1;
444
+ const { material } = object;
445
+ for (const each of Array.isArray(material) ? material : material ? [material] : []) stamp += each.version * 31;
446
+ });
447
+ return stamp;
448
+ }
449
+ /**
450
+ * Softens the picture while nothing moves. Each frame moves the camera by under a pixel and every
451
+ * light within its real size, and the average of those frames has true penumbras and clean edges at
452
+ * no cost to orbiting. The averaged image would be lost to the render loop's safety repaint, so
453
+ * while it holds, that repaint only checks whether the scene changed.
454
+ *
455
+ * It also keeps orbiting smooth on a slow GPU. Once frames run over `lightOrbitFrameMs`, ambient
456
+ * occlusion fades out while the camera moves and back in as it rests. Decided once, so a machine
457
+ * near the limit doesn't flip between the two, and a fast one never sees it.
458
+ */
459
+ function createIdleRefiner(params) {
460
+ const { scene, renderer, getPipeline, frames, lightOrbitFrameMs, jitterLights, restoreLights } = params;
461
+ let index = 0;
462
+ let changedAt = 0;
463
+ let stamp = 0;
464
+ const size = new THREE.Vector2();
465
+ let lightOrbit = frames > 0 && lightOrbitFrameMs <= 0;
466
+ let aoWeight = 1;
467
+ const orbitSamples = [];
468
+ const setAoWeight = (weight) => {
469
+ aoWeight = Math.min(1, Math.max(0, weight));
470
+ getPipeline()?.setAoWeight(aoWeight);
471
+ };
472
+ const measureOrbit = (deltaMs) => {
473
+ orbitSamples.push(deltaMs);
474
+ if (orbitSamples.length < ORBIT_SAMPLES) return;
475
+ orbitSamples.sort((a, b) => a - b);
476
+ lightOrbit = orbitSamples[6] > lightOrbitFrameMs;
477
+ orbitSamples.length = 0;
478
+ };
479
+ const jitterCamera = (camera, sample) => {
480
+ const lens = camera;
481
+ if (!lens.setViewOffset) return;
482
+ renderer.getDrawingBufferSize(size);
483
+ const x = sample ? halton(sample, 2) - .5 : 0;
484
+ const y = sample ? halton(sample, 3) - .5 : 0;
485
+ lens.setViewOffset(size.x, size.y, x, y, size.x, size.y);
486
+ };
487
+ return {
488
+ reset: (now) => {
489
+ index = 0;
490
+ changedAt = now;
491
+ },
492
+ moving: (deltaMs) => {
493
+ if (frames === 0) return;
494
+ if (lightOrbit) setAoWeight(aoWeight - 1 / AO_FADE_FRAMES);
495
+ else measureOrbit(deltaMs);
496
+ },
497
+ wants: (now) => (index < frames || aoWeight < 1) && now - changedAt >= SETTLE_MS && !!getPipeline(),
498
+ step: (camera) => {
499
+ const pipeline = getPipeline();
500
+ if (!pipeline) return;
501
+ if (aoWeight < 1) {
502
+ setAoWeight(aoWeight + 1 / AO_FADE_FRAMES);
503
+ pipeline.setCamera(camera);
504
+ pipeline.accumulate(0);
505
+ return;
506
+ }
507
+ if (index > 0) jitterLights(index);
508
+ jitterCamera(camera, index);
509
+ pipeline.setCamera(camera);
510
+ pipeline.accumulate(index);
511
+ camera.clearViewOffset?.();
512
+ if (index > 0) restoreLights();
513
+ index += 1;
514
+ if (index === frames) stamp = sceneStamp(scene);
515
+ },
516
+ present: () => {
517
+ if (frames === 0 || index < frames) return false;
518
+ getPipeline()?.presentAccumulated();
519
+ return !!getPipeline();
520
+ },
521
+ isHolding: () => frames > 0 && index >= frames && !!getPipeline() && stamp === sceneStamp(scene)
522
+ };
523
+ }
524
+ // src/render/scene-setup/setup-lighting.ts
525
+ const SHADOW_NORMAL_BIAS = .02;
526
+ /**
527
+ * In shadow-map texels, and the map always spans the model, so the penumbra stays about 1.5% of the
528
+ * model at any scale. A sharp edge reads as a sticker; a real sky never gives one.
529
+ */
530
+ const SUN_SHADOW_BLUR = 6;
531
+ /** Sky and bounce always fill a shadow in, so a fully dark one looks painted on. */
532
+ const SUN_SHADOW_DENSITY = .62;
533
+ /**
534
+ * How far the sun moves while the view refines, as a tangent: about 1 degree. The real sun's 0.27
535
+ * looks hard on a model this size, and much past 1 a small part's shadow dissolves completely.
536
+ */
537
+ const SUN_DISC_RADIUS = .018;
538
+ /** Survives a solve with the sun, which would otherwise aim at a target outside the scene. */
539
+ const SUN_TARGET_ID = "sun-target";
540
+ function setupLighting(scene, config) {
541
+ const ambient = new THREE.AmbientLight(config.lighting.ambientLightColor, config.lighting.ambientLightIntensity);
542
+ scene.add(ambient);
543
+ let hemisphere = null;
544
+ if (config.lighting.enableHemisphereLight) {
545
+ hemisphere = new THREE.HemisphereLight(config.lighting.hemisphereSkyColor, config.lighting.hemisphereGroundColor, config.lighting.hemisphereIntensity);
546
+ const up = config.environment.sceneUp ?? defaultUp;
547
+ hemisphere.position.copy(up);
548
+ scene.add(hemisphere);
549
+ }
550
+ if (!config.lighting.enableSunlight) return {
551
+ ambient,
552
+ hemisphere,
553
+ sun: null
554
+ };
555
+ const sunlight = new THREE.DirectionalLight(config.lighting.sunlightColor ?? 16777215, config.lighting.sunlightIntensity);
556
+ const pos = config.lighting.sunlightPosition;
557
+ if (pos) sunlight.position.set(pos.x, pos.y, pos.z);
558
+ if (!config.render.enableShadows) {
559
+ scene.add(sunlight);
560
+ return {
561
+ ambient,
562
+ hemisphere,
563
+ sun: null
564
+ };
565
+ }
566
+ sunlight.castShadow = true;
567
+ sunlight.shadow.mapSize.width = config.render.shadowMapSize;
568
+ sunlight.shadow.mapSize.height = config.render.shadowMapSize;
569
+ sunlight.shadow.bias = -1e-4;
570
+ sunlight.shadow.normalBias = SHADOW_NORMAL_BIAS;
571
+ sunlight.shadow.radius = SUN_SHADOW_BLUR;
572
+ sunlight.shadow.blurSamples = 12;
573
+ sunlight.shadow.intensity = SUN_SHADOW_DENSITY;
574
+ scene.add(sunlight);
575
+ sunlight.target.userData.id = SUN_TARGET_ID;
576
+ scene.add(sunlight.target);
577
+ return {
578
+ ambient,
579
+ hemisphere,
580
+ sun: sunlight
581
+ };
582
+ }
583
+ /** Moves the sun within its disc and returns how to put it back. */
584
+ function jitterSun(light, index) {
585
+ const home = light.position.clone();
586
+ const toSun = home.clone().sub(light.target.position);
587
+ const side = new THREE.Vector3(0, 0, 1).cross(toSun);
588
+ if (side.lengthSq() === 0) side.set(1, 0, 0);
589
+ side.setLength(toSun.length() * SUN_DISC_RADIUS);
590
+ const across = toSun.clone().cross(side).setLength(side.length());
591
+ const angle = halton(index, 2) * Math.PI * 2;
592
+ const reach = Math.sqrt(halton(index, 3));
593
+ light.position.addScaledVector(side, Math.cos(angle) * reach).addScaledVector(across, Math.sin(angle) * reach);
594
+ return () => light.position.copy(home);
595
+ }
596
+ /**
597
+ * Sizes a directional light's orthographic shadow frustum to the scene content's bounding sphere —
598
+ * the dominant lever on shadow crispness. No-op on an empty or zero-size box: the frustum would
599
+ * collapse to a point, and the sun would land on its target and lose its direction for good.
600
+ */
601
+ function fitShadowToContent(light, bounds) {
602
+ if (bounds.isEmpty()) return;
603
+ const center = bounds.getCenter(new THREE.Vector3());
604
+ const radius = bounds.getSize(new THREE.Vector3()).length() * .5 * 1.2;
605
+ if (radius === 0) return;
606
+ light.shadow.normalBias = Math.min(SHADOW_NORMAL_BIAS, radius * .0015);
607
+ const cam = light.shadow.camera;
608
+ cam.left = -radius;
609
+ cam.right = radius;
610
+ cam.top = radius;
611
+ cam.bottom = -radius;
612
+ const direction = light.position.clone().sub(light.target.position).normalize();
613
+ light.target.position.copy(center);
614
+ light.target.updateMatrixWorld();
615
+ const lightDistance = radius * 2;
616
+ light.position.copy(center).addScaledVector(direction, lightDistance);
617
+ light.updateMatrixWorld();
618
+ cam.near = lightDistance - radius;
619
+ cam.far = lightDistance + radius;
620
+ cam.updateProjectionMatrix();
621
+ }
622
+ // src/render/three-helpers.ts
623
+ const CAMERA_CONFIG = {
624
+ HUGE_THRESHOLD: 1e4,
625
+ LARGE_THRESHOLD: 1e3,
626
+ SCALE_RATIO_THRESHOLD: 100,
627
+ NEAR_PLANE_FACTOR: {
628
+ TINY: 1e-4,
629
+ SMALL: .001,
630
+ NORMAL: .01
631
+ },
632
+ FAR_PLANE_FACTOR: {
633
+ HUGE: 100,
634
+ LARGE: 50,
635
+ NORMAL: 20
636
+ },
637
+ INITIAL_DISTANCE_MULTIPLIER: 4
638
+ };
639
+ /** Replaces scene content with `meshes`, rescales the camera frustum to fit, and (first call only) positions the camera/controls. */
640
+ function updateScene(scene, meshes, camera, controls, initialPositionSet) {
641
+ clearScene(scene, cachedGeometriesOf(meshes));
642
+ if (meshes.length === 0) return;
643
+ meshes.forEach((mesh) => {
644
+ scene.add(mesh);
645
+ applyReflectionEnvironment(mesh, scene);
646
+ });
647
+ const unionBoundingBox = computeCombinedBoundingBox(meshes);
648
+ const center = unionBoundingBox.getCenter(new THREE.Vector3());
649
+ const size = unionBoundingBox.getSize(new THREE.Vector3());
650
+ const maxDim = Math.max(size.x, size.y, size.z);
651
+ if (maxDim / Math.min(size.x || 1, size.y || 1, size.z || 1) > CAMERA_CONFIG.SCALE_RATIO_THRESHOLD || maxDim > CAMERA_CONFIG.HUGE_THRESHOLD) {
652
+ camera.near = maxDim * CAMERA_CONFIG.NEAR_PLANE_FACTOR.TINY;
653
+ camera.far = maxDim * CAMERA_CONFIG.FAR_PLANE_FACTOR.HUGE;
654
+ } else if (maxDim > CAMERA_CONFIG.LARGE_THRESHOLD) {
655
+ camera.near = maxDim * CAMERA_CONFIG.NEAR_PLANE_FACTOR.SMALL;
656
+ camera.far = maxDim * CAMERA_CONFIG.FAR_PLANE_FACTOR.LARGE;
657
+ } else {
658
+ camera.near = Math.max(.01, maxDim * CAMERA_CONFIG.NEAR_PLANE_FACTOR.NORMAL);
659
+ camera.far = Math.max(2e3, maxDim * CAMERA_CONFIG.FAR_PLANE_FACTOR.NORMAL);
660
+ }
661
+ camera.updateProjectionMatrix();
662
+ if (!initialPositionSet) {
663
+ const distance = maxDim * CAMERA_CONFIG.INITIAL_DISTANCE_MULTIPLIER;
664
+ camera.position.copy(center).add(isoOffset(camera.up, distance));
665
+ controls.target.copy(center);
666
+ controls.update();
667
+ }
668
+ }
669
+ const VIEWER_AID_IDS = /* @__PURE__ */ new Set([
670
+ "grid",
671
+ "floor",
672
+ "label-layer",
673
+ "measure",
674
+ SUN_TARGET_ID
675
+ ]);
676
+ /** True if `object` itself roots a viewer aid; {@link isViewerAid} also checks ancestors. */
677
+ function isViewerAidRoot(object) {
678
+ return typeof object.userData.id === "string" && VIEWER_AID_IDS.has(object.userData.id);
679
+ }
680
+ /** True if `object` or any ancestor is a viewer aid (grid/floor/labels/measure). */
681
+ function isViewerAid(object) {
682
+ let current = object;
683
+ while (current) {
684
+ if (isViewerAidRoot(current)) return true;
685
+ current = current.parent;
686
+ }
687
+ return false;
688
+ }
689
+ /**
690
+ * Bounds of the scene's renderable content, excluding viewer aids (grid/floor/labels/measure).
691
+ * Shared by fit-to-view, pick-threshold scaling, camera framing (`setView`), and shadow-frustum
692
+ * fitting so they all measure exactly the same box.
693
+ */
694
+ function computeContentBounds(scene) {
695
+ scene.updateMatrixWorld(true);
696
+ const box = new THREE.Box3();
697
+ scene.traverse((object) => {
698
+ const renderable = object;
699
+ if (object.visible && !isViewerAid(object) && renderable.geometry) box.expandByObject(object);
700
+ });
701
+ return box;
702
+ }
703
+ function cachedGeometriesOf(objects) {
704
+ const cached = /* @__PURE__ */ new Set();
705
+ for (const root of objects) root.traverse((object) => {
706
+ const geometry = object.geometry;
707
+ const source = geometry && cachedSourceOf(geometry);
708
+ if (source) cached.add(source);
709
+ });
710
+ return cached;
711
+ }
712
+ const PERSISTENT_SCENE_IDS = /* @__PURE__ */ new Set([
713
+ "floor",
714
+ "grid",
715
+ "label-layer",
716
+ SUN_TARGET_ID
717
+ ]);
718
+ /** Frees the solve content. `keepUploaded` names the cached geometries the next content draws. */
719
+ function clearScene(scene, keepUploaded = /* @__PURE__ */ new Set()) {
720
+ const kept = [];
721
+ const removed = [];
722
+ for (const object of scene.children) if (object.isLight === true || PERSISTENT_SCENE_IDS.has(object.userData.id) || isHostOwned(object)) kept.push(object);
723
+ else removed.push(object);
724
+ scene.children.length = 0;
725
+ for (const object of kept) scene.children.push(object);
726
+ for (const object of removed) {
727
+ disposeObjectTree(object, { keepUploaded });
728
+ object.parent = null;
729
+ object.dispatchEvent({ type: "removed" });
730
+ scene.dispatchEvent({
731
+ type: "childremoved",
732
+ child: object
733
+ });
734
+ }
735
+ }
736
+ // src/render/camera-controller.ts
737
+ /**
738
+ * Seven preset view directions (target → camera, unit vectors), derived from `up` rather than a
739
+ * fixed Y-up table so Top/Front/… stay meaningful for Z-up Rhino scenes.
740
+ *
741
+ * `buildUpBasis`'s `forward` is camera→model; these are camera positions relative to target, so
742
+ * "front" is `-forward`. Flipping this puts the camera behind the model and swaps left/right.
743
+ */
744
+ function buildViewDirections(up) {
745
+ const { up: u, forward, right } = buildUpBasis(up);
746
+ const frontPosition = forward.clone().negate();
747
+ const rightPosition = right.clone();
748
+ return {
749
+ top: u.clone(),
750
+ bottom: u.clone().negate(),
751
+ front: frontPosition.clone(),
752
+ back: frontPosition.clone().negate(),
753
+ right: rightPosition.clone(),
754
+ left: rightPosition.clone().negate(),
755
+ iso: frontPosition.clone().multiplyScalar(1.2).add(rightPosition.clone()).add(u.clone()).normalize()
756
+ };
757
+ }
758
+ function createCameraController(deps) {
759
+ const { scene, perspective, controls, onActiveCameraChange } = deps;
760
+ const up = (deps.up ?? perspective.up).clone().normalize();
761
+ const VIEW_DIRECTIONS = buildViewDirections(up);
762
+ const ortho = new THREE.OrthographicCamera(-1, 1, 1, -1, perspective.near, perspective.far);
763
+ ortho.up.copy(up);
764
+ let projection = "perspective";
765
+ let aspect = perspective.aspect;
766
+ const active = () => projection === "perspective" ? perspective : ortho;
767
+ let activeTween = null;
768
+ const cancelTween = () => {
769
+ activeTween?.cancel();
770
+ activeTween = null;
771
+ };
772
+ const syncOrthoFrustum = () => {
773
+ const halfH = (projection === "orthographic" ? ortho : perspective).position.distanceTo(controls.target) * Math.tan(perspective.fov * Math.PI / 360);
774
+ const halfW = halfH * aspect;
775
+ ortho.left = -halfW;
776
+ ortho.right = halfW;
777
+ ortho.top = halfH;
778
+ ortho.bottom = -halfH;
779
+ ortho.near = perspective.near;
780
+ ortho.far = perspective.far;
781
+ ortho.updateProjectionMatrix();
782
+ };
783
+ const setProjection = (next) => {
784
+ if (next === projection) return;
785
+ cancelTween();
786
+ if (next === "orthographic") {
787
+ ortho.position.copy(perspective.position);
788
+ ortho.up.copy(perspective.up);
789
+ ortho.lookAt(controls.target);
790
+ ortho.zoom = 1;
791
+ syncOrthoFrustum();
792
+ } else {
793
+ const distance = (ortho.top - ortho.bottom) / (2 * ortho.zoom) / Math.tan(perspective.fov * Math.PI / 360);
794
+ const direction = ortho.position.clone().sub(controls.target);
795
+ if (direction.lengthSq() < 1e-12) direction.copy(up);
796
+ direction.normalize();
797
+ perspective.position.copy(controls.target).add(direction.multiplyScalar(distance));
798
+ }
799
+ projection = next;
800
+ controls.object = active();
801
+ controls.update();
802
+ onActiveCameraChange(active());
803
+ };
804
+ const frame = (center, maxDim, direction, animate) => {
805
+ const fov = perspective.fov * (Math.PI / 180);
806
+ const distance = maxDim / (2 * Math.tan(fov / 2)) * 1.5;
807
+ const dir = nudgeOffPole(direction, up);
808
+ const toPosition = center.clone().add(dir.clone().multiplyScalar(distance));
809
+ const cam = active();
810
+ if (projection === "orthographic") ortho.zoom = 1;
811
+ cancelTween();
812
+ if (animate) activeTween = animateMove(cam, controls, toPosition, center, () => {
813
+ if (projection === "orthographic") syncOrthoFrustum();
814
+ });
815
+ else {
816
+ cam.position.copy(toPosition);
817
+ controls.target.copy(center);
818
+ if (projection === "orthographic") syncOrthoFrustum();
819
+ controls.update();
820
+ }
821
+ };
822
+ const setViewDirection = (direction, animate = true) => {
823
+ const box = computeContentBounds(scene);
824
+ const center = box.isEmpty() ? controls.target.clone() : box.getCenter(new THREE.Vector3());
825
+ const size = box.isEmpty() ? new THREE.Vector3(1, 1, 1) : box.getSize(new THREE.Vector3());
826
+ const maxDim = Math.max(size.x, size.y, size.z) || 1;
827
+ frame(center, maxDim, direction, animate);
828
+ };
829
+ const frameBounds = (box, animate = true) => {
830
+ if (box.isEmpty()) return;
831
+ const center = box.getCenter(new THREE.Vector3());
832
+ const size = box.getSize(new THREE.Vector3());
833
+ const maxDim = Math.max(size.x, size.y, size.z) || 1;
834
+ const direction = active().position.clone().sub(controls.target);
835
+ if (direction.lengthSq() < 1e-12) direction.copy(VIEW_DIRECTIONS.iso);
836
+ frame(center, maxDim, direction.normalize(), animate);
837
+ };
838
+ const setView = (preset, animate = true) => {
839
+ setViewDirection(VIEW_DIRECTIONS[preset], animate);
840
+ };
841
+ const setRotateEnabled = (enabled) => {
842
+ controls.enableRotate = enabled;
843
+ };
844
+ const updateAspect = (width, height) => {
845
+ aspect = height === 0 ? aspect : width / height;
846
+ if (projection === "orthographic") syncOrthoFrustum();
847
+ };
848
+ return {
849
+ getActiveCamera: active,
850
+ getProjection: () => projection,
851
+ setProjection,
852
+ toggleProjection: () => {
853
+ setProjection(projection === "perspective" ? "orthographic" : "perspective");
854
+ return projection;
855
+ },
856
+ setView,
857
+ setViewDirection,
858
+ frameBounds,
859
+ setRotateEnabled,
860
+ isRotateEnabled: () => controls.enableRotate,
861
+ updateAspect,
862
+ dispose: cancelTween
863
+ };
864
+ }
865
+ /**
866
+ * Nudges a top/bottom view direction a ~0.5° tilt off the up axis; other presets pass through
867
+ * unchanged. Looking exactly down `up` is an OrbitControls singularity: camera direction coincides
868
+ * with `camera.up`, azimuth is undefined, and the first drag snaps the view.
869
+ *
870
+ * At the pole, `camera.up` can't define roll, so the tilt direction does instead. Both poles lean
871
+ * toward `-forward` to reproduce Rhino's convention (Top has +forward at screen-top; Bottom mirrors
872
+ * about the horizontal axis, matching Rhino where the far side reads backwards too) — leaning the
873
+ * poles opposite ways also mirrors correctly, but rolled 180° from Rhino.
874
+ */
875
+ function nudgeOffPole(dir, up) {
876
+ const { up: u, forward } = buildUpBasis(up);
877
+ const d = dir.clone().normalize();
878
+ if (Math.abs(d.dot(u)) < .9999) return dir;
879
+ const inPlane = forward.clone().negate();
880
+ const tilt = .5 * Math.PI / 180;
881
+ return d.multiplyScalar(Math.cos(tilt)).add(inPlane.multiplyScalar(Math.sin(tilt))).normalize();
882
+ }
883
+ const easeOut = (t) => 1 - Math.pow(1 - t, 3);
884
+ /** Tweens camera position + controls target; returns a cancel handle for teardown/preemption. */
885
+ function animateMove(camera, controls, toPosition, toTarget, onTick, durationMs = 250) {
886
+ const fromPosition = camera.position.clone();
887
+ const fromTarget = controls.target.clone();
888
+ const startTime = performance.now();
889
+ let rafId = null;
890
+ const tick = () => {
891
+ rafId = null;
892
+ const t = easeOut(Math.min((performance.now() - startTime) / durationMs, 1));
893
+ camera.position.lerpVectors(fromPosition, toPosition, t);
894
+ controls.target.lerpVectors(fromTarget, toTarget, t);
895
+ onTick();
896
+ controls.update();
897
+ if (t < 1) rafId = requestAnimationFrame(tick);
898
+ };
899
+ rafId = requestAnimationFrame(tick);
900
+ return { cancel: () => {
901
+ if (rafId !== null) {
902
+ cancelAnimationFrame(rafId);
903
+ rafId = null;
904
+ }
905
+ } };
906
+ }
907
+ // src/render/edges/line-geometry.ts
908
+ const SPACING_PERCENTILE = .15;
909
+ const SPACING_SAMPLE_LIMIT = 4096;
910
+ function edgeSpacingOf(segments) {
911
+ const segmentCount = Math.floor(segments.length / 6);
912
+ if (segmentCount === 0) return Infinity;
913
+ const stride = Math.max(1, Math.ceil(segmentCount / SPACING_SAMPLE_LIMIT));
914
+ const lengths = [];
915
+ for (let s = 0; s < segmentCount; s += stride) {
916
+ const i = s * 6;
917
+ const length = Math.hypot(segments[i + 3] - segments[i], segments[i + 4] - segments[i + 1], segments[i + 5] - segments[i + 2]);
918
+ if (length > 0) lengths.push(length);
919
+ }
920
+ if (lengths.length === 0) return Infinity;
921
+ lengths.sort((a, b) => a - b);
922
+ return lengths[Math.min(lengths.length - 1, Math.floor(lengths.length * SPACING_PERCENTILE))];
923
+ }
924
+ function buildLineGeometry(segments) {
925
+ const geometry = new LineSegmentsGeometry();
926
+ geometry.setPositions(segments);
927
+ return {
928
+ geometry,
929
+ segmentCount: segments.length / 6,
930
+ edgeSpacing: edgeSpacingOf(segments)
931
+ };
932
+ }
933
+ /**
934
+ * @param index - Triangle indices, or null for non-indexed soup.
935
+ * @returns `segments`: endpoint pairs, same layout as `EdgesGeometry.attributes.position.array`.
936
+ * `faces`: per segment, the triangle it came from, so a merged mesh can drop a hidden member's
937
+ * edges without extracting again.
938
+ * @throws If `positions` holds ≥ 2^26 vertices ({@link MAX_EXTRACT_VERTICES}) — callers fall
939
+ * back to `THREE.EdgesGeometry`.
940
+ */
941
+ function extractEdgeSegments(positions, index, thresholdAngleDeg) {
942
+ const PRECISION = 1e4;
943
+ const ID_BITS = 67108864;
944
+ const thresholdDot = Math.cos(Math.PI / 180 * thresholdAngleDeg);
945
+ const vertexCount = positions.length / 3;
946
+ if (vertexCount >= ID_BITS) throw new Error(`extractEdgeSegments: ${vertexCount} vertices exceeds 2^26 limit`);
947
+ const quantX = new Float64Array(vertexCount);
948
+ const quantY = new Float64Array(vertexCount);
949
+ const quantZ = new Float64Array(vertexCount);
950
+ for (let v = 0; v < vertexCount; v++) {
951
+ quantX[v] = Math.round(positions[3 * v] * PRECISION);
952
+ quantY[v] = Math.round(positions[3 * v + 1] * PRECISION);
953
+ quantZ[v] = Math.round(positions[3 * v + 2] * PRECISION);
954
+ }
955
+ let capacity = 16;
956
+ while (capacity < vertexCount * 2) capacity <<= 1;
957
+ const mask = capacity - 1;
958
+ const table = new Int32Array(capacity).fill(-1);
959
+ const canonical = new Int32Array(vertexCount);
960
+ for (let v = 0; v < vertexCount; v++) {
961
+ let slot = (Math.imul(quantX[v] | 0, 73856093) ^ Math.imul(quantY[v] | 0, 19349663) ^ Math.imul(quantZ[v] | 0, 83492791)) & mask;
962
+ for (;;) {
963
+ const existing = table[slot];
964
+ if (existing === -1) {
965
+ table[slot] = v;
966
+ canonical[v] = v;
967
+ break;
968
+ }
969
+ if (quantX[existing] === quantX[v] && quantY[existing] === quantY[v] && quantZ[existing] === quantZ[v]) {
970
+ canonical[v] = existing;
971
+ break;
972
+ }
973
+ slot = slot + 1 & mask;
974
+ }
975
+ }
976
+ let out = /* @__PURE__ */ new Float32Array(6144);
977
+ let outLength = 0;
978
+ let outFaces = /* @__PURE__ */ new Uint32Array(1024);
979
+ const emit = (i0, i1, face) => {
980
+ if (outLength + 6 > out.length) {
981
+ const grown = new Float32Array(out.length * 2);
982
+ grown.set(out);
983
+ out = grown;
984
+ const grownFaces = new Uint32Array(out.length / 6);
985
+ grownFaces.set(outFaces);
986
+ outFaces = grownFaces;
987
+ }
988
+ outFaces[outLength / 6] = face;
989
+ out[outLength++] = positions[3 * i0];
990
+ out[outLength++] = positions[3 * i0 + 1];
991
+ out[outLength++] = positions[3 * i0 + 2];
992
+ out[outLength++] = positions[3 * i1];
993
+ out[outLength++] = positions[3 * i1 + 1];
994
+ out[outLength++] = positions[3 * i1 + 2];
995
+ };
996
+ const triCount = (index ? index.length : vertexCount) / 3;
997
+ const rowStart = new Int32Array(vertexCount + 1);
998
+ for (let t = 0; t < triCount; t++) {
999
+ const a = canonical[index ? index[3 * t] : 3 * t];
1000
+ const b = canonical[index ? index[3 * t + 1] : 3 * t + 1];
1001
+ const c = canonical[index ? index[3 * t + 2] : 3 * t + 2];
1002
+ if (a === b || b === c || c === a) continue;
1003
+ rowStart[a + 1]++;
1004
+ rowStart[b + 1]++;
1005
+ rowStart[c + 1]++;
1006
+ }
1007
+ for (let v = 0; v < vertexCount; v++) rowStart[v + 1] += rowStart[v];
1008
+ const cellTo = new Int32Array(rowStart[vertexCount]);
1009
+ {
1010
+ const cursor = rowStart.slice(0, vertexCount);
1011
+ for (let t = 0; t < triCount; t++) {
1012
+ const a = canonical[index ? index[3 * t] : 3 * t];
1013
+ const b = canonical[index ? index[3 * t + 1] : 3 * t + 1];
1014
+ const c = canonical[index ? index[3 * t + 2] : 3 * t + 2];
1015
+ if (a === b || b === c || c === a) continue;
1016
+ cellTo[cursor[a]++] = b;
1017
+ cellTo[cursor[b]++] = c;
1018
+ cellTo[cursor[c]++] = a;
1019
+ }
1020
+ }
1021
+ const UNSEEN = -2;
1022
+ const MATCHED = -1;
1023
+ const cellState = new Int32Array(cellTo.length).fill(UNSEEN);
1024
+ const findCell = (from, to) => {
1025
+ for (let k = rowStart[from], end = rowStart[from + 1]; k < end; k++) if (cellTo[k] === to) return k;
1026
+ return -1;
1027
+ };
1028
+ const cornerOf = (t, k) => index ? index[3 * t + k] : 3 * t + k;
1029
+ const normal = /* @__PURE__ */ new Float64Array(3);
1030
+ const faceNormal = (t) => {
1031
+ const i0 = cornerOf(t, 0);
1032
+ const i1 = cornerOf(t, 1);
1033
+ const i2 = cornerOf(t, 2);
1034
+ const e0x = positions[3 * i2] - positions[3 * i1];
1035
+ const e0y = positions[3 * i2 + 1] - positions[3 * i1 + 1];
1036
+ const e0z = positions[3 * i2 + 2] - positions[3 * i1 + 2];
1037
+ const e1x = positions[3 * i0] - positions[3 * i1];
1038
+ const e1y = positions[3 * i0 + 1] - positions[3 * i1 + 1];
1039
+ const e1z = positions[3 * i0 + 2] - positions[3 * i1 + 2];
1040
+ let nx = e0y * e1z - e0z * e1y;
1041
+ let ny = e0z * e1x - e0x * e1z;
1042
+ let nz = e0x * e1y - e0y * e1x;
1043
+ const lengthSq = nx * nx + ny * ny + nz * nz;
1044
+ if (lengthSq > 0) {
1045
+ const inverseLength = 1 / Math.sqrt(lengthSq);
1046
+ nx *= inverseLength;
1047
+ ny *= inverseLength;
1048
+ nz *= inverseLength;
1049
+ } else {
1050
+ nx = 0;
1051
+ ny = 0;
1052
+ nz = 0;
1053
+ }
1054
+ normal[0] = nx;
1055
+ normal[1] = ny;
1056
+ normal[2] = nz;
1057
+ };
1058
+ let pendingCount = 0;
1059
+ let pendingCorners = new Uint32Array(Math.max(16, cellTo.length >> 1));
1060
+ let pendingCells = new Int32Array(pendingCorners.length);
1061
+ const growPending = () => {
1062
+ const corners = new Uint32Array(pendingCorners.length * 2);
1063
+ corners.set(pendingCorners);
1064
+ pendingCorners = corners;
1065
+ const cells = new Int32Array(corners.length);
1066
+ cells.set(pendingCells);
1067
+ pendingCells = cells;
1068
+ };
1069
+ for (let t = 0; t < triCount; t++) {
1070
+ const i0 = cornerOf(t, 0);
1071
+ const i1 = cornerOf(t, 1);
1072
+ const i2 = cornerOf(t, 2);
1073
+ const a = canonical[i0];
1074
+ const b = canonical[i1];
1075
+ const c = canonical[i2];
1076
+ if (a === b || b === c || c === a) continue;
1077
+ let haveNormal = false;
1078
+ let nx = 0;
1079
+ let ny = 0;
1080
+ let nz = 0;
1081
+ for (let j = 0; j < 3; j++) {
1082
+ const fromCanonical = j === 0 ? a : j === 1 ? b : c;
1083
+ const toCanonical = j === 0 ? b : j === 1 ? c : a;
1084
+ const reverseCell = findCell(toCanonical, fromCanonical);
1085
+ const reverseSlot = reverseCell === -1 ? UNSEEN : cellState[reverseCell];
1086
+ if (reverseSlot >= 0) {
1087
+ if (!haveNormal) {
1088
+ faceNormal(t);
1089
+ nx = normal[0];
1090
+ ny = normal[1];
1091
+ nz = normal[2];
1092
+ haveNormal = true;
1093
+ }
1094
+ faceNormal(Math.floor(pendingCorners[reverseSlot] / 3));
1095
+ if (nx * normal[0] + ny * normal[1] + nz * normal[2] <= thresholdDot) emit(j === 0 ? i0 : j === 1 ? i1 : i2, j === 0 ? i1 : j === 1 ? i2 : i0, t);
1096
+ cellState[reverseCell] = MATCHED;
1097
+ } else {
1098
+ const forwardCell = findCell(fromCanonical, toCanonical);
1099
+ if (cellState[forwardCell] === UNSEEN) {
1100
+ if (pendingCount === pendingCorners.length) growPending();
1101
+ const slot = pendingCount++;
1102
+ cellState[forwardCell] = slot;
1103
+ pendingCorners[slot] = 3 * t + j;
1104
+ pendingCells[slot] = forwardCell;
1105
+ }
1106
+ }
1107
+ }
1108
+ }
1109
+ for (let slot = 0; slot < pendingCount; slot++) {
1110
+ if (cellState[pendingCells[slot]] !== slot) continue;
1111
+ const corner = pendingCorners[slot];
1112
+ const t = Math.floor(corner / 3);
1113
+ const j = corner - 3 * t;
1114
+ emit(cornerOf(t, j), cornerOf(t, j === 2 ? 0 : j + 1), t);
1115
+ }
1116
+ return {
1117
+ segments: out.slice(0, outLength),
1118
+ faces: outFaces.slice(0, outLength / 6)
1119
+ };
1120
+ }
1121
+ /**
1122
+ * Worker source running {@link extractEdgeSegments} off the main thread. Protocol: receives
1123
+ * `{id, positions, index, thresholdAngle}`, replies `{id, segments, faces}` (buffers transferred) or
1124
+ * `{id, error}`.
1125
+ *
1126
+ * Relies on `extractEdgeSegments` stringifying to standalone code — guarded by a unit test that
1127
+ * evals this source in isolation.
1128
+ */
1129
+ function edgeExtractWorkerSource() {
1130
+ return [
1131
+ `const extract = ${extractEdgeSegments.toString()};`,
1132
+ `self.onmessage = (event) => {`,
1133
+ ` const { id, positions, index, thresholdAngle } = event.data;`,
1134
+ ` try {`,
1135
+ ` const { segments, faces } = extract(positions, index, thresholdAngle);`,
1136
+ ` self.postMessage({ id, segments, faces }, [segments.buffer, faces.buffer]);`,
1137
+ ` } catch (error) {`,
1138
+ ` self.postMessage({ id, error: String((error && error.message) || error) });`,
1139
+ ` }`,
1140
+ `};`
1141
+ ].join("\n");
1142
+ }
1143
+ // src/render/edges/options.ts
1144
+ /** Tag on edge overlays so pick/fit/clear logic can recognize and skip or dispose them. */
1145
+ const EDGE_USERDATA_KIND = "edge-overlay";
1146
+ /** `userData.edgesSkipped` value; see {@link EdgeOptions.maxTriangles}. */
1147
+ const EDGES_SKIPPED_TRIANGLE_CAP = "triangle-cap";
1148
+ /** `userData.edgesSkipped` value; see {@link EdgeOptions.maxOverlays}. */
1149
+ const EDGES_SKIPPED_OVERLAY_BUDGET = "overlay-budget";
1150
+ const DEFAULT_EDGE_COLOR = 2236962;
1151
+ const DEFAULT_EDGE_WIDTH = 1.5;
1152
+ const DEFAULT_THRESHOLD_ANGLE = 44;
1153
+ const DEFAULT_DARKEN = .75;
1154
+ const DEFAULT_MAX_TRIANGLES = 8e6;
1155
+ const DEFAULT_MAX_SEGMENTS = 2e6;
1156
+ const DEFAULT_MAX_OVERLAYS = 1500;
1157
+ function resolveOptions(options) {
1158
+ return {
1159
+ forcedColor: options.color != null ? new THREE.Color(options.color) : null,
1160
+ darken: THREE.MathUtils.clamp(options.darken ?? DEFAULT_DARKEN, 0, 1),
1161
+ width: options.width ?? DEFAULT_EDGE_WIDTH,
1162
+ thresholdAngle: options.thresholdAngle ?? DEFAULT_THRESHOLD_ANGLE,
1163
+ distanceFade: options.distanceFade ?? true,
1164
+ maxTriangles: options.maxTriangles ?? DEFAULT_MAX_TRIANGLES,
1165
+ maxSegments: options.maxSegments ?? DEFAULT_MAX_SEGMENTS,
1166
+ maxOverlays: options.maxOverlays ?? DEFAULT_MAX_OVERLAYS
1167
+ };
1168
+ }
1169
+ // src/render/edges/extraction.ts
1170
+ function triangleCountOf(geometry) {
1171
+ const position = geometry.getAttribute("position");
1172
+ if (!position) return 0;
1173
+ return (geometry.index ? geometry.index.count : position.count) / 3;
1174
+ }
1175
+ function fastPathData(geometry) {
1176
+ const position = geometry.getAttribute("position");
1177
+ if (!position || position.isInterleavedBufferAttribute || position.itemSize !== 3 || !(position.array instanceof Float32Array) || position.count >= 67108864) return null;
1178
+ const index = geometry.index;
1179
+ if (index && !(index.array instanceof Uint32Array) && !(index.array instanceof Uint16Array)) return null;
1180
+ return {
1181
+ positions: position.array,
1182
+ index: index ? index.array : null,
1183
+ version: `${position.version}:${index?.version ?? 0}`
1184
+ };
1185
+ }
1186
+ function contentKey(data, thresholdAngle) {
1187
+ const SAMPLE_WORDS = 4096;
1188
+ let hash = 2166136261;
1189
+ const mix = (word) => {
1190
+ hash ^= word;
1191
+ hash = Math.imul(hash, 16777619);
1192
+ };
1193
+ const words = new Uint32Array(data.positions.buffer, data.positions.byteOffset, data.positions.length);
1194
+ const head = Math.min(SAMPLE_WORDS, words.length);
1195
+ for (let i = 0; i < head; i++) mix(words[i]);
1196
+ for (let i = Math.max(head, words.length - SAMPLE_WORDS); i < words.length; i++) mix(words[i]);
1197
+ let indexLength = 0;
1198
+ if (data.index) {
1199
+ indexLength = data.index.length;
1200
+ const headIndex = Math.min(SAMPLE_WORDS, indexLength);
1201
+ for (let i = 0; i < headIndex; i++) mix(data.index[i]);
1202
+ for (let i = Math.max(headIndex, indexLength - SAMPLE_WORDS); i < indexLength; i++) mix(data.index[i]);
1203
+ }
1204
+ return `${thresholdAngle}:${data.positions.length}:${indexLength}:${hash >>> 0}`;
1205
+ }
1206
+ function extractViaThree(geometry, thresholdAngle) {
1207
+ const edges = new THREE.EdgesGeometry(geometry, thresholdAngle);
1208
+ const positions = edges.attributes.position ? edges.attributes.position.array : /* @__PURE__ */ new Float32Array(0);
1209
+ edges.dispose();
1210
+ return {
1211
+ segments: positions,
1212
+ faces: null
1213
+ };
1214
+ }
1215
+ const extracted = /* @__PURE__ */ new WeakMap();
1216
+ const NO_INDEX = {};
1217
+ function recall(data, thresholdAngle) {
1218
+ const known = extracted.get(data.positions)?.get(data.index ?? NO_INDEX)?.get(thresholdAngle);
1219
+ return known?.version === data.version ? known.edges : void 0;
1220
+ }
1221
+ function remember(data, thresholdAngle, edges) {
1222
+ let byIndex = extracted.get(data.positions);
1223
+ if (!byIndex) extracted.set(data.positions, byIndex = /* @__PURE__ */ new WeakMap());
1224
+ let byAngle = byIndex.get(data.index ?? NO_INDEX);
1225
+ if (!byAngle) byIndex.set(data.index ?? NO_INDEX, byAngle = /* @__PURE__ */ new Map());
1226
+ byAngle.set(thresholdAngle, {
1227
+ version: data.version,
1228
+ edges
1229
+ });
1230
+ return edges;
1231
+ }
1232
+ function extractSegmentsSync(geometry, thresholdAngle) {
1233
+ const data = fastPathData(geometry);
1234
+ if (!data) return extractViaThree(geometry, thresholdAngle);
1235
+ return recall(data, thresholdAngle) ?? remember(data, thresholdAngle, extractEdgeSegments(data.positions, data.index, thresholdAngle));
1236
+ }
1237
+ let extractionWorker;
1238
+ const pendingRequests = /* @__PURE__ */ new Map();
1239
+ let nextRequestId = 1;
1240
+ function getExtractionWorker() {
1241
+ if (extractionWorker !== void 0) return extractionWorker;
1242
+ if (typeof Worker === "undefined" || typeof Blob === "undefined" || typeof URL === "undefined" || typeof URL.createObjectURL !== "function") {
1243
+ extractionWorker = null;
1244
+ return null;
1245
+ }
1246
+ try {
1247
+ const url = URL.createObjectURL(new Blob([edgeExtractWorkerSource()], { type: "text/javascript" }));
1248
+ const worker = new Worker(url);
1249
+ worker.onmessage = (event) => {
1250
+ const { id, segments, faces, error } = event.data;
1251
+ const pending = pendingRequests.get(id);
1252
+ if (!pending) return;
1253
+ pendingRequests.delete(id);
1254
+ if (segments) pending.resolve({
1255
+ segments,
1256
+ faces: faces ?? null
1257
+ });
1258
+ else pending.reject(new Error(error ?? "edge extraction failed in worker"));
1259
+ };
1260
+ worker.onerror = () => {
1261
+ for (const pending of pendingRequests.values()) pending.reject(/* @__PURE__ */ new Error("edge extraction worker crashed"));
1262
+ pendingRequests.clear();
1263
+ worker.terminate();
1264
+ extractionWorker = null;
1265
+ };
1266
+ extractionWorker = worker;
1267
+ } catch {
1268
+ extractionWorker = null;
1269
+ }
1270
+ return extractionWorker;
1271
+ }
1272
+ function extractInWorker(worker, data, thresholdAngle) {
1273
+ return new Promise((resolve, reject) => {
1274
+ const id = nextRequestId++;
1275
+ pendingRequests.set(id, {
1276
+ resolve,
1277
+ reject
1278
+ });
1279
+ const positions = data.positions.slice();
1280
+ const index = data.index ? data.index.slice() : null;
1281
+ const transfer = [positions.buffer];
1282
+ if (index) transfer.push(index.buffer);
1283
+ worker.postMessage({
1284
+ id,
1285
+ positions,
1286
+ index,
1287
+ thresholdAngle
1288
+ }, transfer);
1289
+ });
1290
+ }
1291
+ const inFlightExtractions = /* @__PURE__ */ new Map();
1292
+ function extractSegmentsAsync(geometry, thresholdAngle) {
1293
+ const data = fastPathData(geometry);
1294
+ if (!data || triangleCountOf(geometry) < 25e3) return Promise.resolve(extractSegmentsSync(geometry, thresholdAngle));
1295
+ const known = recall(data, thresholdAngle);
1296
+ if (known) return Promise.resolve(known);
1297
+ const key = contentKey(data, thresholdAngle);
1298
+ const inFlight = inFlightExtractions.get(key);
1299
+ if (inFlight) return inFlight;
1300
+ const worker = getExtractionWorker();
1301
+ if (!worker) return Promise.resolve(extractSegmentsSync(geometry, thresholdAngle));
1302
+ const request = extractInWorker(worker, data, thresholdAngle).catch(() => extractEdgeSegments(data.positions, data.index, thresholdAngle)).then((edges) => remember(data, thresholdAngle, edges)).finally(() => {
1303
+ inFlightExtractions.delete(key);
1304
+ });
1305
+ inFlightExtractions.set(key, request);
1306
+ return request;
1307
+ }
1308
+ // src/render/scene-setup/merged-picking.ts
1309
+ /** Members of a merged mesh, or null for an ordinary one. */
1310
+ function membersOf(object) {
1311
+ const members = object.userData?.members;
1312
+ return Array.isArray(members) && members.length > 0 ? members : null;
1313
+ }
1314
+ /**
1315
+ * The source object a raycast hit landed on, or null when this isn't a merged mesh or the hit
1316
+ * carries no face (points/lines, or a member written without an index window).
1317
+ *
1318
+ * Members are laid out in ascending, contiguous index order, so this is a binary search rather
1319
+ * than a scan — a merged group can hold thousands of members and picking runs per click.
1320
+ */
1321
+ function resolveHitMember(hit) {
1322
+ const members = membersOf(hit.object);
1323
+ if (!members || hit.faceIndex == null) return null;
1324
+ const hitIndex = hit.faceIndex * 3;
1325
+ let low = 0;
1326
+ let high = members.length - 1;
1327
+ while (low <= high) {
1328
+ const mid = low + high >> 1;
1329
+ const member = members[mid];
1330
+ if (member.indexStart == null || member.indexCount == null) return null;
1331
+ if (hitIndex < member.indexStart) high = mid - 1;
1332
+ else if (hitIndex >= member.indexStart + member.indexCount) low = mid + 1;
1333
+ else return {
1334
+ member,
1335
+ index: mid
1336
+ };
1337
+ }
1338
+ return null;
1339
+ }
1340
+ /**
1341
+ * World-space bounds of one member inside a merged mesh, walking only that member's index window.
1342
+ * `Box3.setFromObject` would return the whole merged group's bounds.
1343
+ */
1344
+ function memberBounds(mesh, member) {
1345
+ const box = new THREE.Box3();
1346
+ const index = mesh.geometry.getIndex();
1347
+ const position = mesh.geometry.getAttribute("position");
1348
+ if (!index || !position || member.indexStart == null || member.indexCount == null) return box;
1349
+ mesh.updateMatrixWorld();
1350
+ const vertex = new THREE.Vector3();
1351
+ const end = Math.min(member.indexStart + member.indexCount, index.count);
1352
+ for (let i = member.indexStart; i < end; i++) {
1353
+ vertex.fromBufferAttribute(position, index.getX(i)).applyMatrix4(mesh.matrixWorld);
1354
+ box.expandByPoint(vertex);
1355
+ }
1356
+ return box;
1357
+ }
1358
+ /**
1359
+ * Draws members of a merged mesh with `highlight`. Returns a restore function; call it before
1360
+ * applying a different highlight. Members hidden by the outliner stay hidden either way.
1361
+ */
1362
+ function highlightMembers(mesh, members, highlight, kind) {
1363
+ setMemberHighlight(mesh, members, highlight, kind);
1364
+ return () => clearMemberHighlight(mesh, kind);
1365
+ }
1366
+ // src/render/edges/member-filter.ts
1367
+ /** Per segment, the index of the member whose index window holds its source triangle. */
1368
+ function memberOfSegments(mesh, faces) {
1369
+ const members = membersOf(mesh);
1370
+ if (!members) return null;
1371
+ const starts = [];
1372
+ for (const member of members) {
1373
+ if (member.indexStart == null) return null;
1374
+ starts.push(member.indexStart);
1375
+ }
1376
+ const result = new Uint32Array(faces.length);
1377
+ for (let s = 0; s < faces.length; s++) {
1378
+ const indexPosition = faces[s] * 3;
1379
+ let low = 0;
1380
+ let high = starts.length - 1;
1381
+ while (low < high) {
1382
+ const mid = low + high + 1 >> 1;
1383
+ if (starts[mid] <= indexPosition) low = mid;
1384
+ else high = mid - 1;
1385
+ }
1386
+ result[s] = low;
1387
+ }
1388
+ return result;
1389
+ }
1390
+ function visibleSegments(segments, memberOf, hidden) {
1391
+ let count = 0;
1392
+ for (let s = 0; s < memberOf.length; s++) if (!hidden.has(memberOf[s])) count++;
1393
+ const out = new Float32Array(count * 6);
1394
+ let offset = 0;
1395
+ for (let s = 0; s < memberOf.length; s++) {
1396
+ if (hidden.has(memberOf[s])) continue;
1397
+ out.set(segments.subarray(s * 6, s * 6 + 6), offset);
1398
+ offset += 6;
1399
+ }
1400
+ return out;
1401
+ }
1402
+ /** Keeps `overlay` in step with the hidden members of `mesh`, its parent. */
1403
+ function followHiddenMembers(overlay, mesh, edges) {
1404
+ const { segments, faces } = edges;
1405
+ if (!faces || !membersOf(mesh)) return;
1406
+ let memberOf;
1407
+ let filtered = false;
1408
+ const apply = () => {
1409
+ const hidden = getHiddenMembers(mesh);
1410
+ if (hidden.size === 0 && !filtered) return;
1411
+ memberOf ??= memberOfSegments(mesh, faces);
1412
+ if (!memberOf) return;
1413
+ const next = hidden.size === 0 ? segments : visibleSegments(segments, memberOf, hidden);
1414
+ overlay.geometry.dispose();
1415
+ overlay.geometry = buildLineGeometry(next).geometry;
1416
+ filtered = hidden.size > 0;
1417
+ };
1418
+ apply();
1419
+ const unsubscribe = onMemberDrawChange(mesh, apply);
1420
+ overlay.addEventListener("removed", unsubscribe);
1421
+ }
1422
+ // src/render/edges/overlay.ts
1423
+ function deriveEdgeColor(mesh, darken) {
1424
+ const source = (Array.isArray(mesh.material) ? mesh.material[0] : mesh.material)?.color;
1425
+ if (!source) return new THREE.Color(DEFAULT_EDGE_COLOR);
1426
+ return source.clone().multiplyScalar(1 - darken);
1427
+ }
1428
+ /** Pools materials by color+fade so overlays sharing both share one `LineMaterial` instance. */
1429
+ var MaterialPool = class {
1430
+ options;
1431
+ byKey = /* @__PURE__ */ new Map();
1432
+ constructor(options) {
1433
+ this.options = options;
1434
+ }
1435
+ for(mesh, fade) {
1436
+ const color = this.options.forcedColor ?? deriveEdgeColor(mesh, this.options.darken);
1437
+ const key = color.getHex() * 2 + (fade ? 1 : 0);
1438
+ let material = this.byKey.get(key);
1439
+ if (!material) {
1440
+ material = createEdgeMaterial(color, this.options.width, fade);
1441
+ this.byKey.set(key, material);
1442
+ }
1443
+ return material;
1444
+ }
1445
+ /** Dispose any material no overlay adopted (e.g. every mesh was skipped or cancelled). */
1446
+ disposeUnused(created) {
1447
+ const used = new Set(created.map((overlay) => overlay.material));
1448
+ for (const material of this.byKey.values()) if (!used.has(material)) material.dispose();
1449
+ }
1450
+ };
1451
+ function createEdgeMaterial(color, width, distanceFade) {
1452
+ const material = new LineMaterial({ color });
1453
+ material.linewidth = width;
1454
+ material.polygonOffset = true;
1455
+ material.polygonOffsetFactor = 0;
1456
+ material.polygonOffsetUnits = -1;
1457
+ if (distanceFade) material.transparent = true;
1458
+ return material;
1459
+ }
1460
+ function buildEdgeOverlay(entry, material, distanceFade) {
1461
+ const overlay = new LineSegments2(entry.geometry, material);
1462
+ overlay.userData.kind = EDGE_USERDATA_KIND;
1463
+ overlay.raycast = () => {};
1464
+ if (distanceFade) enableDistanceFade(overlay, entry.edgeSpacing);
1465
+ return overlay;
1466
+ }
1467
+ const _fadeCenter = new THREE.Vector3();
1468
+ const _fadeCameraPos = new THREE.Vector3();
1469
+ function pixelsPerWorldUnit(overlay, camera, viewportHeightPx) {
1470
+ if (!overlay.geometry.boundingSphere) overlay.geometry.computeBoundingSphere();
1471
+ const sphere = overlay.geometry.boundingSphere;
1472
+ if (!sphere) return Infinity;
1473
+ if (camera.isPerspectiveCamera) {
1474
+ const perspective = camera;
1475
+ _fadeCenter.copy(sphere.center).applyMatrix4(overlay.matrixWorld);
1476
+ const distance = _fadeCameraPos.setFromMatrixPosition(camera.matrixWorld).distanceTo(_fadeCenter);
1477
+ if (distance <= sphere.radius * overlay.matrixWorld.getMaxScaleOnAxis()) return Infinity;
1478
+ const tanHalfFov = Math.tan(THREE.MathUtils.degToRad(perspective.fov) * .5);
1479
+ const worldHeightAtCentre = 2 * distance * tanHalfFov;
1480
+ return worldHeightAtCentre > 0 ? viewportHeightPx / worldHeightAtCentre : Infinity;
1481
+ }
1482
+ if (camera.isOrthographicCamera) {
1483
+ const ortho = camera;
1484
+ const worldHeight = (ortho.top - ortho.bottom) / ortho.zoom;
1485
+ return worldHeight > 0 ? viewportHeightPx / worldHeight : Infinity;
1486
+ }
1487
+ return Infinity;
1488
+ }
1489
+ function enableDistanceFade(overlay, edgeSpacing) {
1490
+ overlay.onBeforeRender = (renderer, _scene, camera) => {
1491
+ LineSegments2.prototype.onBeforeRender.call(overlay, renderer);
1492
+ const material = overlay.material;
1493
+ const gapPx = edgeSpacing * pixelsPerWorldUnit(overlay, camera, material.resolution.y);
1494
+ material.opacity = THREE.MathUtils.clamp((gapPx - 1) / 3, 0, 1);
1495
+ };
1496
+ }
1497
+ // src/render/edges.ts
1498
+ /** For pick/fit filters elsewhere to exclude overlays from hit-testing. */
1499
+ function isEdgeOverlay(object) {
1500
+ return object.userData?.kind === EDGE_USERDATA_KIND;
1501
+ }
1502
+ /**
1503
+ * Meshes under `root` that should get an overlay: content meshes without one, caps applied.
1504
+ *
1505
+ * Two caps, and they fail in opposite directions. `maxTriangles` is per mesh and rejects the rare
1506
+ * enormous one; `maxOverlays` is a budget over the whole set and rejects the far more common case
1507
+ * of thousands of small ones, which no per-mesh test can see. Overlays already attached from an
1508
+ * earlier apply are counted against the budget, so a re-apply can't creep past it.
1509
+ */
1510
+ function collectTargets(root, resolved) {
1511
+ const candidates = [];
1512
+ let existingOverlays = 0;
1513
+ root.traverse((object) => {
1514
+ if (!(object instanceof THREE.Mesh)) return;
1515
+ if (object.userData.id === "floor" || object.userData.id === "grid") return;
1516
+ if (object.userData.kind === "edge-overlay") return;
1517
+ if (object.children.some((c) => c.userData?.kind === "edge-overlay")) {
1518
+ existingOverlays++;
1519
+ return;
1520
+ }
1521
+ if (!object.geometry) return;
1522
+ if (triangleCountOf(object.geometry) > resolved.maxTriangles) {
1523
+ object.userData.edgesSkipped = EDGES_SKIPPED_TRIANGLE_CAP;
1524
+ return;
1525
+ }
1526
+ candidates.push(object);
1527
+ });
1528
+ const budget = Math.max(0, resolved.maxOverlays - existingOverlays);
1529
+ const targets = candidates.slice(0, budget);
1530
+ for (const mesh of targets) delete mesh.userData.edgesSkipped;
1531
+ for (const mesh of candidates.slice(budget)) mesh.userData.edgesSkipped = EDGES_SKIPPED_OVERLAY_BUDGET;
1532
+ if (candidates.length > budget) console.debug(`[edges] overlay budget reached: ${candidates.length} meshes want overlays, ${budget} allowed — the rest fall back to the screen-space edge pass.`);
1533
+ return targets;
1534
+ }
1535
+ function attachOverlay(mesh, edges, materials, resolved) {
1536
+ const entry = buildLineGeometry(edges.segments);
1537
+ const fade = resolved.distanceFade && entry.segmentCount <= resolved.maxSegments;
1538
+ const overlay = buildEdgeOverlay(entry, materials.for(mesh, fade), fade);
1539
+ mesh.add(overlay);
1540
+ followHiddenMembers(overlay, mesh, edges);
1541
+ return overlay;
1542
+ }
1543
+ /**
1544
+ * {@link removeEdges} bumps this per root; async attaches landing after a bump are dropped, so
1545
+ * "toggle off while extracting" can't resurrect overlays.
1546
+ */
1547
+ const rootGenerations = /* @__PURE__ */ new WeakMap();
1548
+ function generationOf(root) {
1549
+ return rootGenerations.get(root) ?? 0;
1550
+ }
1551
+ /** Is `mesh` still reachable from `root`? Guards attaches racing scene clears. */
1552
+ function isConnected(mesh, root) {
1553
+ for (let node = mesh; node; node = node.parent) if (node === root) return true;
1554
+ return false;
1555
+ }
1556
+ /**
1557
+ * Like {@link addEdges}, but large-mesh extraction runs in a Worker so the main thread never
1558
+ * stalls; small meshes still attach synchronously before this resolves. Resolves with every
1559
+ * overlay actually attached — late results are dropped if the mesh left the subtree,
1560
+ * {@link removeEdges} ran for this root meanwhile, or another apply already attached one to that
1561
+ * mesh.
1562
+ */
1563
+ async function addEdgesAsync(root, options = {}) {
1564
+ const resolved = resolveOptions(options);
1565
+ const materials = new MaterialPool(resolved);
1566
+ const generation = generationOf(root);
1567
+ const created = [];
1568
+ const attaches = collectTargets(root, resolved).map(async (mesh) => {
1569
+ const edges = await extractSegmentsAsync(mesh.geometry, resolved.thresholdAngle);
1570
+ if (generationOf(root) !== generation) return;
1571
+ if (!isConnected(mesh, root)) return;
1572
+ if (mesh.children.some((c) => c.userData?.kind === "edge-overlay")) return;
1573
+ created.push(attachOverlay(mesh, edges, materials, resolved));
1574
+ });
1575
+ await Promise.all(attaches);
1576
+ materials.disposeUnused(created);
1577
+ return created;
1578
+ }
1579
+ /**
1580
+ * Remove every edge overlay under `root`, disposing geometry and material, and cancels any
1581
+ * in-flight async attaches for `root`. Inverse of {@link addEdges}/{@link addEdgesAsync}. Returns
1582
+ * the count removed.
1583
+ */
1584
+ function removeEdges(root) {
1585
+ rootGenerations.set(root, generationOf(root) + 1);
1586
+ const overlays = [];
1587
+ root.traverse((object) => {
1588
+ if (object instanceof LineSegments2 && isEdgeOverlay(object)) overlays.push(object);
1589
+ });
1590
+ const materials = /* @__PURE__ */ new Set();
1591
+ for (const overlay of overlays) {
1592
+ overlay.geometry.dispose();
1593
+ materials.add(overlay.material);
1594
+ overlay.removeFromParent();
1595
+ }
1596
+ materials.forEach((material) => material.dispose());
1597
+ return overlays.length;
1598
+ }
1599
+ // src/render/grid.ts
1600
+ /** Rounds to a "nice" 1/2/5 × 10ⁿ step (CAD ruler convention) so cells never come out finer than the target and alias into a solid sheet. */
1601
+ function niceStep(value) {
1602
+ if (!(value > 0) || !Number.isFinite(value)) return 1;
1603
+ const power = Math.pow(10, Math.floor(Math.log10(value)));
1604
+ const mantissa = value / power;
1605
+ return (mantissa >= 5 ? 5 : mantissa >= 2 ? 2 : 1) * power;
1606
+ }
1607
+ const GRID_VERTEX = `
3
1608
  varying vec3 vWorldPos;
4
1609
  void main() {
5
1610
  vec4 world = modelMatrix * vec4(position, 1.0);
6
1611
  vWorldPos = world.xyz;
7
1612
  gl_Position = projectionMatrix * viewMatrix * world;
8
1613
  }
9
- `,fragmentShader:`
1614
+ `;
1615
+ const GRID_FRAGMENT = `
10
1616
  precision highp float;
11
1617
  varying vec3 vWorldPos;
12
1618
 
@@ -50,13 +1656,1642 @@ import{a as e,c as t,d as n,f as r,g as i,h as a,l as o,m as s,p as c,r as l,s a
50
1656
  if (alpha < 0.001) discard;
51
1657
  gl_FragColor = vec4(color, alpha);
52
1658
  }
53
- `,transparent:!0,depthWrite:!1,side:v.DoubleSide,uniforms:{uAxes:{value:s},uCell:{value:t},uMajor:{value:n},uCellColor:{value:new v.Color(r)},uMajorColor:{value:new v.Color(i)},uCenter:{value:new v.Vector3},uFade:{value:a}}}),d=new v.Mesh(l,u);d.name=`grid`,d.userData.id=`grid`,d.renderOrder=-1;let f=a,p=a*c,m=new v.Vector3;return{object:d,update:e=>{o===`y`?(d.position.set(e.x,0,e.z),m.set(e.x,0,e.z)):o===`z`?(d.position.set(e.x,e.y,0),m.set(e.x,e.y,0)):(d.position.set(0,e.y,e.z),m.set(0,e.y,e.z)),u.uniforms.uCenter.value.copy(m),d.scale.setScalar(p)},fitToContent:e=>{if(e.isEmpty())return;let t=e.getSize(new v.Vector3),n=(e,t)=>t===0?e.x:t===1?e.y:e.z,r=Math.max(n(t,s.x),n(t,s.y));r>0&&Number.isFinite(r)&&(u.uniforms.uCell.value=rt(r/20),f=r*2,u.uniforms.uFade.value=f,p=f*c)},setVisible:e=>{d.visible=e},dispose:()=>{d.removeFromParent(),l.dispose(),u.dispose()}}}function at(e,t){let n=new E,r=n.domElement;r.style.position=`absolute`,r.style.top=`0`,r.style.left=`0`,r.style.overflow=`hidden`,r.style.pointerEvents=`none`,r.style.zIndex=`30`,getComputedStyle(e).position===`static`&&(e.style.position=`relative`),e.appendChild(r);let i={width:e.clientWidth||1,height:e.clientHeight||1};n.setSize(i.width,i.height);let a=new v.Group;a.name=`label-layer`,a.userData.id=`label-layer`,t.add(a);let o=new Set;return{addLabel:(e,t,n)=>{let r=document.createElement(`div`);r.textContent=e,n?r.className=n:Object.assign(r.style,{padding:`2px 6px`,borderRadius:`4px`,background:`rgba(20, 20, 20, 0.78)`,color:`#fff`,font:`12px/1.3 system-ui, sans-serif`,whiteSpace:`pre`,textAlign:`center`,userSelect:`none`}),r.style.pointerEvents=`none`;let i=new T(r);return i.position.copy(t),a.add(i),o.add(i),{object:i,setPosition:e=>i.position.copy(e),setText:e=>{r.textContent=e},remove:()=>{i.removeFromParent(),r.remove(),o.delete(i)}}},render:(e,t)=>n.render(e,t),setSize:(e,t)=>n.setSize(e,t),dispose:()=>{o.forEach(e=>{e.removeFromParent(),e.element.remove()}),o.clear(),a.removeFromParent(),r.remove()}}}const ot=.015,st={Millimeters:{metersPerUnit:1/1e3,suffix:`mm`},Centimeters:{metersPerUnit:1/100,suffix:`cm`},Meters:{metersPerUnit:1,suffix:`m`},Inches:{metersPerUnit:1/39.37,suffix:`in`},Feet:{metersPerUnit:1/3.28084,suffix:`ft`}};function ct(e){let t=e&&st[e]||st.Meters;return e=>`${(e/t.metersPerUnit).toPrecision(3)} ${t.suffix}`}function lt(e,t){if(e.isOrthographicCamera){let t=e;return Math.abs(t.top-t.bottom)/(t.zoom||1)*ot}return((t?e.position.distanceTo(t):e.position.length())||1)*ot}function ut(e){let t=e.object;if(t instanceof v.Mesh)return e.face?[e.face.a,e.face.b,e.face.c]:null;if(t instanceof v.Points)return e.index==null?null:[e.index];if(t instanceof v.Line){if(e.index==null)return null;let n=t.geometry.index;return n?e.index+1>=n.count?null:[n.getX(e.index),n.getX(e.index+1)]:[e.index,e.index+1]}return null}function dt(e,t,n,r){let i=e.point.clone(),a=e.object,o=ut(e);if(!o||!a.geometry)return i;let s=a.geometry.attributes.position;if(!s)return i;let c=e=>{let r=e.clone().project(t);return new v.Vector2((r.x+1)/2*n.width,(1-r.y)/2*n.height)},l=c(i),u=i,d=r;for(let e of o){if(e>=s.count)continue;let t=new v.Vector3().fromBufferAttribute(s,e).applyMatrix4(a.matrixWorld),n=c(t).distanceTo(l);n<d&&(d=n,u=t)}return u}function ft(e){let{canvas:t,scene:n,getActiveCamera:r,getViewTarget:i,labelLayer:a,options:o={}}=e,s=o.snapPixels??12,c=new v.Color(o.color??16763904),l=ct(o.displayUnit),u=o.format??((e,t)=>`${l(e)}\nΔx ${l(t.x)} Δy ${l(t.y)} Δz ${l(t.z)}`),d=new v.Raycaster,f=new v.Vector2,p=!1,m=[],h=[],g=null,_=null,S=new v.PointsMaterial({color:c,size:8,sizeAttenuation:!1,depthTest:!1}),C=new v.PointsMaterial({color:c,size:11,sizeAttenuation:!1,depthTest:!1,transparent:!0,opacity:.5}),w=null,T=e=>{if(!e){w&&(w.visible=!1);return}if(!w){let e=new v.BufferGeometry;e.setAttribute(`position`,new v.Float32BufferAttribute([0,0,0],3)),w=new v.Points(e,C),w.renderOrder=1e3,w.userData.id=`measure`,w.raycast=()=>{},n.add(w)}w.position.copy(e),w.visible=!0},E=e=>{let t=new v.BufferGeometry;t.setAttribute(`position`,new v.Float32BufferAttribute([e.x,e.y,e.z],3));let r=new v.Points(t,S);return r.renderOrder=999,r.userData.id=`measure`,r.raycast=()=>{},n.add(r),r},D=()=>{m.length=0,h.forEach(e=>{e.geometry.dispose(),e.removeFromParent()}),h.length=0,g&&=(g.geometry.dispose(),g.material.dispose(),g.removeFromParent(),null),_?.remove(),_=null},O=()=>{if(m.length!==2)return;let[e,t]=m,r=new b;r.setPositions([e.x,e.y,e.z,t.x,t.y,t.z]);let i=new x({color:c});i.linewidth=2,i.depthTest=!1,g=new y(r,i),g.renderOrder=998,g.userData.id=`measure`,g.raycast=()=>{},n.add(g);let s=e.clone().add(t).multiplyScalar(.5),l=new v.Vector3(Math.abs(t.x-e.x),Math.abs(t.y-e.y),Math.abs(t.z-e.z));_=a.addLabel(u(e.distanceTo(t),l),s,o.labelClassName)},k=e=>{let a=t.getBoundingClientRect();f.x=(e.clientX-a.left)/a.width*2-1,f.y=-((e.clientY-a.top)/a.height)*2+1;let o=r();d.setFromCamera(f,o);let c=lt(o,i?.());d.params.Line.threshold=c,d.params.Points.threshold=c;let l=d.intersectObjects(n.children,!0).filter(e=>e.object.userData.id!==`measure`&&e.object.userData.id!==`grid`);return l.length===0?null:dt(l[0],o,{width:a.width,height:a.height},s)},A=null,j=0,M=()=>{j&&=(cancelAnimationFrame(j),0),A=null};return{setEnabled:e=>{p=e,e||(M(),D(),T(null))},isEnabled:()=>p,handleClick:e=>{if(!p)return!1;m.length===2&&D();let t=k(e);return t===null||(m.push(t),h.push(E(t)),m.length===2&&O(),!0)},handleMove:e=>{p&&(A=e,!j&&(j=requestAnimationFrame(()=>{j=0;let e=A;A=null,p&&e&&T(k(e))})))},clear:D,dispose:()=>{M(),D(),w&&=(w.geometry.dispose(),w.removeFromParent(),null),S.dispose(),C.dispose()}}}const pt=[];function mt({camera:e,scene:t,groundNormals:n=()=>pt}){let r=e.near,i=e.near,a=ht();return{update:()=>{e.near!==i&&(r=e.near);let o=a.measure(t,e),s=r;if(o!==null){let t=o;for(let r of n())t=Math.min(t,Math.abs(e.position.dot(r)));s=v.MathUtils.clamp(t*.5,r,e.far*.01)}Math.abs(s-i)>i*.05&&(e.near=s,e.updateProjectionMatrix(),i=s)}}}function ht(){let e=new WeakMap,t=new v.Frustum,n=new v.Matrix4,r=new v.Vector3,i=new v.Box3,a=new Float64Array(20),o=new Float64Array(20),s=new Float32Array(6),c=new Float64Array(21),l=new Float64Array(21),u=1/0,d=!1,f=(t,n)=>{let r=n.getAttribute(`position`),i=n.getIndex(),a=e.get(n);if(a&&a.position===r&&a.positionVersion===_t(r)&&a.index===i&&a.indexVersion===(i?.version??-1))return a;let o={...yt(r,i,vt(t)),position:r,positionVersion:_t(r),index:i,indexVersion:i?.version??-1};return e.set(n,o),o},p=(e,t,n)=>{let{position:r,index:i}=e,a=i?.array,o=1/0;for(let i=e.ranges[t*2];i<e.ranges[t*2+1];i++){for(let e=0;e<3;e++){let t=a?a[i*3+e]:i*3+e;c[e*3]=r.getX(t),c[e*3+1]=r.getY(t),c[e*3+2]=r.getZ(t)}let e=3;for(let t=0;t<16&&e>0;t+=4){let r=n[t],i=n[t+1],a=n[t+2],o=n[t+3],s=0;for(let t=0;t<e;t++){let n=(t+1)%e,u=c[t*3],d=c[t*3+1],f=c[t*3+2],p=c[n*3],m=c[n*3+1],h=c[n*3+2],g=r*u+i*d+a*f+o,_=r*p+i*m+a*h+o;if(g>=0&&(l[s*3]=u,l[s*3+1]=d,l[s*3+2]=f,s++),g>=0!=_>=0){let e=g/(g-_);l[s*3]=u+(p-u)*e,l[s*3+1]=d+(m-d)*e,l[s*3+2]=f+(h-f)*e,s++}}[c,l]=[l,c],e=s}for(let t=0;t<e;t++){let e=n[16]*c[t*3]+n[17]*c[t*3+1]+n[18]*c[t*3+2]+n[19];e<o&&(o=e)}}return o},m=e=>{let t=e.elements;for(let e=0;e<20;e+=4){let n=a[e],r=a[e+1],i=a[e+2];o[e]=t[0]*n+t[1]*r+t[2]*i,o[e+1]=t[4]*n+t[5]*r+t[6]*i,o[e+2]=t[8]*n+t[9]*r+t[10]*i,o[e+3]=n*t[12]+r*t[13]+i*t[14]+a[e+3]}return o},h=(e,t,n)=>{outer:for(let r=0;r<e.length;r+=6){let i=e[r],a=e[r+1],o=e[r+2],s=e[r+3],c=e[r+4],l=e[r+5];for(let e=0;e<16;e+=4){let n=t[e],r=t[e+1],u=t[e+2];if(n*(n>0?s:i)+r*(r>0?c:a)+u*(u>0?l:o)+t[e+3]<0)continue outer}let d=t[16],f=t[17],m=t[18],h=d*(d>0?i:s)+f*(f>0?a:c)+m*(m>0?o:l)+t[19];h>=u||(u=n?Math.min(u,p(n,r/6,t)):h)}},g=e=>{let t=e.geometry;if(!t?.getAttribute?.(`position`))return;if(d=!0,gt(e)){let n=f(e,t);h(n.boxes,m(e.matrixWorld),n);return}let n=e;n.isInstancedMesh?(n.boundingBox||n.computeBoundingBox(),i.copy(n.boundingBox)):(t.boundingBox||t.computeBoundingBox(),i.copy(t.boundingBox)),!i.isEmpty()&&(s[0]=i.min.x,s[1]=i.min.y,s[2]=i.min.z,s[3]=i.max.x,s[4]=i.max.y,s[5]=i.max.z,h(s,m(e.matrixWorld)))},_=e=>{if(e.visible&&!pe(e)&&e.userData.kind!==`edge-overlay`){g(e);for(let t of e.children)_(t)}};return{measure:(e,i)=>{e.updateMatrixWorld(),i.updateMatrixWorld(),n.multiplyMatrices(i.projectionMatrix,i.matrixWorldInverse),t.setFromProjectionMatrix(n);for(let e=0;e<4;e++){let n=t.planes[e];a[e*4]=n.normal.x,a[e*4+1]=n.normal.y,a[e*4+2]=n.normal.z,a[e*4+3]=n.constant}i.getWorldDirection(r),a[16]=r.x,a[17]=r.y,a[18]=r.z,a[19]=-r.dot(i.position),u=1/0,d=!1;for(let t of e.children)_(t);return d?u:null}}}function gt(e){let t=e;if(!t.isMesh||t.isInstancedMesh||t.isSkinnedMesh)return!1;let n=t.geometry;return!n.isInstancedBufferGeometry&&Object.keys(n.morphAttributes).length===0}function _t(e){return e.isInterleavedBufferAttribute?e.data.version:e.version}function vt(e){let t=[];for(let n of X(e)??[])n.indexStart!==void 0&&t.push(n.indexStart/3);return t.sort((e,t)=>e-t)}function yt(e,t,n){let r=Math.floor((t?t.count:e.count)/3),i=[],a=0;for(let e=0;e<r;){for(;a<n.length&&n[a]<=e;)a++;let t=Math.min(r,e+256,n[a]??r);i.push(e,t),e=t}let o=i.length/2,s=new Float32Array(o*6),c=!e.isInterleavedBufferAttribute,l=e.array,u=c&&e.itemSize===3&&!e.normalized,d=t?.array;for(let t=0;t<o;t++){let n=1/0,r=1/0,a=1/0,o=-1/0,c=-1/0,f=-1/0,p=i[t*2+1]*3;for(let s=i[t*2]*3;s<p;s++){let t=d?d[s]:s,i=u?l[t*3]:e.getX(t),p=u?l[t*3+1]:e.getY(t),m=u?l[t*3+2]:e.getZ(t);i<n&&(n=i),p<r&&(r=p),m<a&&(a=m),i>o&&(o=i),p>c&&(c=p),m>f&&(f=m)}let m=t*6;s[m]=n,s[m+1]=r,s[m+2]=a,s[m+3]=o,s[m+4]=c,s[m+5]=f}return{boxes:s,ranges:Uint32Array.from(i)}}function bt(){let e=[],t=null,n=()=>e.sort((e,t)=>t.priority-e.priority),r=t=>e.findIndex(e=>e.id===t),i=n=>{let i=r(n);i!==-1&&(e.splice(i,1),t===n&&(t=null))};return{register:({id:t,tool:r,priority:a=0})=>(i(t),e.push({id:t,tool:r,priority:a}),n(),()=>i(t)),unregister:i,get:t=>e[r(t)]?.tool??null,setActive:n=>{t=n;for(let t of e)t.tool.setEnabled?.(t.id===n)},getActive:()=>t,handleClick:t=>{for(let n of[...e])if(n.tool.handleClick(t))return!0;return!1},handleMove:t=>{for(let n of[...e])n.tool.handleMove?.(t)}}}function xt(e,t){let n=t.getBoundingClientRect();return{x:(e.clientX-n.left)/n.width*2-1,y:-((e.clientY-n.top)/n.height)*2+1}}function St(e){let{camera:t,domElement:n,controller:r}=e,i=new D(t,n);i.setLabels(`X`,`Y`,`Z`);let a=!0,o=new v.Raycaster,s=new v.OrthographicCamera(-2,2,2,-2,0,4);s.position.set(0,0,2),s.updateMatrixWorld();let c={posX:new v.Vector3(1,0,0),negX:new v.Vector3(-1,0,0),posY:new v.Vector3(0,1,0),negY:new v.Vector3(0,-1,0),posZ:new v.Vector3(0,0,1),negZ:new v.Vector3(0,0,-1)},l=e=>{let r=n.getBoundingClientRect(),a=r.left+n.offsetWidth-128-i.location.right,l=r.top+n.offsetHeight-128-i.location.bottom,u=new v.Vector2((e.clientX-a)/128*2-1,-((e.clientY-l)/128)*2+1);if(Math.abs(u.x)>1||Math.abs(u.y)>1)return null;i.quaternion.copy(t.quaternion).invert(),i.updateMatrixWorld(),o.setFromCamera(u,s);let d=o.intersectObjects(i.children,!1);for(let e of d){let t=e.object.userData?.type;if(typeof t==`string`&&t in c)return t}return null};return{render:e=>{if(!a)return;let t=e.autoClear;e.autoClear=!1,i.render(e),e.autoClear=t},handleClick:e=>{if(!a)return!1;let t=l(e);return t?(r.getProjection()===`orthographic`&&r.setProjection(`perspective`),r.setViewDirection(c[t],!1),!0):!1},setVisible:e=>{a=e},isVisible:()=>a,dispose:()=>i.dispose()}}function Ct(e,t,n,r,i,a,o,s,c,l,u,d,f,p,m=!0){let h=null,g=performance.now(),_=!0,y=0,b=new v.Matrix4,x=new v.Matrix4,S=null,C=()=>{_=!0},w=e=>{e.updateMatrixWorld();let t=S!==e||!b.equals(e.matrixWorld)||!x.equals(e.projectionMatrix);return t&&(S=e,b.copy(e.matrixWorld),x.copy(e.projectionMatrix)),t},T=e.domElement,E=[`pointerdown`,`pointerup`,`wheel`];if(m)for(let e of E)T.addEventListener(e,C,{passive:!0});let D=()=>{let{width:t,height:r}=o();if(t===0||r===0)return;let a=Math.floor(t*s),c=Math.floor(r*s);(e.domElement.width!==a||e.domElement.height!==c)&&(e.setPixelRatio(s),e.setSize(t,r,!1),n.aspect=t/r,n.updateProjectionMatrix(),i.updateAspect(t,r),d?.()?.setSize(t,r,s),f?.setSize(t,r),C())},O=(n,r)=>{let i=d?.();i?(i.setCamera(n),i.render(r)):e.render(t,n),f&&f.render(t,n),u&&u.render(e)},k=()=>{O(r(),0)},A=function(){h=requestAnimationFrame(A);let e=performance.now(),t=(e-g)/1e3;g=e,D(),(a.enableDamping||a.autoRotate)&&a.update(),l&&l.update(r().position),p&&p.update(),c?.(t);let n=r();if(m){if(!(_||w(n)||e-y>=500))return;_=!1,y=e}O(n,t)};return{animate:A,dispose:()=>{if(h!==null&&(cancelAnimationFrame(h),h=null),m)for(let e of E)T.removeEventListener(e,C)},invalidate:C,renderNow:k}}const Q=new v.Vector3(0,0,1);function wt(e){let n=e.sceneScale||`m`,r={mm:{cameraDistance:20,near:.1,far:2e3,floorSize:100,lightDistance:10,lightHeight:20,minDistance:.1,shadowSize:100,scaleFactor:1e3},cm:{cameraDistance:20,near:.1,far:2e3,floorSize:100,lightDistance:25,lightHeight:50,minDistance:.1,shadowSize:100,scaleFactor:100},m:{cameraDistance:10,near:.01,far:2e3,floorSize:50,lightDistance:25,lightHeight:50,minDistance:.001,shadowSize:100,scaleFactor:1},inches:{cameraDistance:15,near:.1,far:2e3,floorSize:80,lightDistance:20,lightHeight:40,minDistance:.1,shadowSize:80,scaleFactor:39.37},feet:{cameraDistance:8,near:.1,far:2e3,floorSize:40,lightDistance:15,lightHeight:30,minDistance:.1,shadowSize:60,scaleFactor:3.28084}}[n],i=e.look??`technical`,a=t[i];return{sceneScale:n,look:i,camera:{position:e.camera?.position||se(e.environment?.sceneUp??Q,r.cameraDistance*Math.sqrt(3)),fov:e.camera?.fov||20,near:e.camera?.near||r.near,far:e.camera?.far||r.far,target:e.camera?.target||new v.Vector3(0,0,0),dynamicNear:e.camera?.dynamicNear??!0},lighting:{enableSunlight:e.lighting?.enableSunlight??!0,sunlightIntensity:e.lighting?.sunlightIntensity??a.sunlightIntensity,sunlightPosition:e.lighting?.sunlightPosition||ce(e.environment?.sceneUp??Q,r.lightDistance,r.lightHeight),ambientLightColor:e.lighting?.ambientLightColor||new v.Color(4210752),ambientLightIntensity:e.lighting?.ambientLightIntensity??a.ambientIntensity,sunlightColor:e.lighting?.sunlightColor||16777215,enableHemisphereLight:e.lighting?.enableHemisphereLight??a.hemisphereIntensity>0,hemisphereSkyColor:e.lighting?.hemisphereSkyColor??14673663,hemisphereGroundColor:e.lighting?.hemisphereGroundColor??7036754,hemisphereIntensity:e.lighting?.hemisphereIntensity??a.hemisphereIntensity},environment:{hdrPath:e.environment?.hdrPath||`/baseHDR.hdr`,backgroundColor:e.environment?.backgroundColor||new v.Color(15790320),enableEnvironmentLighting:e.environment?.enableEnvironmentLighting??!0,sceneUp:e.environment?.sceneUp||Q,showEnvironment:e.environment?.showEnvironment??!1,environmentIntensity:e.environment?.environmentIntensity??a.environmentIntensity},floor:{enabled:e.floor?.enabled??!1,size:e.floor?.size||r.floorSize,color:e.floor?.color||new v.Color(8421504),roughness:e.floor?.roughness??.7,metalness:e.floor?.metalness??0,receiveShadow:e.floor?.receiveShadow??!0},render:{enableShadows:e.render?.enableShadows??!0,shadowMapSize:e.render?.shadowMapSize||2048,antialias:e.render?.antialias??!0,pixelRatio:e.render?.pixelRatio||Math.min(window.devicePixelRatio,2),toneMapping:e.render?.toneMapping??a.toneMapping,toneMappingExposure:e.render?.toneMappingExposure??a.toneMappingExposure,preserveDrawingBuffer:e.render?.preserveDrawingBuffer??!1,ambientOcclusion:e.render?.ambientOcclusion??a.ambientOcclusion,aoIntensity:e.render?.aoIntensity??1,aoPixelRatio:e.render?.aoPixelRatio??1,onDemand:e.render?.onDemand??!0},controls:{enableDamping:e.controls?.enableDamping??!1,dampingFactor:e.controls?.dampingFactor||.05,autoRotate:e.controls?.autoRotate??!1,autoRotateSpeed:e.controls?.autoRotateSpeed||.5,enableZoom:e.controls?.enableZoom??!0,enablePan:e.controls?.enablePan??!0,minDistance:e.controls?.minDistance||r.minDistance,maxDistance:e.controls?.maxDistance||1/0},grid:{enabled:e.grid?.enabled??!1,cellSize:e.grid?.cellSize??1,majorEvery:e.grid?.majorEvery??10,cellColor:e.grid?.cellColor??8947848,majorColor:e.grid?.majorColor??4473924,fadeDistance:e.grid?.fadeDistance??100,plane:e.grid?.plane??ue(e.environment?.sceneUp??Q)},gizmo:{enabled:e.gizmo?.enabled??!1},edges:{enabled:e.edges?.enabled??!1,color:e.edges?.color,darken:e.edges?.darken,width:e.edges?.width??1.5,thresholdAngle:e.edges?.thresholdAngle??44,distanceFade:e.edges?.distanceFade??!0,maxTriangles:e.edges?.maxTriangles,maxSegments:e.edges?.maxSegments,maxOverlays:e.edges?.maxOverlays,screenSpaceFallback:e.edges?.screenSpaceFallback},measure:{enabled:e.measure?.enabled??!1,snapPixels:e.measure?.snapPixels,color:e.measure?.color,labelClassName:e.measure?.labelClassName,displayUnit:e.measure?.displayUnit,format:e.measure?.format},events:{onBackgroundClicked:e.events?.onBackgroundClicked,onObjectSelected:e.events?.onObjectSelected,onMeshMetadataClicked:e.events?.onMeshMetadataClicked,onMeshDoubleClicked:e.events?.onMeshDoubleClicked,selectionColor:e.events?.selectionColor||`#ff0000`,enableEventHandlers:e.events?.enableEventHandlers??!0,enableKeyboardControls:e.events?.enableKeyboardControls??!0,enableClickToFocus:e.events?.enableClickToFocus??!0,enableDoubleClickZoom:e.events?.enableDoubleClickZoom??!0,onReady:e.events?.onReady,onFrame:e.events?.onFrame},onMaxAnisotropy:e.onMaxAnisotropy}}const $=`__selvaLookBaseline`;function Tt(e,t){e.color?.setHex(t.color),e.metalness!==void 0&&(e.metalness=t.metalness),e.roughness!==void 0&&(e.roughness=t.roughness),e.opacity=t.opacity,e.transparent=t.transparent,e.depthWrite=t.depthWrite,e.wireframe!==void 0&&(e.wireframe=t.wireframe),e.side=t.side}function Et(e){let t=e[$];t&&(Tt(e,t),delete e[$])}function Dt(e,t){let n=e;if(!t){Et(n),n.needsUpdate=!0;return}n[$]?Tt(n,n[$]):n[$]={color:n.color?.getHex()??16777215,metalness:n.metalness??0,roughness:n.roughness??1,opacity:n.opacity,transparent:n.transparent,depthWrite:n.depthWrite,wireframe:n.wireframe??!1,side:n.side},t.color!==void 0&&n.color?.setHex(t.color),t.metalness!==void 0&&n.metalness!==void 0&&(n.metalness=t.metalness),t.roughness!==void 0&&n.roughness!==void 0&&(n.roughness=t.roughness),t.opacity!==void 0&&(n.opacity=t.opacity,n.transparent=t.opacity<1),t.depthWrite!==void 0&&(n.depthWrite=t.depthWrite),t.wireframe!==void 0&&n.wireframe!==void 0&&(n.wireframe=t.wireframe),t.side!==void 0&&(n.side=t.side),n.needsUpdate=!0}function Ot(e,t){if(t.materialOverride)return t.envMapIntensity;let n=e.userData[o];if(n!=null)return n;let r=e,i=r[$]?.metalness??r.metalness??0;return f(t.envMapIntensity,i)}function kt(e){let{scene:r,renderer:i,lights:a,config:o,pipeline:s,requestRender:c}=e,l=o.look,u=e=>{e.ambientIntensity!==void 0&&(a.ambient.intensity=e.ambientIntensity),e.hemisphereIntensity!==void 0&&!a.hemisphere&&e.hemisphereIntensity>0&&(a.hemisphere=new v.HemisphereLight(e.hemisphereSkyColor??o.lighting.hemisphereSkyColor,e.hemisphereGroundColor??o.lighting.hemisphereGroundColor,e.hemisphereIntensity),a.hemisphere.position.copy(o.environment.sceneUp??Q),r.add(a.hemisphere)),a.hemisphere&&(e.hemisphereIntensity!==void 0&&(a.hemisphere.intensity=e.hemisphereIntensity),e.hemisphereSkyColor!==void 0&&a.hemisphere.color.set(e.hemisphereSkyColor),e.hemisphereGroundColor!==void 0&&a.hemisphere.groundColor.set(e.hemisphereGroundColor)),c()},d=e=>{o.environment.environmentIntensity=e,r.environmentIntensity=e,c()};return{setFillLights:u,setEnvironmentIntensity:d,setToneMappingExposure:e=>{o.render.toneMappingExposure=e,i.toneMappingExposure=e,s.get()&&s.rebuild()},setAoIntensity:e=>{o.render.aoIntensity=e,s.get()&&s.rebuild()},setLook:e=>{let n=t[e];l=e,i.toneMapping=n.toneMapping,i.toneMappingExposure=n.toneMappingExposure,o.render.toneMapping=n.toneMapping,o.render.toneMappingExposure=n.toneMappingExposure,u({hemisphereIntensity:n.hemisphereIntensity,ambientIntensity:n.ambientIntensity}),d(n.environmentIntensity),a.sun&&(a.sun.intensity=n.sunlightIntensity,o.lighting.sunlightIntensity=n.sunlightIntensity);let f=s.get()!==null;s.setAmbientOcclusion(n.ambientOcclusion),f&&s.rebuild(),r.traverse(e=>{if(e.userData.source!==`compute`)return;let t=e,r=Array.isArray(t.material)?t.material:t.material?[t.material]:[];for(let e of r)`envMapIntensity`in e&&(e.envMapIntensity=Ot(e,n)),Dt(e,n.materialOverride)}),H(r,r),c()},getMaterialAppearance:()=>n(l)}}function At(e,t){let n=t.parentElement,r=n?n.clientWidth:window.innerWidth,i=n?n.clientHeight:window.innerHeight,a=new v.PerspectiveCamera(e.camera.fov,r/i,e.camera.near,e.camera.far),o=e.camera.position;return o&&a.position.set(o.x,o.y,o.z),a}function jt(e){let t=new v.Scene;return t.background=(typeof e.environment.backgroundColor==`string`?new v.Color(e.environment.backgroundColor):e.environment.backgroundColor)||null,t}function Mt(e,t){d(e,t),e.environment?.dispose(),oe(e),e.background instanceof v.Texture&&e.background.dispose()}const Nt={uniforms:{tDiffuse:{value:null},tNormal:{value:null},tDepth:{value:null},uResolution:{value:new v.Vector2(1,1)},uColor:{value:new v.Color(2236962)},uOpacity:{value:1},uNormalThreshold:{value:.4},uDepthThreshold:{value:.02},uThickness:{value:1},uNear:{value:.1},uFar:{value:1e3},uPerspective:{value:1}},vertexShader:`
1659
+ `;
1660
+ function createGrid(options = {}) {
1661
+ const { cellSize = 1, majorEvery = 10, cellColor = 8947848, majorColor = 4473924, fadeDistance = 100, plane = "y" } = options;
1662
+ const axes = plane === "y" ? new THREE.Vector2(0, 2) : plane === "z" ? new THREE.Vector2(0, 1) : new THREE.Vector2(1, 2);
1663
+ const PLANE_TO_FADE_RATIO = 2.5;
1664
+ const geometry = new THREE.PlaneGeometry(1, 1);
1665
+ if (plane === "y") geometry.rotateX(-Math.PI / 2);
1666
+ else if (plane === "x") geometry.rotateY(Math.PI / 2);
1667
+ const material = new THREE.ShaderMaterial({
1668
+ vertexShader: GRID_VERTEX,
1669
+ fragmentShader: GRID_FRAGMENT,
1670
+ transparent: true,
1671
+ depthWrite: false,
1672
+ side: THREE.DoubleSide,
1673
+ uniforms: {
1674
+ uAxes: { value: axes },
1675
+ uCell: { value: cellSize },
1676
+ uMajor: { value: majorEvery },
1677
+ uCellColor: { value: new THREE.Color(cellColor) },
1678
+ uMajorColor: { value: new THREE.Color(majorColor) },
1679
+ uCenter: { value: new THREE.Vector3() },
1680
+ uFade: { value: fadeDistance }
1681
+ }
1682
+ });
1683
+ const mesh = new THREE.Mesh(geometry, material);
1684
+ mesh.name = "grid";
1685
+ mesh.userData.id = "grid";
1686
+ mesh.renderOrder = -1;
1687
+ let fadeRadius = fadeDistance;
1688
+ let planeScale = fadeDistance * PLANE_TO_FADE_RATIO;
1689
+ const center = new THREE.Vector3();
1690
+ return {
1691
+ object: mesh,
1692
+ update: (cameraPosition) => {
1693
+ if (plane === "y") {
1694
+ mesh.position.set(cameraPosition.x, 0, cameraPosition.z);
1695
+ center.set(cameraPosition.x, 0, cameraPosition.z);
1696
+ } else if (plane === "z") {
1697
+ mesh.position.set(cameraPosition.x, cameraPosition.y, 0);
1698
+ center.set(cameraPosition.x, cameraPosition.y, 0);
1699
+ } else {
1700
+ mesh.position.set(0, cameraPosition.y, cameraPosition.z);
1701
+ center.set(0, cameraPosition.y, cameraPosition.z);
1702
+ }
1703
+ material.uniforms.uCenter.value.copy(center);
1704
+ mesh.scale.setScalar(planeScale);
1705
+ },
1706
+ fitToContent: (bounds) => {
1707
+ if (bounds.isEmpty()) return;
1708
+ const sizeVec = bounds.getSize(new THREE.Vector3());
1709
+ const axisComponent = (v, i) => i === 0 ? v.x : i === 1 ? v.y : v.z;
1710
+ const inPlaneExtent = Math.max(axisComponent(sizeVec, axes.x), axisComponent(sizeVec, axes.y));
1711
+ if (!(inPlaneExtent > 0) || !Number.isFinite(inPlaneExtent)) return;
1712
+ const TARGET_CELLS_ACROSS = 20;
1713
+ material.uniforms.uCell.value = niceStep(inPlaneExtent / TARGET_CELLS_ACROSS);
1714
+ fadeRadius = inPlaneExtent * 2;
1715
+ material.uniforms.uFade.value = fadeRadius;
1716
+ planeScale = fadeRadius * PLANE_TO_FADE_RATIO;
1717
+ },
1718
+ setVisible: (visible) => {
1719
+ mesh.visible = visible;
1720
+ },
1721
+ dispose: () => {
1722
+ mesh.removeFromParent();
1723
+ geometry.dispose();
1724
+ material.dispose();
1725
+ }
1726
+ };
1727
+ }
1728
+ // src/render/label-layer.ts
1729
+ function createLabelLayer(container, scene) {
1730
+ const renderer = new CSS2DRenderer();
1731
+ const dom = renderer.domElement;
1732
+ dom.style.position = "absolute";
1733
+ dom.style.top = "0";
1734
+ dom.style.left = "0";
1735
+ dom.style.overflow = "hidden";
1736
+ dom.style.pointerEvents = "none";
1737
+ dom.style.zIndex = "30";
1738
+ if (getComputedStyle(container).position === "static") container.style.position = "relative";
1739
+ container.appendChild(dom);
1740
+ const size = {
1741
+ width: container.clientWidth || 1,
1742
+ height: container.clientHeight || 1
1743
+ };
1744
+ renderer.setSize(size.width, size.height);
1745
+ const group = new THREE.Group();
1746
+ group.name = "label-layer";
1747
+ group.userData.id = "label-layer";
1748
+ scene.add(group);
1749
+ const labels = /* @__PURE__ */ new Set();
1750
+ const addLabel = (text, position, className) => {
1751
+ const el = document.createElement("div");
1752
+ el.textContent = text;
1753
+ if (className) el.className = className;
1754
+ else Object.assign(el.style, {
1755
+ padding: "2px 6px",
1756
+ borderRadius: "4px",
1757
+ background: "rgba(20, 20, 20, 0.78)",
1758
+ color: "#fff",
1759
+ font: "12px/1.3 system-ui, sans-serif",
1760
+ whiteSpace: "pre",
1761
+ textAlign: "center",
1762
+ userSelect: "none"
1763
+ });
1764
+ el.style.pointerEvents = "none";
1765
+ const object = new CSS2DObject(el);
1766
+ object.position.copy(position);
1767
+ group.add(object);
1768
+ labels.add(object);
1769
+ return {
1770
+ object,
1771
+ setPosition: (p) => object.position.copy(p),
1772
+ setText: (t) => {
1773
+ el.textContent = t;
1774
+ },
1775
+ remove: () => {
1776
+ object.removeFromParent();
1777
+ el.remove();
1778
+ labels.delete(object);
1779
+ }
1780
+ };
1781
+ };
1782
+ return {
1783
+ addLabel,
1784
+ render: (scene, camera) => renderer.render(scene, camera),
1785
+ setSize: (width, height) => renderer.setSize(width, height),
1786
+ dispose: () => {
1787
+ labels.forEach((object) => {
1788
+ object.removeFromParent();
1789
+ object.element.remove();
1790
+ });
1791
+ labels.clear();
1792
+ group.removeFromParent();
1793
+ dom.remove();
1794
+ }
1795
+ };
1796
+ }
1797
+ // src/render/scene-pick.ts
1798
+ const bvhCache = /* @__PURE__ */ new WeakMap();
1799
+ function versionOf$1(attribute) {
1800
+ return attribute?.version ?? 0;
1801
+ }
1802
+ /** BVH for a mesh geometry, building it on first use. Null when the geometry can't take one. */
1803
+ function bvhFor(geometry) {
1804
+ const position = geometry.attributes.position;
1805
+ if (!position || position.count === 0) return null;
1806
+ const index = geometry.index;
1807
+ const key = index ?? position;
1808
+ const cached = bvhCache.get(key);
1809
+ if (cached === null) return null;
1810
+ if (cached && cached.position === position && cached.positionVersion === versionOf$1(position) && cached.indexVersion === versionOf$1(index)) return cached.bvh;
1811
+ const proxy = new THREE.BufferGeometry();
1812
+ proxy.setAttribute("position", position);
1813
+ if (index) proxy.setIndex(index);
1814
+ try {
1815
+ const bvh = new MeshBVH(proxy, { indirect: true });
1816
+ bvhCache.set(key, {
1817
+ position,
1818
+ positionVersion: versionOf$1(position),
1819
+ indexVersion: versionOf$1(index),
1820
+ bvh
1821
+ });
1822
+ return bvh;
1823
+ } catch {
1824
+ bvhCache.set(key, null);
1825
+ return null;
1826
+ }
1827
+ }
1828
+ function isBvhMesh(object) {
1829
+ const o = object;
1830
+ return o.isMesh === true && !o.isInstancedMesh && !o.isSkinnedMesh && !o.isBatchedMesh && !o.isLineSegments2 && !object.morphTargetInfluences?.length;
1831
+ }
1832
+ /**
1833
+ * Visible, pickable objects under `root`: hidden subtrees, viewer aids and edge overlays are
1834
+ * skipped. Overlays are skipped because they double the mesh's own edges and, as fat lines, cost a
1835
+ * linear scan of every segment per ray.
1836
+ */
1837
+ function collectPickable(root, out = []) {
1838
+ for (const child of root.children) {
1839
+ if (!child.visible || isViewerAidRoot(child) || isEdgeOverlay(child)) continue;
1840
+ out.push(child);
1841
+ collectPickable(child, out);
1842
+ }
1843
+ return out;
1844
+ }
1845
+ /**
1846
+ * Index-buffer ranges a merged mesh currently draws, or null when it draws everything. Hidden
1847
+ * members are the gaps between groups (`shared/merged-draw.ts`).
1848
+ */
1849
+ function drawnRanges(mesh) {
1850
+ const { groups } = mesh.geometry;
1851
+ if (!Array.isArray(mesh.material) || groups.length === 0) return null;
1852
+ const materials = mesh.material;
1853
+ return groups.filter((g) => materials[g.materialIndex ?? 0]?.visible !== false).map((g) => ({
1854
+ start: g.start,
1855
+ end: g.start + g.count
1856
+ }));
1857
+ }
1858
+ const _inverse = new THREE.Matrix4();
1859
+ const _localRay = new THREE.Ray();
1860
+ const _sphere = new THREE.Sphere();
1861
+ function raycastMeshBvh(mesh, bvh, raycaster) {
1862
+ _inverse.copy(mesh.matrixWorld).invert();
1863
+ _localRay.copy(raycaster.ray).applyMatrix4(_inverse);
1864
+ const ranges = drawnRanges(mesh);
1865
+ let best = null;
1866
+ if (!ranges) best = bvh.raycastFirst(_localRay, THREE.DoubleSide);
1867
+ else for (const hit of bvh.raycast(_localRay, THREE.DoubleSide)) {
1868
+ const at = (hit.faceIndex ?? 0) * 3;
1869
+ if (!ranges.some((r) => at >= r.start && at < r.end)) continue;
1870
+ if (!best || hit.distance < best.distance) best = hit;
1871
+ }
1872
+ if (!best) return null;
1873
+ best.point.applyMatrix4(mesh.matrixWorld);
1874
+ best.distance = best.point.distanceTo(raycaster.ray.origin);
1875
+ best.object = mesh;
1876
+ if (best.distance < raycaster.near || best.distance > raycaster.far) return null;
1877
+ return best;
1878
+ }
1879
+ /**
1880
+ * Every hit along `raycaster`'s ray over the visible content of `scene`, nearest first. Meshes use
1881
+ * the BVH; lines, points and instanced meshes fall back to their own `raycast`.
1882
+ */
1883
+ function raycastVisible(scene, raycaster) {
1884
+ const hits = [];
1885
+ for (const object of collectPickable(scene)) {
1886
+ if (!object.layers.test(raycaster.layers)) continue;
1887
+ if (isBvhMesh(object)) {
1888
+ const { geometry } = object;
1889
+ if (!geometry.boundingSphere) geometry.computeBoundingSphere();
1890
+ if (!geometry.boundingSphere) continue;
1891
+ _sphere.copy(geometry.boundingSphere).applyMatrix4(object.matrixWorld);
1892
+ if (!raycaster.ray.intersectsSphere(_sphere)) continue;
1893
+ const bvh = bvhFor(geometry);
1894
+ if (bvh) {
1895
+ const hit = raycastMeshBvh(object, bvh, raycaster);
1896
+ if (hit) hits.push(hit);
1897
+ continue;
1898
+ }
1899
+ }
1900
+ object.raycast(raycaster, hits);
1901
+ }
1902
+ return hits.sort((a, b) => a.distance - b.distance);
1903
+ }
1904
+ /**
1905
+ * Builds BVHs for the visible meshes under `root` one per idle slice, so the first hover over a
1906
+ * large model doesn't pay for all of them at once. Returns a cancel function.
1907
+ */
1908
+ function warmPickIndex(root) {
1909
+ const queue = collectPickable(root).filter(isBvhMesh);
1910
+ const idle = typeof requestIdleCallback === "function" ? (fn) => requestIdleCallback(fn, { timeout: 500 }) : (fn) => setTimeout(fn, 16);
1911
+ const cancelIdle = typeof cancelIdleCallback === "function" ? cancelIdleCallback : (id) => clearTimeout(id);
1912
+ let handle = 0;
1913
+ let cancelled = false;
1914
+ const step = () => {
1915
+ if (cancelled) return;
1916
+ const mesh = queue.shift();
1917
+ if (!mesh) return;
1918
+ bvhFor(mesh.geometry);
1919
+ handle = idle(step);
1920
+ };
1921
+ handle = idle(step);
1922
+ return () => {
1923
+ cancelled = true;
1924
+ cancelIdle(handle);
1925
+ };
1926
+ }
1927
+ // src/render/measure.ts
1928
+ const DEFAULT_SNAP_PIXELS = 12;
1929
+ const DEFAULT_COLOR = 16763904;
1930
+ const LINE_PICK_FRACTION = .015;
1931
+ const RENDER_ORDER_LINE = 998;
1932
+ const RENDER_ORDER_MARKER = 999;
1933
+ const RENDER_ORDER_HOVER = 1e3;
1934
+ const UNIT_DISPLAY = {
1935
+ Millimeters: {
1936
+ metersPerUnit: 1 / 1e3,
1937
+ suffix: "mm"
1938
+ },
1939
+ Centimeters: {
1940
+ metersPerUnit: 1 / 100,
1941
+ suffix: "cm"
1942
+ },
1943
+ Meters: {
1944
+ metersPerUnit: 1,
1945
+ suffix: "m"
1946
+ },
1947
+ Inches: {
1948
+ metersPerUnit: 1 / 39.37,
1949
+ suffix: "in"
1950
+ },
1951
+ Feet: {
1952
+ metersPerUnit: 1 / 3.28084,
1953
+ suffix: "ft"
1954
+ }
1955
+ };
1956
+ /**
1957
+ * Three significant figures below 1000, whole units above. `toPrecision(3)` alone switches to
1958
+ * exponent notation past 999 ("1.23e+4 mm"), which is unreadable on a building-scale model.
1959
+ *
1960
+ * @internal exported for tests
1961
+ */
1962
+ function makeFormatter(displayUnit) {
1963
+ const unit = displayUnit && UNIT_DISPLAY[displayUnit] || UNIT_DISPLAY.Meters;
1964
+ return (meters) => {
1965
+ const value = meters / unit.metersPerUnit;
1966
+ const abs = Math.abs(value);
1967
+ const decimals = abs === 0 ? 0 : Math.min(6, Math.max(0, 2 - Math.floor(Math.log10(abs))));
1968
+ return `${value.toFixed(decimals)} ${unit.suffix}`;
1969
+ };
1970
+ }
1971
+ /** @internal exported for tests */
1972
+ function makeDefaultFormat(fmt) {
1973
+ return (distance, delta) => {
1974
+ const eps = distance * 1e-6;
1975
+ const axes = [
1976
+ "x",
1977
+ "y",
1978
+ "z"
1979
+ ].filter((axis) => delta[axis] > eps);
1980
+ if (axes.length < 2) return fmt(distance);
1981
+ return `${fmt(distance)}\n${axes.map((axis) => `Δ${axis} ${fmt(delta[axis])}`).join(" ")}`;
1982
+ };
1983
+ }
1984
+ /**
1985
+ * Raycast threshold for picking lines/points, as a fixed fraction of view size so the grab band
1986
+ * stays constant on screen while zooming. Perspective uses camera→target distance (see
1987
+ * `MeasureDeps.getViewTarget`); orthographic uses frustum height `(top − bottom) / zoom`, since
1988
+ * ortho zoom changes `camera.zoom` rather than position.
1989
+ *
1990
+ * Shared with any tool doing its own picking, so grab bands stay consistent across tools.
1991
+ */
1992
+ function pickThreshold(camera, viewTarget) {
1993
+ if (camera.isOrthographicCamera) {
1994
+ const ortho = camera;
1995
+ return Math.abs(ortho.top - ortho.bottom) / (ortho.zoom || 1) * LINE_PICK_FRACTION;
1996
+ }
1997
+ return ((viewTarget ? camera.position.distanceTo(viewTarget) : camera.position.length()) || 1) * LINE_PICK_FRACTION;
1998
+ }
1999
+ /**
2000
+ * Local-space vertices to consider snapping to, by object type: Mesh → struck triangle's 3
2001
+ * vertices; Line/LineSegments and fat Line2/LineSegments2 → struck segment's 2 endpoints; Points →
2002
+ * the struck vertex. Null when the hit carries no usable index, so the caller keeps the raw point.
2003
+ */
2004
+ function snapCandidates(hit) {
2005
+ const obj = hit.object;
2006
+ const geometry = obj.geometry;
2007
+ if (!geometry) return null;
2008
+ if (obj.isLineSegments2) {
2009
+ const start = geometry.attributes.instanceStart;
2010
+ const end = geometry.attributes.instanceEnd;
2011
+ const i = hit.faceIndex;
2012
+ if (!start || !end || i == null || i >= start.count) return null;
2013
+ return [new THREE.Vector3().fromBufferAttribute(start, i), new THREE.Vector3().fromBufferAttribute(end, i)];
2014
+ }
2015
+ const pos = geometry.attributes.position;
2016
+ if (!pos) return null;
2017
+ const at = (indices) => indices.filter((i) => i < pos.count).map((i) => new THREE.Vector3().fromBufferAttribute(pos, i));
2018
+ if (obj instanceof THREE.Mesh) return hit.face ? at([
2019
+ hit.face.a,
2020
+ hit.face.b,
2021
+ hit.face.c
2022
+ ]) : null;
2023
+ if (obj instanceof THREE.Points) return hit.index != null ? at([hit.index]) : null;
2024
+ if (obj instanceof THREE.Line) {
2025
+ if (hit.index == null) return null;
2026
+ const index = geometry.index;
2027
+ if (index) {
2028
+ if (hit.index + 1 >= index.count) return null;
2029
+ return at([index.getX(hit.index), index.getX(hit.index + 1)]);
2030
+ }
2031
+ return at([hit.index, hit.index + 1]);
2032
+ }
2033
+ return null;
2034
+ }
2035
+ function toScreen(world, camera, screenSize) {
2036
+ const ndc = world.clone().project(camera);
2037
+ return new THREE.Vector2((ndc.x + 1) / 2 * screenSize.width, (1 - ndc.y) / 2 * screenSize.height);
2038
+ }
2039
+ /** Where a hit sits on screen: fat lines report the nearest point on the line separately. */
2040
+ function hitPoint(hit) {
2041
+ return hit.pointOnLine ?? hit.point;
2042
+ }
2043
+ function snapHit(hit, camera, screenSize, snapPixels) {
2044
+ const raw = hitPoint(hit).clone();
2045
+ const candidates = snapCandidates(hit);
2046
+ if (!candidates) return {
2047
+ point: raw,
2048
+ snapped: false
2049
+ };
2050
+ const rawScreen = toScreen(raw, camera, screenSize);
2051
+ let best = {
2052
+ point: raw,
2053
+ snapped: false
2054
+ };
2055
+ let bestPx = snapPixels;
2056
+ for (const local of candidates) {
2057
+ const world = local.applyMatrix4(hit.object.matrixWorld);
2058
+ const px = toScreen(world, camera, screenSize).distanceTo(rawScreen);
2059
+ if (px < bestPx) {
2060
+ bestPx = px;
2061
+ best = {
2062
+ point: world,
2063
+ snapped: true
2064
+ };
2065
+ }
2066
+ }
2067
+ return best;
2068
+ }
2069
+ /** Snap a raycast hit to the nearest geometry vertex within `snapPixels` on screen, else the raw hit point. */
2070
+ function snapToVertex(hit, camera, screenSize, snapPixels) {
2071
+ return snapHit(hit, camera, screenSize, snapPixels).point;
2072
+ }
2073
+ function createMeasureTool(deps) {
2074
+ const { canvas, scene, getActiveCamera, getViewTarget, labelLayer, options = {} } = deps;
2075
+ const requestRender = deps.requestRender ?? (() => {});
2076
+ const snapPixels = options.snapPixels ?? DEFAULT_SNAP_PIXELS;
2077
+ const color = new THREE.Color(options.color ?? DEFAULT_COLOR);
2078
+ const format = options.format ?? makeDefaultFormat(makeFormatter(options.displayUnit));
2079
+ const raycaster = new THREE.Raycaster();
2080
+ const pointer = new THREE.Vector2();
2081
+ let enabled = false;
2082
+ let start = null;
2083
+ const measurements = [];
2084
+ const markerMaterial = new THREE.PointsMaterial({
2085
+ color,
2086
+ size: 8,
2087
+ sizeAttenuation: false,
2088
+ depthTest: false
2089
+ });
2090
+ const hoverSnappedMaterial = new THREE.PointsMaterial({
2091
+ color,
2092
+ size: 12,
2093
+ sizeAttenuation: false,
2094
+ depthTest: false
2095
+ });
2096
+ const hoverFreeMaterial = new THREE.PointsMaterial({
2097
+ color,
2098
+ size: 7,
2099
+ sizeAttenuation: false,
2100
+ depthTest: false,
2101
+ transparent: true,
2102
+ opacity: .6
2103
+ });
2104
+ const tag = (object, renderOrder) => {
2105
+ object.renderOrder = renderOrder;
2106
+ object.userData.id = "measure";
2107
+ object.raycast = () => {};
2108
+ object.frustumCulled = false;
2109
+ return object;
2110
+ };
2111
+ const makePoints = (...ps) => {
2112
+ const geometry = new THREE.BufferGeometry();
2113
+ geometry.setAttribute("position", new THREE.Float32BufferAttribute(ps.flatMap((p) => [
2114
+ p.x,
2115
+ p.y,
2116
+ p.z
2117
+ ]), 3));
2118
+ const points = tag(new THREE.Points(geometry, markerMaterial), RENDER_ORDER_MARKER);
2119
+ scene.add(points);
2120
+ return points;
2121
+ };
2122
+ const makeLine = (a, b, opacity = 1) => {
2123
+ const geometry = new LineGeometry();
2124
+ geometry.setPositions([
2125
+ a.x,
2126
+ a.y,
2127
+ a.z,
2128
+ b.x,
2129
+ b.y,
2130
+ b.z
2131
+ ]);
2132
+ const material = new LineMaterial({
2133
+ color,
2134
+ transparent: opacity < 1,
2135
+ opacity
2136
+ });
2137
+ material.linewidth = 2;
2138
+ material.depthTest = false;
2139
+ const line = tag(new Line2(geometry, material), RENDER_ORDER_LINE);
2140
+ scene.add(line);
2141
+ return line;
2142
+ };
2143
+ const moveLine = (line, a, b) => {
2144
+ const buffer = line.geometry.attributes.instanceStart.data;
2145
+ buffer.array.set([
2146
+ a.x,
2147
+ a.y,
2148
+ a.z,
2149
+ b.x,
2150
+ b.y,
2151
+ b.z
2152
+ ]);
2153
+ buffer.needsUpdate = true;
2154
+ };
2155
+ const disposeLine = (line) => {
2156
+ line.geometry.dispose();
2157
+ line.material.dispose();
2158
+ line.removeFromParent();
2159
+ };
2160
+ const disposePoints = (points) => {
2161
+ points.geometry.dispose();
2162
+ points.removeFromParent();
2163
+ };
2164
+ const labelText = (a, b) => {
2165
+ const delta = new THREE.Vector3(Math.abs(b.x - a.x), Math.abs(b.y - a.y), Math.abs(b.z - a.z));
2166
+ return format(a.distanceTo(b), delta);
2167
+ };
2168
+ const midpoint = (a, b) => a.clone().add(b).multiplyScalar(.5);
2169
+ let hoverMarker = null;
2170
+ let rubberBand = null;
2171
+ let rubberLabel = null;
2172
+ const hidePreview = () => {
2173
+ if (hoverMarker) hoverMarker.visible = false;
2174
+ if (rubberBand) rubberBand.visible = false;
2175
+ rubberLabel?.remove();
2176
+ rubberLabel = null;
2177
+ };
2178
+ const showPreview = (snap) => {
2179
+ if (!snap) {
2180
+ hidePreview();
2181
+ return;
2182
+ }
2183
+ if (!hoverMarker) {
2184
+ hoverMarker = makePoints(new THREE.Vector3());
2185
+ hoverMarker.renderOrder = RENDER_ORDER_HOVER;
2186
+ }
2187
+ hoverMarker.material = snap.snapped ? hoverSnappedMaterial : hoverFreeMaterial;
2188
+ hoverMarker.position.copy(snap.point);
2189
+ hoverMarker.visible = true;
2190
+ if (!start) {
2191
+ if (rubberBand) rubberBand.visible = false;
2192
+ rubberLabel?.remove();
2193
+ rubberLabel = null;
2194
+ return;
2195
+ }
2196
+ if (!rubberBand) rubberBand = makeLine(start.point, snap.point, .7);
2197
+ moveLine(rubberBand, start.point, snap.point);
2198
+ rubberBand.visible = true;
2199
+ const text = labelText(start.point, snap.point);
2200
+ const mid = midpoint(start.point, snap.point);
2201
+ if (rubberLabel) {
2202
+ rubberLabel.setText(text);
2203
+ rubberLabel.setPosition(mid);
2204
+ } else rubberLabel = labelLayer.addLabel(text, mid, options.labelClassName);
2205
+ };
2206
+ const pick = (event) => {
2207
+ const rect = canvas.getBoundingClientRect();
2208
+ const cursorX = event.clientX - rect.left;
2209
+ const cursorY = event.clientY - rect.top;
2210
+ pointer.x = cursorX / rect.width * 2 - 1;
2211
+ pointer.y = -(cursorY / rect.height) * 2 + 1;
2212
+ const camera = getActiveCamera();
2213
+ raycaster.setFromCamera(pointer, camera);
2214
+ const threshold = pickThreshold(camera, getViewTarget?.()) * 2;
2215
+ raycaster.params.Line.threshold = threshold;
2216
+ raycaster.params.Points.threshold = threshold;
2217
+ raycaster.params.Line2 = { threshold: snapPixels / 2 };
2218
+ const screen = {
2219
+ width: rect.width,
2220
+ height: rect.height
2221
+ };
2222
+ const cursor = new THREE.Vector2(cursorX, cursorY);
2223
+ for (const hit of raycastVisible(scene, raycaster)) {
2224
+ if (!(hit.object.isMesh && hit.face != null) && toScreen(hitPoint(hit), camera, screen).distanceTo(cursor) > snapPixels) continue;
2225
+ return snapHit(hit, camera, screen, snapPixels);
2226
+ }
2227
+ return null;
2228
+ };
2229
+ let pendingMove = null;
2230
+ let moveRaf = 0;
2231
+ const cancelPendingMove = () => {
2232
+ if (moveRaf) {
2233
+ cancelAnimationFrame(moveRaf);
2234
+ moveRaf = 0;
2235
+ }
2236
+ pendingMove = null;
2237
+ };
2238
+ const handleMove = (event) => {
2239
+ if (!enabled) return;
2240
+ pendingMove = event;
2241
+ if (moveRaf) return;
2242
+ moveRaf = requestAnimationFrame(() => {
2243
+ moveRaf = 0;
2244
+ const latest = pendingMove;
2245
+ pendingMove = null;
2246
+ if (!enabled || !latest) return;
2247
+ showPreview(pick(latest));
2248
+ requestRender();
2249
+ });
2250
+ };
2251
+ const handleLeave = () => {
2252
+ cancelPendingMove();
2253
+ hidePreview();
2254
+ requestRender();
2255
+ };
2256
+ const dropStart = () => {
2257
+ if (!start) return;
2258
+ disposePoints(start.marker);
2259
+ start = null;
2260
+ };
2261
+ const clear = () => {
2262
+ dropStart();
2263
+ for (const m of measurements) {
2264
+ disposePoints(m.markers);
2265
+ disposeLine(m.line);
2266
+ m.label.remove();
2267
+ }
2268
+ measurements.length = 0;
2269
+ hidePreview();
2270
+ requestRender();
2271
+ };
2272
+ const cancel = () => {
2273
+ if (start) {
2274
+ dropStart();
2275
+ hidePreview();
2276
+ requestRender();
2277
+ } else clear();
2278
+ };
2279
+ const handleClick = (event) => {
2280
+ if (!enabled) return false;
2281
+ cancelPendingMove();
2282
+ const snap = pick(event);
2283
+ if (!snap) return true;
2284
+ if (!start) start = {
2285
+ point: snap.point,
2286
+ marker: makePoints(snap.point)
2287
+ };
2288
+ else if (!snap.point.equals(start.point)) {
2289
+ const a = start.point;
2290
+ const b = snap.point;
2291
+ dropStart();
2292
+ measurements.push({
2293
+ markers: makePoints(a, b),
2294
+ line: makeLine(a, b),
2295
+ label: labelLayer.addLabel(labelText(a, b), midpoint(a, b), options.labelClassName)
2296
+ });
2297
+ }
2298
+ showPreview(snap);
2299
+ requestRender();
2300
+ return true;
2301
+ };
2302
+ const handleKey = (event) => {
2303
+ if (event.key !== "Escape") return;
2304
+ if (event.target?.closest?.("input, textarea, select, [contenteditable]")) return;
2305
+ cancel();
2306
+ };
2307
+ let previousCursor = "";
2308
+ let cancelWarm = null;
2309
+ const setEnabled = (value) => {
2310
+ if (value === enabled) return;
2311
+ enabled = value;
2312
+ if (value) {
2313
+ previousCursor = canvas.style.cursor;
2314
+ canvas.style.cursor = "crosshair";
2315
+ canvas.addEventListener("mouseleave", handleLeave);
2316
+ window.addEventListener("keydown", handleKey);
2317
+ cancelWarm = warmPickIndex(scene);
2318
+ } else {
2319
+ canvas.style.cursor = previousCursor;
2320
+ canvas.removeEventListener("mouseleave", handleLeave);
2321
+ window.removeEventListener("keydown", handleKey);
2322
+ cancelWarm?.();
2323
+ cancelWarm = null;
2324
+ cancelPendingMove();
2325
+ clear();
2326
+ }
2327
+ };
2328
+ return {
2329
+ setEnabled,
2330
+ isEnabled: () => enabled,
2331
+ handleClick,
2332
+ handleMove,
2333
+ cancel,
2334
+ clear,
2335
+ dispose: () => {
2336
+ setEnabled(false);
2337
+ clear();
2338
+ if (hoverMarker) disposePoints(hoverMarker);
2339
+ if (rubberBand) disposeLine(rubberBand);
2340
+ hoverMarker = null;
2341
+ rubberBand = null;
2342
+ markerMaterial.dispose();
2343
+ hoverSnappedMaterial.dispose();
2344
+ hoverFreeMaterial.dispose();
2345
+ }
2346
+ };
2347
+ }
2348
+ // src/render/near-plane.ts
2349
+ /**
2350
+ * Depth-buffer precision is ∝ near/z²: at the 0.01 m floor a 24-bit buffer resolves ~0.15 mm at
2351
+ * 5 m and ~0.25 m at 200 m, so a sheet's two faces fight up close and coplanar surfaces fight far
2352
+ * away. Pushing `near` up to a fraction of the view depth of the nearest geometry in view recovers
2353
+ * that precision without clipping anything.
2354
+ *
2355
+ * The nearest geometry is measured on per-chunk triangle bounds, not the scene's bounding sphere:
2356
+ * zoomed in on a detail, the camera sits inside the whole model's sphere, which pinned near to the
2357
+ * floor exactly when precision mattered most. Chunks outside the frustum's side planes are skipped,
2358
+ * since geometry nobody sees can't be clipped.
2359
+ *
2360
+ * `far` stays owned by config/`updateScene` (must keep covering grid fade, floor). External writes
2361
+ * to `camera.near` are adopted as the new lower bound rather than fought — the fitter only ever
2362
+ * *raises* near above that floor.
2363
+ *
2364
+ * Ortho camera needs no fitting (linear depth) and is left untouched.
2365
+ */
2366
+ /** Safety margin: a host's `onFrame` can still move the camera after the fit. */
2367
+ const NEAR_GAP_FRACTION = .5;
2368
+ /** Caps near so the frustum stays sane if the camera flies out. */
2369
+ const MAX_NEAR_TO_FAR = .01;
2370
+ /** Skip sub-5% changes so the projection matrix isn't rebuilt every frame while orbiting. */
2371
+ const APPLY_THRESHOLD = .05;
2372
+ /**
2373
+ * Triangles per bounding box. Smaller fits tighter up close but costs more box tests per frame:
2374
+ * at 256, a 2M-triangle model is ~8k tests, well under a millisecond.
2375
+ */
2376
+ const TRIANGLES_PER_CHUNK = 256;
2377
+ const NO_GROUND_NORMALS = [];
2378
+ function createNearPlaneFitter({ camera, scene, groundNormals = () => NO_GROUND_NORMALS }) {
2379
+ let baseNear = camera.near;
2380
+ let appliedNear = camera.near;
2381
+ const nearest = createNearestDepthQuery();
2382
+ const update = () => {
2383
+ if (camera.near !== appliedNear) baseNear = camera.near;
2384
+ const depth = nearest.measure(scene, camera);
2385
+ let near = baseNear;
2386
+ if (depth !== null) {
2387
+ let gap = depth;
2388
+ for (const normal of groundNormals()) gap = Math.min(gap, Math.abs(camera.position.dot(normal)));
2389
+ near = THREE.MathUtils.clamp(gap * NEAR_GAP_FRACTION, baseNear, camera.far * MAX_NEAR_TO_FAR);
2390
+ }
2391
+ if (Math.abs(near - appliedNear) > appliedNear * APPLY_THRESHOLD) {
2392
+ camera.near = near;
2393
+ camera.updateProjectionMatrix();
2394
+ appliedNear = near;
2395
+ }
2396
+ };
2397
+ return { update };
2398
+ }
2399
+ /**
2400
+ * Smallest view depth (distance along the camera's forward axis) of any content inside the
2401
+ * frustum's side planes: `null` when the scene has no content, `Infinity` when none of it is in
2402
+ * view.
2403
+ *
2404
+ * A chunk's box only bounds that depth from below, and loosely: a big box reaches beside and behind
2405
+ * the camera even when its triangles are all well ahead. One 60-triangle wall around a building is
2406
+ * one box the size of the building, which used to pin near to the floor from anywhere in it. So a
2407
+ * box that could still be the nearest has its triangles clipped to the frustum and measured
2408
+ * exactly; boxes already further than the best so far are skipped.
2409
+ */
2410
+ function createNearestDepthQuery() {
2411
+ const chunkCache = /* @__PURE__ */ new WeakMap();
2412
+ const frustum = new THREE.Frustum();
2413
+ const viewProjection = new THREE.Matrix4();
2414
+ const forward = new THREE.Vector3();
2415
+ const box = new THREE.Box3();
2416
+ const worldPlanes = /* @__PURE__ */ new Float64Array(20);
2417
+ const localPlanes = /* @__PURE__ */ new Float64Array(20);
2418
+ const singleBox = /* @__PURE__ */ new Float32Array(6);
2419
+ let polygon = /* @__PURE__ */ new Float64Array(21);
2420
+ let clipped = /* @__PURE__ */ new Float64Array(21);
2421
+ let nearest = Infinity;
2422
+ let sawContent = false;
2423
+ const chunksOf = (object, geometry) => {
2424
+ const position = geometry.getAttribute("position");
2425
+ const index = geometry.getIndex();
2426
+ const cached = chunkCache.get(geometry);
2427
+ if (cached && cached.position === position && cached.positionVersion === versionOf(position) && cached.index === index && cached.indexVersion === (index?.version ?? -1)) return cached;
2428
+ const built = {
2429
+ ...buildChunkBoxes(position, index, memberStarts(object)),
2430
+ position,
2431
+ positionVersion: versionOf(position),
2432
+ index,
2433
+ indexVersion: index?.version ?? -1
2434
+ };
2435
+ chunkCache.set(geometry, built);
2436
+ return built;
2437
+ };
2438
+ /** Exact nearest depth of a chunk's triangles inside the frustum's side planes. */
2439
+ const chunkDepth = (chunks, chunk, planes) => {
2440
+ const { position, index } = chunks;
2441
+ const indices = index?.array;
2442
+ let best = Infinity;
2443
+ for (let t = chunks.ranges[chunk * 2]; t < chunks.ranges[chunk * 2 + 1]; t++) {
2444
+ for (let corner = 0; corner < 3; corner++) {
2445
+ const v = indices ? indices[t * 3 + corner] : t * 3 + corner;
2446
+ polygon[corner * 3] = position.getX(v);
2447
+ polygon[corner * 3 + 1] = position.getY(v);
2448
+ polygon[corner * 3 + 2] = position.getZ(v);
2449
+ }
2450
+ let count = 3;
2451
+ for (let p = 0; p < 16 && count > 0; p += 4) {
2452
+ const a = planes[p];
2453
+ const b = planes[p + 1];
2454
+ const c = planes[p + 2];
2455
+ const d = planes[p + 3];
2456
+ let kept = 0;
2457
+ for (let i = 0; i < count; i++) {
2458
+ const j = (i + 1) % count;
2459
+ const ix = polygon[i * 3];
2460
+ const iy = polygon[i * 3 + 1];
2461
+ const iz = polygon[i * 3 + 2];
2462
+ const jx = polygon[j * 3];
2463
+ const jy = polygon[j * 3 + 1];
2464
+ const jz = polygon[j * 3 + 2];
2465
+ const di = a * ix + b * iy + c * iz + d;
2466
+ const dj = a * jx + b * jy + c * jz + d;
2467
+ if (di >= 0) {
2468
+ clipped[kept * 3] = ix;
2469
+ clipped[kept * 3 + 1] = iy;
2470
+ clipped[kept * 3 + 2] = iz;
2471
+ kept++;
2472
+ }
2473
+ if (di >= 0 !== dj >= 0) {
2474
+ const f = di / (di - dj);
2475
+ clipped[kept * 3] = ix + (jx - ix) * f;
2476
+ clipped[kept * 3 + 1] = iy + (jy - iy) * f;
2477
+ clipped[kept * 3 + 2] = iz + (jz - iz) * f;
2478
+ kept++;
2479
+ }
2480
+ }
2481
+ [polygon, clipped] = [clipped, polygon];
2482
+ count = kept;
2483
+ }
2484
+ for (let i = 0; i < count; i++) {
2485
+ const depth = planes[16] * polygon[i * 3] + planes[17] * polygon[i * 3 + 1] + planes[18] * polygon[i * 3 + 2] + planes[19];
2486
+ if (depth < best) best = depth;
2487
+ }
2488
+ }
2489
+ return best;
2490
+ };
2491
+ const toLocalPlanes = (matrix) => {
2492
+ const e = matrix.elements;
2493
+ for (let i = 0; i < 20; i += 4) {
2494
+ const nx = worldPlanes[i];
2495
+ const ny = worldPlanes[i + 1];
2496
+ const nz = worldPlanes[i + 2];
2497
+ localPlanes[i] = e[0] * nx + e[1] * ny + e[2] * nz;
2498
+ localPlanes[i + 1] = e[4] * nx + e[5] * ny + e[6] * nz;
2499
+ localPlanes[i + 2] = e[8] * nx + e[9] * ny + e[10] * nz;
2500
+ localPlanes[i + 3] = nx * e[12] + ny * e[13] + nz * e[14] + worldPlanes[i + 3];
2501
+ }
2502
+ return localPlanes;
2503
+ };
2504
+ const testBoxes = (boxes, planes, chunks) => {
2505
+ outer: for (let b = 0; b < boxes.length; b += 6) {
2506
+ const minX = boxes[b];
2507
+ const minY = boxes[b + 1];
2508
+ const minZ = boxes[b + 2];
2509
+ const maxX = boxes[b + 3];
2510
+ const maxY = boxes[b + 4];
2511
+ const maxZ = boxes[b + 5];
2512
+ for (let p = 0; p < 16; p += 4) {
2513
+ const a = planes[p];
2514
+ const bb = planes[p + 1];
2515
+ const c = planes[p + 2];
2516
+ if (a * (a > 0 ? maxX : minX) + bb * (bb > 0 ? maxY : minY) + c * (c > 0 ? maxZ : minZ) + planes[p + 3] < 0) continue outer;
2517
+ }
2518
+ const a = planes[16];
2519
+ const bb = planes[17];
2520
+ const c = planes[18];
2521
+ const depth = a * (a > 0 ? minX : maxX) + bb * (bb > 0 ? minY : maxY) + c * (c > 0 ? minZ : maxZ) + planes[19];
2522
+ if (depth >= nearest) continue;
2523
+ nearest = chunks ? Math.min(nearest, chunkDepth(chunks, b / 6, planes)) : depth;
2524
+ }
2525
+ };
2526
+ const measureObject = (object) => {
2527
+ const geometry = object.geometry;
2528
+ if (!geometry?.getAttribute?.("position")) return;
2529
+ sawContent = true;
2530
+ if (isPlainTriangleMesh(object)) {
2531
+ const chunks = chunksOf(object, geometry);
2532
+ testBoxes(chunks.boxes, toLocalPlanes(object.matrixWorld), chunks);
2533
+ return;
2534
+ }
2535
+ const instanced = object;
2536
+ if (instanced.isInstancedMesh) {
2537
+ if (!instanced.boundingBox) instanced.computeBoundingBox();
2538
+ box.copy(instanced.boundingBox);
2539
+ } else {
2540
+ if (!geometry.boundingBox) geometry.computeBoundingBox();
2541
+ box.copy(geometry.boundingBox);
2542
+ }
2543
+ if (box.isEmpty()) return;
2544
+ singleBox[0] = box.min.x;
2545
+ singleBox[1] = box.min.y;
2546
+ singleBox[2] = box.min.z;
2547
+ singleBox[3] = box.max.x;
2548
+ singleBox[4] = box.max.y;
2549
+ singleBox[5] = box.max.z;
2550
+ testBoxes(singleBox, toLocalPlanes(object.matrixWorld));
2551
+ };
2552
+ const visit = (object) => {
2553
+ if (!object.visible || isViewerAidRoot(object)) return;
2554
+ if (object.userData.kind === "edge-overlay") return;
2555
+ measureObject(object);
2556
+ for (const child of object.children) visit(child);
2557
+ };
2558
+ const measure = (scene, camera) => {
2559
+ scene.updateMatrixWorld();
2560
+ camera.updateMatrixWorld();
2561
+ viewProjection.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
2562
+ frustum.setFromProjectionMatrix(viewProjection);
2563
+ for (let i = 0; i < 4; i++) {
2564
+ const plane = frustum.planes[i];
2565
+ worldPlanes[i * 4] = plane.normal.x;
2566
+ worldPlanes[i * 4 + 1] = plane.normal.y;
2567
+ worldPlanes[i * 4 + 2] = plane.normal.z;
2568
+ worldPlanes[i * 4 + 3] = plane.constant;
2569
+ }
2570
+ camera.getWorldDirection(forward);
2571
+ worldPlanes[16] = forward.x;
2572
+ worldPlanes[17] = forward.y;
2573
+ worldPlanes[18] = forward.z;
2574
+ worldPlanes[19] = -forward.dot(camera.position);
2575
+ nearest = Infinity;
2576
+ sawContent = false;
2577
+ for (const child of scene.children) visit(child);
2578
+ return sawContent ? nearest : null;
2579
+ };
2580
+ return { measure };
2581
+ }
2582
+ function isPlainTriangleMesh(object) {
2583
+ const mesh = object;
2584
+ if (!mesh.isMesh || mesh.isInstancedMesh || mesh.isSkinnedMesh) return false;
2585
+ const geometry = mesh.geometry;
2586
+ if (geometry.isInstancedBufferGeometry) return false;
2587
+ return Object.keys(geometry.morphAttributes).length === 0;
2588
+ }
2589
+ function versionOf(attribute) {
2590
+ return attribute.isInterleavedBufferAttribute ? attribute.data.version : attribute.version;
2591
+ }
2592
+ /**
2593
+ * Triangle offsets where a merged mesh's source objects begin. A chunk spanning two of them would
2594
+ * box the empty space between, and a camera parked in that space would read as touching geometry.
2595
+ */
2596
+ function memberStarts(object) {
2597
+ const starts = [];
2598
+ for (const member of membersOf(object) ?? []) if (member.indexStart !== void 0) starts.push(member.indexStart / 3);
2599
+ return starts.sort((a, b) => a - b);
2600
+ }
2601
+ /**
2602
+ * Bounds of consecutive runs of triangles, never crossing a `breaks` offset. Grouped by triangle,
2603
+ * not by vertex, so each box holds whole triangles: a big face whose corners all sit off-screen
2604
+ * still overlaps the frustum.
2605
+ */
2606
+ function buildChunkBoxes(position, index, breaks) {
2607
+ const triangleCount = Math.floor((index ? index.count : position.count) / 3);
2608
+ const ranges = [];
2609
+ let nextBreak = 0;
2610
+ for (let start = 0; start < triangleCount;) {
2611
+ while (nextBreak < breaks.length && breaks[nextBreak] <= start) nextBreak++;
2612
+ const end = Math.min(triangleCount, start + TRIANGLES_PER_CHUNK, breaks[nextBreak] ?? triangleCount);
2613
+ ranges.push(start, end);
2614
+ start = end;
2615
+ }
2616
+ const chunkCount = ranges.length / 2;
2617
+ const boxes = new Float32Array(chunkCount * 6);
2618
+ const plain = !position.isInterleavedBufferAttribute;
2619
+ const array = position.array;
2620
+ const fast = plain && position.itemSize === 3 && !position.normalized;
2621
+ const indices = index?.array;
2622
+ for (let chunk = 0; chunk < chunkCount; chunk++) {
2623
+ let minX = Infinity;
2624
+ let minY = Infinity;
2625
+ let minZ = Infinity;
2626
+ let maxX = -Infinity;
2627
+ let maxY = -Infinity;
2628
+ let maxZ = -Infinity;
2629
+ const end = ranges[chunk * 2 + 1] * 3;
2630
+ for (let corner = ranges[chunk * 2] * 3; corner < end; corner++) {
2631
+ const v = indices ? indices[corner] : corner;
2632
+ const x = fast ? array[v * 3] : position.getX(v);
2633
+ const y = fast ? array[v * 3 + 1] : position.getY(v);
2634
+ const z = fast ? array[v * 3 + 2] : position.getZ(v);
2635
+ if (x < minX) minX = x;
2636
+ if (y < minY) minY = y;
2637
+ if (z < minZ) minZ = z;
2638
+ if (x > maxX) maxX = x;
2639
+ if (y > maxY) maxY = y;
2640
+ if (z > maxZ) maxZ = z;
2641
+ }
2642
+ const o = chunk * 6;
2643
+ boxes[o] = minX;
2644
+ boxes[o + 1] = minY;
2645
+ boxes[o + 2] = minZ;
2646
+ boxes[o + 3] = maxX;
2647
+ boxes[o + 4] = maxY;
2648
+ boxes[o + 5] = maxZ;
2649
+ }
2650
+ return {
2651
+ boxes,
2652
+ ranges: Uint32Array.from(ranges)
2653
+ };
2654
+ }
2655
+ // src/render/tool-registry.ts
2656
+ function createToolRegistry() {
2657
+ const entries = [];
2658
+ let activeId = null;
2659
+ const sort = () => entries.sort((a, b) => b.priority - a.priority);
2660
+ const indexOf = (id) => entries.findIndex((entry) => entry.id === id);
2661
+ const unregister = (id) => {
2662
+ const index = indexOf(id);
2663
+ if (index === -1) return;
2664
+ entries.splice(index, 1);
2665
+ if (activeId === id) activeId = null;
2666
+ };
2667
+ const register = ({ id, tool, priority = 0 }) => {
2668
+ unregister(id);
2669
+ entries.push({
2670
+ id,
2671
+ tool,
2672
+ priority
2673
+ });
2674
+ sort();
2675
+ return () => unregister(id);
2676
+ };
2677
+ const setActive = (id) => {
2678
+ activeId = id;
2679
+ for (const entry of entries) entry.tool.setEnabled?.(entry.id === id);
2680
+ };
2681
+ return {
2682
+ register,
2683
+ unregister,
2684
+ get: (id) => entries[indexOf(id)]?.tool ?? null,
2685
+ setActive,
2686
+ getActive: () => activeId,
2687
+ handleClick: (event) => {
2688
+ for (const entry of [...entries]) if (entry.tool.handleClick(event)) return true;
2689
+ return false;
2690
+ },
2691
+ handleMove: (event) => {
2692
+ for (const entry of [...entries]) entry.tool.handleMove?.(event);
2693
+ }
2694
+ };
2695
+ }
2696
+ /**
2697
+ * Screen-space ray from a canvas mouse event, for tools doing their own picking. Handles the
2698
+ * canvas's position and size, so it stays correct under CSS scaling and in fullscreen.
2699
+ */
2700
+ function pointerToNdc(event, canvas) {
2701
+ const rect = canvas.getBoundingClientRect();
2702
+ return {
2703
+ x: (event.clientX - rect.left) / rect.width * 2 - 1,
2704
+ y: -((event.clientY - rect.top) / rect.height) * 2 + 1
2705
+ };
2706
+ }
2707
+ // src/render/view-gizmo.ts
2708
+ function createViewGizmo(deps) {
2709
+ const { camera, domElement, controller } = deps;
2710
+ const helper = new ViewHelper(camera, domElement);
2711
+ helper.setLabels("X", "Y", "Z");
2712
+ let visible = true;
2713
+ const DIM = 128;
2714
+ const raycaster = new THREE.Raycaster();
2715
+ const gizmoCamera = new THREE.OrthographicCamera(-2, 2, 2, -2, 0, 4);
2716
+ gizmoCamera.position.set(0, 0, 2);
2717
+ gizmoCamera.updateMatrixWorld();
2718
+ const AXIS_DIRECTIONS = {
2719
+ posX: new THREE.Vector3(1, 0, 0),
2720
+ negX: new THREE.Vector3(-1, 0, 0),
2721
+ posY: new THREE.Vector3(0, 1, 0),
2722
+ negY: new THREE.Vector3(0, -1, 0),
2723
+ posZ: new THREE.Vector3(0, 0, 1),
2724
+ negZ: new THREE.Vector3(0, 0, -1)
2725
+ };
2726
+ const pickAxis = (event) => {
2727
+ const rect = domElement.getBoundingClientRect();
2728
+ const offsetX = rect.left + domElement.offsetWidth - DIM - helper.location.right;
2729
+ const offsetY = rect.top + domElement.offsetHeight - DIM - helper.location.bottom;
2730
+ const mouse = new THREE.Vector2((event.clientX - offsetX) / DIM * 2 - 1, -((event.clientY - offsetY) / DIM) * 2 + 1);
2731
+ if (Math.abs(mouse.x) > 1 || Math.abs(mouse.y) > 1) return null;
2732
+ helper.quaternion.copy(camera.quaternion).invert();
2733
+ helper.updateMatrixWorld();
2734
+ raycaster.setFromCamera(mouse, gizmoCamera);
2735
+ const hits = raycaster.intersectObjects(helper.children, false);
2736
+ for (const hit of hits) {
2737
+ const type = hit.object.userData?.type;
2738
+ if (typeof type === "string" && type in AXIS_DIRECTIONS) return type;
2739
+ }
2740
+ return null;
2741
+ };
2742
+ const handleClick = (event) => {
2743
+ if (!visible) return false;
2744
+ const axis = pickAxis(event);
2745
+ if (!axis) return false;
2746
+ if (controller.getProjection() === "orthographic") controller.setProjection("perspective");
2747
+ controller.setViewDirection(AXIS_DIRECTIONS[axis], false);
2748
+ return true;
2749
+ };
2750
+ return {
2751
+ render: (renderer) => {
2752
+ if (!visible) return;
2753
+ const prevAutoClear = renderer.autoClear;
2754
+ renderer.autoClear = false;
2755
+ helper.render(renderer);
2756
+ renderer.autoClear = prevAutoClear;
2757
+ },
2758
+ handleClick,
2759
+ setVisible: (value) => {
2760
+ visible = value;
2761
+ },
2762
+ isVisible: () => visible,
2763
+ dispose: () => helper.dispose()
2764
+ };
2765
+ }
2766
+ // src/render/scene-setup/animation-loop.ts
2767
+ function createAnimationLoop(renderer, scene, camera, getActiveCamera, cameraController, controls, getCanvasSize, getPixelRatio, onFrame, grid, gizmo, getRenderPipeline, labelLayer, nearFitter, onDemand = true, refiner) {
2768
+ let animationId = null;
2769
+ let lastTime = performance.now();
2770
+ let renderRequested = true;
2771
+ let lastRenderTime = 0;
2772
+ const IDLE_REPAINT_INTERVAL_MS = 500;
2773
+ const lastWorldMatrix = new THREE.Matrix4();
2774
+ const lastProjectionMatrix = new THREE.Matrix4();
2775
+ let lastCamera = null;
2776
+ const invalidate = () => {
2777
+ renderRequested = true;
2778
+ };
2779
+ const cameraMoved = (activeCamera) => {
2780
+ activeCamera.updateMatrixWorld();
2781
+ const moved = lastCamera !== activeCamera || !lastWorldMatrix.equals(activeCamera.matrixWorld) || !lastProjectionMatrix.equals(activeCamera.projectionMatrix);
2782
+ if (moved) {
2783
+ lastCamera = activeCamera;
2784
+ lastWorldMatrix.copy(activeCamera.matrixWorld);
2785
+ lastProjectionMatrix.copy(activeCamera.projectionMatrix);
2786
+ }
2787
+ return moved;
2788
+ };
2789
+ const canvas = renderer.domElement;
2790
+ const pointerEvents = [
2791
+ "pointerdown",
2792
+ "pointerup",
2793
+ "wheel"
2794
+ ];
2795
+ if (onDemand) for (const type of pointerEvents) canvas.addEventListener(type, invalidate, { passive: true });
2796
+ const checkResize = () => {
2797
+ const { width, height } = getCanvasSize();
2798
+ if (width === 0 || height === 0) return;
2799
+ const pixelRatio = getPixelRatio();
2800
+ const newW = Math.floor(width * pixelRatio);
2801
+ const newH = Math.floor(height * pixelRatio);
2802
+ if (renderer.domElement.width !== newW || renderer.domElement.height !== newH) {
2803
+ renderer.setPixelRatio(pixelRatio);
2804
+ renderer.setSize(width, height, false);
2805
+ camera.aspect = width / height;
2806
+ camera.updateProjectionMatrix();
2807
+ cameraController.updateAspect(width, height);
2808
+ getRenderPipeline?.()?.setSize(width, height, pixelRatio);
2809
+ labelLayer?.setSize(width, height);
2810
+ invalidate();
2811
+ }
2812
+ };
2813
+ const drawOverlays = (activeCamera) => {
2814
+ if (labelLayer) labelLayer.render(scene, activeCamera);
2815
+ if (gizmo) gizmo.render(renderer);
2816
+ };
2817
+ const drawFrame = (activeCamera, delta) => {
2818
+ const renderPipeline = getRenderPipeline?.();
2819
+ if (renderPipeline) {
2820
+ renderPipeline.setCamera(activeCamera);
2821
+ renderPipeline.render(delta);
2822
+ } else renderer.render(scene, activeCamera);
2823
+ drawOverlays(activeCamera);
2824
+ };
2825
+ const renderNow = () => {
2826
+ const activeCamera = getActiveCamera();
2827
+ if (refiner?.present()) drawOverlays(activeCamera);
2828
+ else drawFrame(activeCamera, 0);
2829
+ };
2830
+ const animate = function() {
2831
+ animationId = requestAnimationFrame(animate);
2832
+ const now = performance.now();
2833
+ const delta = (now - lastTime) / 1e3;
2834
+ lastTime = now;
2835
+ checkResize();
2836
+ if (controls.enableDamping || controls.autoRotate) controls.update();
2837
+ if (grid) grid.update(getActiveCamera().position);
2838
+ if (nearFitter) nearFitter.update();
2839
+ onFrame?.(delta);
2840
+ const activeCamera = getActiveCamera();
2841
+ if (onDemand) {
2842
+ const moved = cameraMoved(activeCamera);
2843
+ if (moved) refiner?.moving(delta * 1e3);
2844
+ const changed = renderRequested || moved;
2845
+ if (!changed && refiner?.wants(now)) {
2846
+ refiner.step(activeCamera);
2847
+ drawOverlays(activeCamera);
2848
+ return;
2849
+ }
2850
+ const repaint = now - lastRenderTime >= IDLE_REPAINT_INTERVAL_MS;
2851
+ if (!changed && !repaint) return;
2852
+ lastRenderTime = now;
2853
+ if (!changed && refiner?.isHolding()) return;
2854
+ renderRequested = false;
2855
+ refiner?.reset(now);
2856
+ }
2857
+ drawFrame(activeCamera, delta);
2858
+ };
2859
+ const dispose = () => {
2860
+ if (animationId !== null) {
2861
+ cancelAnimationFrame(animationId);
2862
+ animationId = null;
2863
+ }
2864
+ if (onDemand) for (const type of pointerEvents) canvas.removeEventListener(type, invalidate);
2865
+ };
2866
+ return {
2867
+ animate,
2868
+ dispose,
2869
+ invalidate,
2870
+ renderNow
2871
+ };
2872
+ }
2873
+ // src/render/scene-setup/emissive-lights.ts
2874
+ /** Point lights are expensive: every lit material pays for each one in its shader. */
2875
+ const DEFAULT_MAX_LIGHTS = 3;
2876
+ /** Six shadow-map faces per light, each drawing the whole scene. */
2877
+ const MAX_SHADOWED_LIGHTS = 2;
2878
+ const SHADOW_MAP_SIZE = 1024;
2879
+ /** A glow this far below the strongest is a lit surface, not a source: a diffuser, not the bulb. */
2880
+ const MIN_RELATIVE_STRENGTH = 1 / 3;
2881
+ /** Parts this close, as a share of the larger one's diagonal, are one lamp: a bulb and its reflector. */
2882
+ const SAME_LAMP_GAP = .05;
2883
+ /**
2884
+ * three's point lights are in candela, so brightness falls off with the scene's units squared.
2885
+ * Scaling by the scene's radius squared makes one strength read the same in millimetres and metres.
2886
+ */
2887
+ const INTENSITY_PER_STRENGTH = .17;
2888
+ /** Share of a source's light that comes back off the room, with no shadow to stop it. */
2889
+ const BOUNCE_SHARE = .07;
2890
+ /** A lamp's source has a size, so its shadow has a penumbra; three's default edge is a hard line. */
2891
+ const SHADOW_SOFTNESS = 7;
2892
+ /** A source wider than this share of the scene isn't a point. */
2893
+ const MAX_POINT_SOURCE_SPREAD = .15;
2894
+ function emissiveMaterialOf(mesh) {
2895
+ const material = getBaseMaterial(mesh);
2896
+ return material?.userData["selvaEmissive"] ? material : null;
2897
+ }
2898
+ function drawnBounds(mesh) {
2899
+ const members = membersOf(mesh);
2900
+ if (!members) return [new THREE.Box3().setFromObject(mesh)];
2901
+ const hidden = getHiddenMembers(mesh);
2902
+ return members.filter((_, index) => !hidden.has(index)).map((member) => memberBounds(mesh, member)).filter((bounds) => !bounds.isEmpty());
2903
+ }
2904
+ function touches(a, b) {
2905
+ const diagonal = (box) => box.getSize(new THREE.Vector3()).length();
2906
+ const gap = Math.max(diagonal(a), diagonal(b)) * SAME_LAMP_GAP;
2907
+ return a.clone().expandByScalar(gap).intersectsBox(b);
2908
+ }
2909
+ /**
2910
+ * Groups parts of one glow into lamps by contact. More lamps than lights get one light in the
2911
+ * middle: lighting three of a street's lamps would pick them by mesh order.
2912
+ */
2913
+ function lampsOf(parts, maxLights) {
2914
+ let lamps = [];
2915
+ for (const part of parts) {
2916
+ const lamp = {
2917
+ bounds: part.bounds.clone(),
2918
+ meshes: /* @__PURE__ */ new Set([part.mesh])
2919
+ };
2920
+ const separate = [];
2921
+ for (const other of lamps) if (touches(other.bounds, part.bounds)) {
2922
+ lamp.bounds.union(other.bounds);
2923
+ for (const mesh of other.meshes) lamp.meshes.add(mesh);
2924
+ } else separate.push(other);
2925
+ lamps = [...separate, lamp];
2926
+ if (lamps.length > maxLights) {
2927
+ const bounds = new THREE.Box3();
2928
+ for (const each of parts) bounds.union(each.bounds);
2929
+ return [{
2930
+ bounds,
2931
+ meshes: new Set(parts.map((each) => each.mesh))
2932
+ }];
2933
+ }
2934
+ }
2935
+ return lamps;
2936
+ }
2937
+ function sourcesFrom(parts, maxLights) {
2938
+ const byGlow = /* @__PURE__ */ new Map();
2939
+ for (const part of parts) {
2940
+ const { emissive, emissiveIntensity, userData } = part.material;
2941
+ const glow = `${emissive.getHexString()}|${emissiveIntensity}|${userData[EMISSIVE_LIGHT]}`;
2942
+ const same = byGlow.get(glow);
2943
+ if (same) same.push(part);
2944
+ else byGlow.set(glow, [part]);
2945
+ }
2946
+ return [...byGlow.values()].flatMap((same) => {
2947
+ const { emissive, emissiveIntensity, userData } = same[0].material;
2948
+ return lampsOf(same, maxLights).map((lamp) => ({
2949
+ ...lamp,
2950
+ color: emissive,
2951
+ strength: emissiveIntensity,
2952
+ light: userData[EMISSIVE_LIGHT]
2953
+ }));
2954
+ });
2955
+ }
2956
+ /**
2957
+ * Picks the strongest emissive materials to light the scene. Exported for tests: which surface
2958
+ * counts as a source decides whether a lamp's desk gets one pool of light or a washed-out pair.
2959
+ */
2960
+ function pickLightSources(sources, maxLights = DEFAULT_MAX_LIGHTS) {
2961
+ const strongest = Math.max(0, ...sources.map((s) => s.strength));
2962
+ return sources.filter((s) => s.strength >= strongest * MIN_RELATIVE_STRENGTH).sort((a, b) => b.strength - a.strength).slice(0, maxLights);
2963
+ }
2964
+ /**
2965
+ * An emissive surface only looks bright, it lights nothing, so a look that wants real light from
2966
+ * glowing parts gets a point light at each lamp. A picked lamp stops casting shadows meanwhile, and
2967
+ * so does a dimmer glow around its light: a bulb or its diffuser would otherwise block the light.
2968
+ */
2969
+ function createEmissiveLights(scene) {
2970
+ const lights = [];
2971
+ const silenced = /* @__PURE__ */ new Set();
2972
+ const offset = new THREE.Vector3();
2973
+ const addLight = (shadowed) => {
2974
+ const key = new THREE.PointLight(16777215, 1, 0, 2);
2975
+ const bounce = new THREE.PointLight(16777215, 1, 0, 1);
2976
+ if (shadowed) {
2977
+ key.castShadow = true;
2978
+ key.shadow.mapSize.setScalar(SHADOW_MAP_SIZE);
2979
+ key.shadow.radius = SHADOW_SOFTNESS;
2980
+ key.shadow.autoUpdate = false;
2981
+ }
2982
+ const pair = {
2983
+ key,
2984
+ bounce,
2985
+ home: new THREE.Vector3(),
2986
+ size: new THREE.Vector3()
2987
+ };
2988
+ lights.push(pair);
2989
+ scene.add(key, bounce);
2990
+ return pair;
2991
+ };
2992
+ const collect = () => {
2993
+ const content = new THREE.Box3();
2994
+ const parts = [];
2995
+ scene.traverseVisible((object) => {
2996
+ const mesh = object;
2997
+ if (!mesh.isMesh || object.userData.source !== "compute") return;
2998
+ content.union(new THREE.Box3().setFromObject(mesh));
2999
+ const material = emissiveMaterialOf(mesh);
3000
+ if (!material) return;
3001
+ for (const bounds of drawnBounds(mesh)) parts.push({
3002
+ mesh,
3003
+ material,
3004
+ bounds
3005
+ });
3006
+ });
3007
+ return {
3008
+ parts,
3009
+ radius: content.getSize(new THREE.Vector3()).length() / 2
3010
+ };
3011
+ };
3012
+ const silence = (parts, sources) => {
3013
+ for (const { mesh, bounds } of parts) if (sources.some((source, index) => source.meshes.has(mesh) || bounds.containsPoint(lights[index].key.position)) && mesh.castShadow) {
3014
+ mesh.castShadow = false;
3015
+ silenced.add(mesh);
3016
+ }
3017
+ };
3018
+ const sync = (enabled, maxLights = DEFAULT_MAX_LIGHTS) => {
3019
+ for (const mesh of silenced) mesh.castShadow = true;
3020
+ silenced.clear();
3021
+ const { parts, radius } = enabled ? collect() : {
3022
+ parts: [],
3023
+ radius: 0
3024
+ };
3025
+ const sources = pickLightSources(sourcesFrom(parts, maxLights), maxLights);
3026
+ sources.forEach((source, index) => {
3027
+ const { key, bounce, home, size } = lights[index] ?? addLight(index < MAX_SHADOWED_LIGHTS);
3028
+ const intensity = source.light ?? source.strength * INTENSITY_PER_STRENGTH * radius * radius;
3029
+ key.color.copy(source.color);
3030
+ const spread = source.bounds.getSize(new THREE.Vector3()).length() / 2 / radius;
3031
+ key.intensity = !source.light && spread > MAX_POINT_SOURCE_SPREAD ? 0 : intensity;
3032
+ source.bounds.getCenter(key.position);
3033
+ home.copy(key.position);
3034
+ source.bounds.getSize(size);
3035
+ bounce.color.copy(source.color);
3036
+ bounce.intensity = intensity * BOUNCE_SHARE / radius;
3037
+ bounce.position.copy(key.position);
3038
+ if (key.castShadow) {
3039
+ key.shadow.camera.near = radius * .005;
3040
+ key.shadow.camera.far = radius * 4;
3041
+ key.shadow.camera.updateProjectionMatrix();
3042
+ key.shadow.bias = -.002;
3043
+ key.shadow.normalBias = radius * .002;
3044
+ key.shadow.needsUpdate = true;
3045
+ }
3046
+ });
3047
+ while (lights.length > sources.length) {
3048
+ const { key, bounce } = lights.pop();
3049
+ scene.remove(key, bounce);
3050
+ key.dispose();
3051
+ bounce.dispose();
3052
+ }
3053
+ silence(parts, sources);
3054
+ };
3055
+ const place = (position) => {
3056
+ for (const { key, home, size } of lights) {
3057
+ key.position.copy(position(home, size));
3058
+ if (key.castShadow) key.shadow.needsUpdate = true;
3059
+ }
3060
+ };
3061
+ return {
3062
+ sync,
3063
+ jitter: (index) => place((home, size) => offset.set(halton(index, 2) - .5, halton(index, 3) - .5, halton(index, 5) - .5).multiply(size).add(home)),
3064
+ settle: () => place((home) => home),
3065
+ dispose: () => sync(false)
3066
+ };
3067
+ }
3068
+ // src/render/scene-setup/appearance.ts
3069
+ const BASELINE_KEY = "__selvaLookBaseline";
3070
+ function applyBaseline(target, baseline) {
3071
+ target.color?.setHex(baseline.color);
3072
+ if (target.metalness !== void 0) target.metalness = baseline.metalness;
3073
+ if (target.roughness !== void 0) target.roughness = baseline.roughness;
3074
+ if (target.emissiveIntensity !== void 0) target.emissiveIntensity = baseline.emissiveIntensity;
3075
+ target.opacity = baseline.opacity;
3076
+ target.transparent = baseline.transparent;
3077
+ target.depthWrite = baseline.depthWrite;
3078
+ if (target.wireframe !== void 0) target.wireframe = baseline.wireframe;
3079
+ target.side = baseline.side;
3080
+ }
3081
+ /** Restores the parsed values and forgets the baseline. No-op when nothing was overridden. */
3082
+ function restoreBaseline(target) {
3083
+ const baseline = target[BASELINE_KEY];
3084
+ if (!baseline) return;
3085
+ applyBaseline(target, baseline);
3086
+ delete target[BASELINE_KEY];
3087
+ }
3088
+ /**
3089
+ * Applies a look's material overrides, or restores the parsed values when the look has none.
3090
+ * `needsUpdate` is set because switching `transparent` changes the shader program, not just a
3091
+ * uniform.
3092
+ *
3093
+ * Exported for tests: the leak this guards against is invisible until two overriding looks are
3094
+ * applied in sequence, which no single-look check catches.
3095
+ *
3096
+ * Every apply restores the baseline first, so an override only has to describe what *it* wants.
3097
+ * Without that reset, switching between two looks that both override leaves behind whatever the
3098
+ * previous one set and the new one is silent about — `wireframe` surviving out of the wireframe
3099
+ * look, or `opacity`/`depthWrite` surviving out of xray, since no other look mentions them.
3100
+ */
3101
+ function applyMaterialOverride(material, override) {
3102
+ const target = material;
3103
+ if (!override) {
3104
+ restoreBaseline(target);
3105
+ target.needsUpdate = true;
3106
+ return;
3107
+ }
3108
+ if (!target[BASELINE_KEY]) target[BASELINE_KEY] = {
3109
+ color: target.color?.getHex() ?? 16777215,
3110
+ metalness: target.metalness ?? 0,
3111
+ roughness: target.roughness ?? 1,
3112
+ emissiveIntensity: target.emissiveIntensity ?? 0,
3113
+ opacity: target.opacity,
3114
+ transparent: target.transparent,
3115
+ depthWrite: target.depthWrite,
3116
+ wireframe: target.wireframe ?? false,
3117
+ side: target.side
3118
+ };
3119
+ else applyBaseline(target, target[BASELINE_KEY]);
3120
+ if (override.color !== void 0) target.color?.setHex(override.color);
3121
+ if (override.metalness !== void 0 && target.metalness !== void 0) target.metalness = override.metalness;
3122
+ if (override.roughness !== void 0 && target.roughness !== void 0) target.roughness = override.roughness;
3123
+ if (override.emissiveIntensity !== void 0 && target.userData["selvaEmissive"]) target.emissiveIntensity = override.emissiveIntensity;
3124
+ if (override.opacity !== void 0) {
3125
+ target.opacity = override.opacity;
3126
+ target.transparent = override.opacity < 1;
3127
+ }
3128
+ if (override.depthWrite !== void 0) target.depthWrite = override.depthWrite;
3129
+ if (override.wireframe !== void 0 && target.wireframe !== void 0) target.wireframe = override.wireframe;
3130
+ if (override.side !== void 0) target.side = override.side;
3131
+ target.needsUpdate = true;
3132
+ }
3133
+ /** A material's own value (see `OWN_ENV_MAP_INTENSITY`) unless the look restyles every material. */
3134
+ function envMapIntensityForLook(material, preset) {
3135
+ if (preset.materialOverride) return preset.envMapIntensity;
3136
+ const own = material.userData[OWN_ENV_MAP_INTENSITY];
3137
+ if (own != null) return own;
3138
+ const target = material;
3139
+ const metalness = target[BASELINE_KEY]?.metalness ?? target.metalness ?? 0;
3140
+ return lookEnvMapIntensity(preset.envMapIntensity, metalness);
3141
+ }
3142
+ function lookPixelRatio(fullPixelRatio, preset) {
3143
+ return Math.min(fullPixelRatio, preset.maxPixelRatio ?? fullPixelRatio);
3144
+ }
3145
+ function createAppearanceController(params) {
3146
+ const { scene, renderer, lights, config, pipeline, fullPixelRatio, requestRender } = params;
3147
+ const presetOf = (look) => lookPreset(look, config.render.lowPower);
3148
+ let activeLook = config.look;
3149
+ const emissiveLights = createEmissiveLights(scene);
3150
+ const ownShadowType = renderer.shadowMap.type;
3151
+ const applyShadowType = (preset) => {
3152
+ const type = preset.emissiveLights ? THREE.PCFShadowMap : ownShadowType;
3153
+ if (renderer.shadowMap.type === type) return;
3154
+ renderer.shadowMap.type = type;
3155
+ renderer.shadowMap.needsUpdate = true;
3156
+ };
3157
+ let ownBackground;
3158
+ const applyBackground = (preset) => {
3159
+ if (preset.background === void 0) {
3160
+ if (ownBackground !== void 0) scene.background = ownBackground;
3161
+ ownBackground = void 0;
3162
+ return;
3163
+ }
3164
+ ownBackground ??= scene.background;
3165
+ scene.background = new THREE.Color(preset.background);
3166
+ };
3167
+ applyBackground(presetOf(activeLook));
3168
+ applyShadowType(presetOf(activeLook));
3169
+ const setFillLights = (opts) => {
3170
+ if (opts.ambientIntensity !== void 0) lights.ambient.intensity = opts.ambientIntensity;
3171
+ if (opts.hemisphereIntensity !== void 0 && !lights.hemisphere && opts.hemisphereIntensity > 0) {
3172
+ lights.hemisphere = new THREE.HemisphereLight(opts.hemisphereSkyColor ?? config.lighting.hemisphereSkyColor, opts.hemisphereGroundColor ?? config.lighting.hemisphereGroundColor, opts.hemisphereIntensity);
3173
+ lights.hemisphere.position.copy(config.environment.sceneUp ?? defaultUp);
3174
+ scene.add(lights.hemisphere);
3175
+ }
3176
+ if (lights.hemisphere) {
3177
+ if (opts.hemisphereIntensity !== void 0) lights.hemisphere.intensity = opts.hemisphereIntensity;
3178
+ if (opts.hemisphereSkyColor !== void 0) lights.hemisphere.color.set(opts.hemisphereSkyColor);
3179
+ if (opts.hemisphereGroundColor !== void 0) lights.hemisphere.groundColor.set(opts.hemisphereGroundColor);
3180
+ }
3181
+ requestRender();
3182
+ };
3183
+ const setEnvironmentIntensity = (intensity) => {
3184
+ config.environment.environmentIntensity = intensity;
3185
+ scene.environmentIntensity = intensity;
3186
+ requestRender();
3187
+ };
3188
+ const setToneMappingExposure = (exposure) => {
3189
+ config.render.toneMappingExposure = exposure;
3190
+ renderer.toneMappingExposure = exposure;
3191
+ if (pipeline.get()) pipeline.rebuild();
3192
+ };
3193
+ const setAoIntensity = (intensity) => {
3194
+ config.render.aoIntensity = intensity;
3195
+ if (pipeline.get()) pipeline.rebuild();
3196
+ };
3197
+ const syncEmissiveLights = () => {
3198
+ const { emissiveLights: enabled, maxEmissiveLights } = presetOf(activeLook);
3199
+ emissiveLights.sync(!!enabled, maxEmissiveLights);
3200
+ requestRender();
3201
+ };
3202
+ const setLook = (look, options) => {
3203
+ const preset = presetOf(look);
3204
+ activeLook = look;
3205
+ renderer.toneMapping = preset.toneMapping;
3206
+ renderer.toneMappingExposure = preset.toneMappingExposure;
3207
+ config.render.toneMapping = preset.toneMapping;
3208
+ config.render.toneMappingExposure = preset.toneMappingExposure;
3209
+ setFillLights({
3210
+ hemisphereIntensity: preset.hemisphereIntensity,
3211
+ ambientIntensity: preset.ambientIntensity
3212
+ });
3213
+ setEnvironmentIntensity(preset.environmentIntensity);
3214
+ if (lights.sun) {
3215
+ lights.sun.intensity = preset.sunlightIntensity;
3216
+ config.lighting.sunlightIntensity = preset.sunlightIntensity;
3217
+ }
3218
+ applyBackground(preset);
3219
+ applyShadowType(preset);
3220
+ config.render.bloom = preset.bloom;
3221
+ config.render.pixelRatio = lookPixelRatio(fullPixelRatio, preset);
3222
+ pipeline.setAmbientOcclusion(preset.ambientOcclusion);
3223
+ scene.traverse((object) => {
3224
+ if (object.userData.source !== "compute") return;
3225
+ const mesh = object;
3226
+ const materials = Array.isArray(mesh.material) ? mesh.material : mesh.material ? [mesh.material] : [];
3227
+ for (const material of materials) {
3228
+ if ("envMapIntensity" in material) material.envMapIntensity = envMapIntensityForLook(material, preset);
3229
+ applyMaterialOverride(material, preset.materialOverride);
3230
+ }
3231
+ });
3232
+ applyReflectionEnvironment(scene, scene);
3233
+ if (!options?.deferEmissiveLights) syncEmissiveLights();
3234
+ };
3235
+ return {
3236
+ setFillLights,
3237
+ setEnvironmentIntensity,
3238
+ setToneMappingExposure,
3239
+ setAoIntensity,
3240
+ setLook,
3241
+ syncEmissiveLights,
3242
+ jitterEmissiveLights: emissiveLights.jitter,
3243
+ settleEmissiveLights: emissiveLights.settle,
3244
+ getMaterialAppearance: () => materialAppearanceForLook(activeLook)
3245
+ };
3246
+ }
3247
+ // src/render/scene-setup/create-camera.ts
3248
+ function createCamera(config, canvas) {
3249
+ const parent = canvas.parentElement;
3250
+ const width = parent ? parent.clientWidth : window.innerWidth;
3251
+ const height = parent ? parent.clientHeight : window.innerHeight;
3252
+ const camera = new THREE.PerspectiveCamera(config.camera.fov, width / height, config.camera.near, config.camera.far);
3253
+ const pos = config.camera.position;
3254
+ if (pos) camera.position.set(pos.x, pos.y, pos.z);
3255
+ return camera;
3256
+ }
3257
+ // src/render/scene-setup/create-scene.ts
3258
+ function createScene(config) {
3259
+ const scene = new THREE.Scene();
3260
+ scene.background = (typeof config.environment.backgroundColor === "string" ? new THREE.Color(config.environment.backgroundColor) : config.environment.backgroundColor) || null;
3261
+ return scene;
3262
+ }
3263
+ // src/render/scene-setup/dispose.ts
3264
+ /** Sweeps every renderable plus the scene-level textures the object traversal can't reach. */
3265
+ function disposeSceneResources(scene, options) {
3266
+ disposeObjectTree(scene, options);
3267
+ scene.environment?.dispose();
3268
+ disposeReflectionEnvironment(scene);
3269
+ if (scene.background instanceof THREE.Texture) scene.background.dispose();
3270
+ }
3271
+ // src/render/edge-detection-pass.ts
3272
+ const EDGE_SHADER = {
3273
+ uniforms: {
3274
+ tDiffuse: { value: null },
3275
+ tNormal: { value: null },
3276
+ tDepth: { value: null },
3277
+ uResolution: { value: new THREE.Vector2(1, 1) },
3278
+ uColor: { value: new THREE.Color(2236962) },
3279
+ uOpacity: { value: 1 },
3280
+ uNormalThreshold: { value: .4 },
3281
+ uDepthThreshold: { value: .02 },
3282
+ uThickness: { value: 1 },
3283
+ uNear: { value: .1 },
3284
+ uFar: { value: 1e3 },
3285
+ uPerspective: { value: 1 }
3286
+ },
3287
+ vertexShader: `
54
3288
  varying vec2 vUv;
55
3289
  void main() {
56
3290
  vUv = uv;
57
3291
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
58
3292
  }
59
- `,fragmentShader:`
3293
+ `,
3294
+ fragmentShader: `
60
3295
  uniform sampler2D tDiffuse;
61
3296
  uniform sampler2D tNormal;
62
3297
  uniform sampler2D tDepth;
@@ -104,5 +3339,1045 @@ import{a as e,c as t,d as n,f as r,g as i,h as a,l as o,m as s,p as c,r as l,s a
104
3339
  float edge = max(depthEdge, normalEdge) * uOpacity;
105
3340
  gl_FragColor = vec4(mix(color.rgb, uColor, edge), color.a);
106
3341
  }
107
- `};var Pt=class extends N{camera;scene;normalMaterial;edgeMaterial;fsQuad;normalTarget=null;width;height;constructor(e,t,n,r,i={}){super(),this.scene=e,this.camera=t,this.width=Math.max(1,n),this.height=Math.max(1,r),this.normalMaterial=new v.MeshNormalMaterial,this.normalMaterial.blending=v.NoBlending,this.edgeMaterial=new v.ShaderMaterial({uniforms:v.UniformsUtils.clone(Nt.uniforms),vertexShader:Nt.vertexShader,fragmentShader:Nt.fragmentShader});let a=this.edgeMaterial.uniforms;a.uColor.value=new v.Color(i.color??2236962),a.uOpacity.value=i.opacity??1,a.uNormalThreshold.value=i.normalThreshold??.4,a.uDepthThreshold.value=i.depthThreshold??.02,a.uThickness.value=i.thickness??1,this.fsQuad=new ee(this.edgeMaterial),this.needsSwap=!0}acquireNormalTarget(){if(!this.normalTarget){let e=new v.DepthTexture(this.width,this.height);this.normalTarget=new v.WebGLRenderTarget(this.width,this.height,{minFilter:v.NearestFilter,magFilter:v.NearestFilter,depthTexture:e})}return this.normalTarget}setSize(e,t){this.width=Math.max(1,e),this.height=Math.max(1,t),this.normalTarget?.setSize(this.width,this.height)}render(e,t,n){let r=this.acquireNormalTarget(),i=e.getRenderTarget(),a=e.autoClear,o=e.getClearColor(new v.Color),s=e.getClearAlpha(),c=this.scene.overrideMaterial;e.setRenderTarget(r),e.setClearColor(7829503,1),e.autoClear=!0,this.scene.overrideMaterial=this.normalMaterial,e.render(this.scene,this.camera),this.scene.overrideMaterial=c,e.setClearColor(o,s),e.autoClear=a;let l=this.edgeMaterial.uniforms;l.tDiffuse.value=n.texture,l.tNormal.value=r.texture,l.tDepth.value=r.depthTexture,l.uResolution.value.set(this.width,this.height);let u=this.camera;l.uPerspective.value=+!!u.isPerspectiveCamera,l.uNear.value=this.camera.near??.1,l.uFar.value=this.camera.far??1e3,e.setRenderTarget(this.renderToScreen?null:t),this.fsQuad.render(e),e.setRenderTarget(i)}dispose(){this.normalTarget?.dispose(),this.normalMaterial.dispose(),this.edgeMaterial.dispose(),this.fsQuad.dispose()}};function Ft(e){return e>=2?0:4}function It(e,t,n,r,i,a){let o=new v.WebGLRenderTarget(r,i,{type:v.HalfFloatType}),s=new O(e,o);s.renderTarget2.samples=0;let c=e=>{s.readBuffer=o,s.writeBuffer=s.renderTarget2,s.render(e)},l=new k(t,n);s.addPass(l);let u=null;(a.ambientOcclusion??!0)&&(u=new A(t,n,r,i),u.blendIntensity=a.aoIntensity??1,u.updateGtaoMaterial({screenSpaceRadius:!0}),s.addPass(u));let d=new Pt(t,n,r,i,typeof a.edgeDetection==`object`?a.edgeDetection:{});d.enabled=!!a.edgeDetection,s.addPass(d);let f=new j;s.addPass(f);let p=new M;s.addPass(p),e.toneMapping=a.toneMapping,e.toneMappingExposure=a.toneMappingExposure;let m=a.aoPixelRatio??1,h=1;if(u){let e=u.setSize.bind(u);u.setSize=(t,n)=>e(Math.max(1,Math.round(t*h)),Math.max(1,Math.round(n*h)))}let g=e=>{h=Math.min(e,m)/e;let t=Ft(e);o.samples!==t&&(o.samples=t,o.dispose()),s.setPixelRatio(e)};return g(e.getPixelRatio()),s.setSize(r,i),{render:c,setSize:(e,t,n)=>{g(n),s.setSize(e,t)},setCamera:e=>{if(l.camera=e,d.camera=e,!u)return;u.camera=e;let t=+!!e.isPerspectiveCamera;u.gtaoMaterial.defines.PERSPECTIVE_CAMERA!==t&&(u.gtaoMaterial.defines.PERSPECTIVE_CAMERA=t,u.gtaoMaterial.needsUpdate=!0)},setEdgeDetection:e=>{d.enabled=e},edgeDetectionEnabled:()=>d.enabled,dispose:()=>{s.dispose(),u?.dispose(),d.dispose(),f.dispose(),p.dispose()}}}function Lt(e){let{renderer:t,scene:n,getActiveCamera:r,getCanvasSize:i,pixelRatio:a,config:o,requestRender:s}=e,c=null,l=!!o.render.ambientOcclusion,u=!1,d=!1,f=e=>{let{width:s,height:c}=i(),l=It(t,n,r(),Math.max(1,s),Math.max(1,c),{toneMapping:o.render.toneMapping??v.NeutralToneMapping,toneMappingExposure:o.render.toneMappingExposure??1,ambientOcclusion:e,aoIntensity:o.render.aoIntensity,aoPixelRatio:o.render.aoPixelRatio,edgeDetection:!1});return l.setSize(Math.max(1,s),Math.max(1,c),a),l},p=()=>{if(!(l||u)){c?.dispose(),c=null,s();return}(!c||d!==l)&&(c?.dispose(),c=f(l),d=l),c.setEdgeDetection(u),s()};return{get:()=>c,sync:p,rebuild:()=>{c?.dispose(),c=null,p()},setAmbientOcclusion:e=>{l=e,p()},setEdgeFallback:e=>{e!==u&&(u=e,p())},isEdgeFallbackActive:()=>u,dispose:()=>{c?.dispose(),c=null}}}function Rt(e,t,n){let r=new P(e,t),i=n.camera.target;return i&&r.target.set(i.x,i.y,i.z),r.enableDamping=n.controls.enableDamping||!1,r.dampingFactor=n.controls.dampingFactor||.05,r.autoRotate=n.controls.autoRotate||!1,r.autoRotateSpeed=n.controls.autoRotateSpeed||.5,r.enableZoom=n.controls.enableZoom??!0,r.enablePan=n.controls.enablePan??!0,r.minDistance=n.controls.minDistance||.001,r.maxDistance=n.controls.maxDistance||1/0,r.screenSpacePanning=!1,r.maxPolarAngle=Math.PI,r.update(),r}function zt(e,t,n,r){n.environment.enableEnvironmentLighting?(V(e,t,le(n.environment.sceneUp??Q)),new F().load(n.environment.hdrPath||`/baseHDR.hdr`,function(i){if(r()){i.dispose();return}if(!i?.image){c().warn(`HDR loaded without image data; skipping environment map.`),i?.dispose(),n.events.onReady?.();return}i.mapping=v.EquirectangularReflectionMapping;let a=new v.PMREMGenerator(t);a.compileEquirectangularShader();let o=a.fromEquirectangular(i).texture;a.dispose(),e.environment=o,e.environmentIntensity=n.environment.environmentIntensity??1;let s=le(n.environment.sceneUp??Q);e.environmentRotation.copy(s),n.environment.showEnvironment?(e.background=i,e.backgroundRotation.copy(s)):i.dispose(),n.events.onReady?.()},void 0,function(e){r()||(c().warn(`HDR texture could not be loaded, falling back to basic lighting:`,e),n.events.onReady?.())})):n.events.onReady?.()}function Bt(e,t){let n=t.floor.size,r=new v.PlaneGeometry(n,n),i=typeof t.floor.color==`string`?new v.Color(t.floor.color):t.floor.color,a=new v.MeshStandardMaterial({color:i,roughness:t.floor.roughness,metalness:t.floor.metalness,side:v.DoubleSide}),o=new v.Mesh(r,a);o.userData.id=`floor`,o.name=`floor`;let s=(t.environment?.sceneUp||Q).clone().normalize();o.quaternion.setFromUnitVectors(new v.Vector3(0,0,1),s),o.position.set(0,0,0),t.floor.receiveShadow&&t.render.enableShadows&&(o.receiveShadow=!0),e.add(o)}function Vt(e,t,n){if(n&&`emissive`in e){let n=e;n.emissive=t.clone(),n.emissiveIntensity=.6,n.color.lerp(t,.75),n.metalness=Math.min(n.metalness,.2),n.opacity<1&&(n.opacity=1,n.transparent=!1,n.depthWrite=!0);return}`color`in e&&(e.color=t.clone())}function Ht(e,t,n,r){let i=new Set,a=new Map,o=null,s=null,l=null,u=new v.Raycaster,d=new v.Vector2,f=new v.Vector2,p=()=>n.getActiveCamera(),h=e=>{let t=e;for(;t;){if(!t.visible)return!1;t=t.parent}return!0},g=()=>{let e=G(t);if(e.isEmpty()){c().warn(`No objects to fit to view`);return}n.frameBounds(e,!1)},_=typeof r.events.selectionColor==`string`?new v.Color(r.events.selectionColor):r.events.selectionColor instanceof v.Color?r.events.selectionColor:new v.Color(`#ff0000`),y=()=>{o&&(o(),o=null,l?.dispose(),l=null),i.forEach(e=>{let n=e;if(a.has(e)){let r=a.get(e),i=n.material;i instanceof v.Material?i.dispose():Array.isArray(i)&&i.forEach(e=>e.dispose()),n.material=r,a.delete(e);let o=e;for(;o.parent;)o=o.parent;o!==t&&(r instanceof v.Material?r.dispose():r.forEach(e=>e.dispose()))}}),i.clear(),s=null},b=(e,t)=>{if(t&&e instanceof v.Mesh){let n=m(e).clone();return Vt(n,_,!0),l=n,o=Re(e,t,n),!0}let n=e;if(!(n.material instanceof v.Material))return!1;let r=n.material.clone();return Vt(r,_,e instanceof v.Mesh),a.set(e,n.material),n.material=r,!0},x=null,S=-1,C=()=>{if(x===null||t.children.length!==S){let e=G(t);x=e.isEmpty()?1:e.getSize(new v.Vector3).length(),S=t.children.length}u.params.Points.threshold=x*.01},w=(n,r)=>{let i=e.getBoundingClientRect();d.x=(n-i.left)/i.width*2-1,d.y=-((r-i.top)/i.height)*2+1,C(),u.setFromCamera(d,p());for(let e of u.intersectObjects(t.children,!0))if(h(e.object)&&!me(e.object))return e;return null},T=e=>{f.set(e.clientX,e.clientY)},E=e=>{let t=new v.Vector2(e.clientX,e.clientY);if(f.distanceTo(t)>5)return;let n=w(e.clientX,e.clientY);if(!n){y(),r.events?.onBackgroundClicked?.({x:d.x,y:d.y});return}let a=n.object,o=Ie(n),c=o?.index??null;i.has(a)&&c===s||(y(),i.add(a),s=c,b(a,o?.member??null),r.events?.onObjectSelected?.(a),o?r.events?.onMeshMetadataClicked?.({source:a.userData?.source,name:o.member.name,layer:o.member.layer,trackingKey:o.member.trackingKey,metadata:o.member.metadata}):a instanceof v.Mesh&&Object.keys(a.userData).length>0&&r.events?.onMeshMetadataClicked?.(a.userData))},D=e=>{let i=w(e.clientX,e.clientY);if(!i){r.events?.enableDoubleClickZoom&&n.frameBounds(G(t),!0);return}let a=i.object;if(r.events?.onMeshDoubleClicked?.(a),!r.events?.enableDoubleClickZoom)return;let o=Ie(i),s=o&&a instanceof v.Mesh?Le(a,o.member):new v.Box3().setFromObject(a);s.isEmpty()||n.frameBounds(s,!0)},O=e=>{if(r.events?.enableKeyboardControls)switch(e.key.toLowerCase()){case`f`:e.preventDefault(),g();break;case`escape`:e.preventDefault(),y();break;case` `:e.preventDefault(),g()}};return r.events?.enableClickToFocus&&(e.addEventListener(`mousedown`,T),e.addEventListener(`click`,E),e.addEventListener(`dblclick`,D)),r.events?.enableKeyboardControls&&(e.setAttribute(`tabindex`,`0`),e.addEventListener(`keydown`,O)),{dispose:()=>{e.removeEventListener(`mousedown`,T),e.removeEventListener(`click`,E),e.removeEventListener(`dblclick`,D),e.removeEventListener(`keydown`,O),y()},fitToView:g,clearSelection:y}}function Ut(e,t){let n=new v.AmbientLight(t.lighting.ambientLightColor,t.lighting.ambientLightIntensity);e.add(n);let r=null;if(t.lighting.enableHemisphereLight){r=new v.HemisphereLight(t.lighting.hemisphereSkyColor,t.lighting.hemisphereGroundColor,t.lighting.hemisphereIntensity);let n=t.environment.sceneUp??Q;r.position.copy(n),e.add(r)}if(!t.lighting.enableSunlight)return{ambient:n,hemisphere:r,sun:null};let i=new v.DirectionalLight(t.lighting.sunlightColor??16777215,t.lighting.sunlightIntensity),a=t.lighting.sunlightPosition;return a&&i.position.set(a.x,a.y,a.z),t.render.enableShadows?(i.castShadow=!0,i.shadow.mapSize.width=t.render.shadowMapSize||2048,i.shadow.mapSize.height=t.render.shadowMapSize||2048,i.shadow.bias=-1e-4,i.shadow.normalBias=.02,i.shadow.radius=4,e.add(i),e.add(i.target),{ambient:n,hemisphere:r,sun:i}):(e.add(i),{ambient:n,hemisphere:r,sun:null})}function Wt(e,t){if(t.isEmpty())return;let n=t.getCenter(new v.Vector3),r=t.getSize(new v.Vector3).length()*.5*1.2,i=e.shadow.camera;i.left=-r,i.right=r,i.top=r,i.bottom=-r,e.target.position.copy(n),e.target.updateMatrixWorld();let a=e.position.distanceTo(n);i.near=Math.max(r*.01,a-r),i.far=a+r,i.updateProjectionMatrix()}function Gt(e,t,n){let r=new v.WebGLRenderer({antialias:t.render.antialias,canvas:e,alpha:!0,powerPreference:`high-performance`,preserveDrawingBuffer:t.render.preserveDrawingBuffer,logarithmicDepthBuffer:!1}),i=e.parentElement,a=i?i.clientWidth:window.innerWidth,o=i?i.clientHeight:window.innerHeight;return i&&(e.style.width=`100%`,e.style.height=`100%`,e.style.display=`block`),r.setSize(a,o,!1),r.setPixelRatio(n),t.render.enableShadows&&(r.shadowMap.enabled=!0,r.shadowMap.type=v.VSMShadowMap),r.toneMapping=t.render.toneMapping,r.toneMappingExposure=t.render.toneMappingExposure??1,r.outputColorSpace=v.SRGBColorSpace,r.sortObjects=!0,r}const Kt=function(e,t){let n=wt(t||{}),r=n.environment?.sceneUp||Q,i=n.render.pixelRatio??Math.min(window.devicePixelRatio,2),a=jt(n),o=At(n,e);o.up.copy(r);let s=Gt(e,n,i);l(s.capabilities.getMaxAnisotropy()),t?.onMaxAnisotropy?.(s.capabilities.getMaxAnisotropy());let c=Rt(o,e,n),u=ve({scene:a,perspective:o,controls:c,onActiveCameraChange:()=>{},up:r}),f=()=>u.getActiveCamera(),p=!1;zt(a,s,n,()=>p);let m=Ut(a,n),h=m.sun,g=()=>{h&&Wt(h,G(a))};n.floor?.enabled&&Bt(a,n);let _=n.floor?.enabled?a.children.find(e=>e.userData.id===`floor`)??null:null,y=n.grid.enabled?it({cellSize:n.grid.cellSize,majorEvery:n.grid.majorEvery,cellColor:n.grid.cellColor,majorColor:n.grid.majorColor,fadeDistance:n.grid.fadeDistance,plane:n.grid.plane}):null;y&&a.add(y.object);let b=()=>{y&&y.fitToContent(G(a))},x=n.gizmo.enabled?St({camera:o,domElement:e,controller:u}):null,S=n.grid.plane??ue(r),C=new v.Vector3(+(S===`x`),+(S===`y`),+(S===`z`)),w=r.clone().normalize(),T=n.camera.dynamicNear?mt({camera:o,scene:a,groundNormals:()=>{let e=[];return y?.object.visible&&e.push(C),n.floor.enabled&&_?.visible&&e.push(w),e}}):null,E=at(e.parentElement??e,a),D=n.measure.enabled?ft({canvas:e,scene:a,getActiveCamera:f,labelLayer:E,options:{snapPixels:n.measure.snapPixels,color:n.measure.color,labelClassName:n.measure.labelClassName,displayUnit:n.measure.displayUnit,format:n.measure.format}}):null,O=n.events.enableEventHandlers===!1?{dispose:()=>{},fitToView:()=>{},clearSelection:()=>{}}:Ht(e,a,u,n),k=bt();D&&k.register({id:`measure`,tool:D,priority:0}),x&&k.register({id:`gizmo`,tool:x,priority:-100});let A=0,j=0,M=e=>{A=e.clientX,j=e.clientY},ee=e=>Math.hypot(e.clientX-A,e.clientY-j)>5,N=e=>{ee(e)||k.handleClick(e)&&e.stopImmediatePropagation()};e.addEventListener(`mousedown`,M,{capture:!0}),e.addEventListener(`click`,N,{capture:!0});let P=e=>k.handleMove(e);e.addEventListener(`mousemove`,P,{passive:!0});let F=()=>{},te=(e,t)=>{tt(e,{color:n.edges.color,darken:n.edges.darken,width:n.edges.width,thresholdAngle:n.edges.thresholdAngle,distanceFade:n.edges.distanceFade,maxTriangles:n.edges.maxTriangles,maxSegments:n.edges.maxSegments,maxOverlays:n.edges.maxOverlays,...t}).then(()=>{ne(e),F()})},ne=e=>{if(n.edges.screenSpaceFallback===!1)return;let t=!1;e.traverse(e=>{let n=e.userData?.edgesSkipped;(n===`triangle-cap`||n===`overlay-budget`)&&(t=!0)}),B.setEdgeFallback(t)},I=e=>{nt(e),B.setEdgeFallback(!1),F()},R=e.parentElement,z=()=>R?{width:R.clientWidth,height:R.clientHeight}:{width:window.innerWidth,height:window.innerHeight},B=Lt({renderer:s,scene:a,getActiveCamera:f,getCanvasSize:z,pixelRatio:i,config:n,requestRender:()=>F()});B.sync();let V=kt({scene:a,renderer:s,lights:m,config:n,pipeline:B,requestRender:()=>F()}),{animate:ae,dispose:oe,invalidate:H,renderNow:U}=Ct(s,a,o,f,u,c,z,i,n.events.onFrame,y,x,()=>B.get(),E,T,n.render.onDemand??!0);return F=H,ae(),a.up.set(r.x,r.y,r.z),g(),b(),{scene:a,camera:o,controls:c,renderer:s,cameraController:u,grid:y,gizmo:x,measureTool:D,labelLayer:E,tools:k,applyEdges:te,clearEdges:I,invalidate:H,captureImage:(e=`image/png`,t)=>(U(),new Promise(n=>s.domElement.toBlob(n,e,t))),setAmbientOcclusion:B.setAmbientOcclusion,setLook:V.setLook,setFillLights:V.setFillLights,setEnvironmentIntensity:V.setEnvironmentIntensity,setToneMappingExposure:V.setToneMappingExposure,setAoIntensity:V.setAoIntensity,getMaterialAppearance:V.getMaterialAppearance,updateShadowBounds:g,updateGridScale:b,dispose:()=>{p||(p=!0,oe(),O.dispose(),e.removeEventListener(`mousedown`,M,{capture:!0}),e.removeEventListener(`click`,N,{capture:!0}),e.removeEventListener(`mousemove`,P),D?.dispose(),E?.dispose(),x?.dispose(),y?.dispose(),B.dispose(),u.dispose(),c.dispose(),s.dispose(),s.forceContextLoss(),Mt(a))},fitToView:O.fitToView,clearSelection:O.clearSelection,addUserGeometry:(e,t)=>{e.userData.source=t===void 0?re:L(t),a.add(e),F()},removeUserGeometry:e=>{e.removeFromParent(),d(e),F()},clearUserGeometry:e=>{a.children.filter(t=>ie(t,e)).forEach(e=>{e.removeFromParent(),d(e)}),F()}}};export{u as DEFAULT_LOOK,a as ErrorCodes,te as LOOKS,t as LOOK_PRESETS,ne as SOURCE_COMPUTE,re as SOURCE_USER,i as VisualizationError,R as appIdFromSource,L as appSource,r as enableDebugLogging,c as getLogger,Kt as initThree,z as isHostOwned,ie as isOwnedBy,n as materialAppearanceForLook,lt as pickThreshold,xt as pointerToNdc,s as setLogger,dt as snapToVertex,de as updateScene};
3342
+ `
3343
+ };
3344
+ var EdgeDetectionPass = class extends Pass {
3345
+ camera;
3346
+ scene;
3347
+ normalMaterial;
3348
+ edgeMaterial;
3349
+ fsQuad;
3350
+ normalTarget = null;
3351
+ width;
3352
+ height;
3353
+ constructor(scene, camera, width, height, options = {}) {
3354
+ super();
3355
+ this.scene = scene;
3356
+ this.camera = camera;
3357
+ this.width = Math.max(1, width);
3358
+ this.height = Math.max(1, height);
3359
+ this.normalMaterial = new THREE.MeshNormalMaterial();
3360
+ this.normalMaterial.blending = THREE.NoBlending;
3361
+ this.edgeMaterial = new THREE.ShaderMaterial({
3362
+ uniforms: THREE.UniformsUtils.clone(EDGE_SHADER.uniforms),
3363
+ vertexShader: EDGE_SHADER.vertexShader,
3364
+ fragmentShader: EDGE_SHADER.fragmentShader
3365
+ });
3366
+ const uniforms = this.edgeMaterial.uniforms;
3367
+ uniforms.uColor.value = new THREE.Color(options.color ?? 2236962);
3368
+ uniforms.uOpacity.value = options.opacity ?? 1;
3369
+ uniforms.uNormalThreshold.value = options.normalThreshold ?? .4;
3370
+ uniforms.uDepthThreshold.value = options.depthThreshold ?? .02;
3371
+ uniforms.uThickness.value = options.thickness ?? 1;
3372
+ this.fsQuad = new FullScreenQuad(this.edgeMaterial);
3373
+ this.needsSwap = true;
3374
+ }
3375
+ acquireNormalTarget() {
3376
+ if (!this.normalTarget) {
3377
+ const depthTexture = new THREE.DepthTexture(this.width, this.height);
3378
+ this.normalTarget = new THREE.WebGLRenderTarget(this.width, this.height, {
3379
+ minFilter: THREE.NearestFilter,
3380
+ magFilter: THREE.NearestFilter,
3381
+ depthTexture
3382
+ });
3383
+ }
3384
+ return this.normalTarget;
3385
+ }
3386
+ setSize(width, height) {
3387
+ this.width = Math.max(1, width);
3388
+ this.height = Math.max(1, height);
3389
+ this.normalTarget?.setSize(this.width, this.height);
3390
+ }
3391
+ render(renderer, writeBuffer, readBuffer) {
3392
+ const normalTarget = this.acquireNormalTarget();
3393
+ const previousTarget = renderer.getRenderTarget();
3394
+ const previousAutoClear = renderer.autoClear;
3395
+ const previousClearColor = renderer.getClearColor(new THREE.Color());
3396
+ const previousClearAlpha = renderer.getClearAlpha();
3397
+ const previousOverride = this.scene.overrideMaterial;
3398
+ renderer.setRenderTarget(normalTarget);
3399
+ renderer.setClearColor(7829503, 1);
3400
+ renderer.autoClear = true;
3401
+ this.scene.overrideMaterial = this.normalMaterial;
3402
+ renderer.render(this.scene, this.camera);
3403
+ this.scene.overrideMaterial = previousOverride;
3404
+ renderer.setClearColor(previousClearColor, previousClearAlpha);
3405
+ renderer.autoClear = previousAutoClear;
3406
+ const uniforms = this.edgeMaterial.uniforms;
3407
+ uniforms.tDiffuse.value = readBuffer.texture;
3408
+ uniforms.tNormal.value = normalTarget.texture;
3409
+ uniforms.tDepth.value = normalTarget.depthTexture;
3410
+ uniforms.uResolution.value.set(this.width, this.height);
3411
+ const perspective = this.camera;
3412
+ uniforms.uPerspective.value = perspective.isPerspectiveCamera ? 1 : 0;
3413
+ uniforms.uNear.value = this.camera.near ?? .1;
3414
+ uniforms.uFar.value = this.camera.far ?? 1e3;
3415
+ renderer.setRenderTarget(this.renderToScreen ? null : writeBuffer);
3416
+ this.fsQuad.render(renderer);
3417
+ renderer.setRenderTarget(previousTarget);
3418
+ }
3419
+ dispose() {
3420
+ this.normalTarget?.dispose();
3421
+ this.normalMaterial.dispose();
3422
+ this.edgeMaterial.dispose();
3423
+ this.fsQuad.dispose();
3424
+ }
3425
+ };
3426
+ // src/render/render-pipeline.ts
3427
+ /**
3428
+ * A highlight beside a point light overflows the half-float target to infinity, and bloom's blur
3429
+ * turns that one pixel into NaN across the whole frame: the viewer goes blank. Clamping first keeps
3430
+ * a hot pixel hot without letting it reach the blur.
3431
+ */
3432
+ const HDR_CLAMP_SHADER = {
3433
+ name: "HdrClamp",
3434
+ uniforms: { tDiffuse: { value: null } },
3435
+ vertexShader: `
3436
+ varying vec2 vUv;
3437
+ void main() {
3438
+ vUv = uv;
3439
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
3440
+ }`,
3441
+ fragmentShader: `
3442
+ uniform sampler2D tDiffuse;
3443
+ varying vec2 vUv;
3444
+ const float MAX_RADIANCE = 64.0;
3445
+ float safe(float v) {
3446
+ return isnan(v) ? 0.0 : clamp(v, 0.0, MAX_RADIANCE);
3447
+ }
3448
+ void main() {
3449
+ vec4 color = texture2D(tDiffuse, vUv);
3450
+ gl_FragColor = vec4(safe(color.r), safe(color.g), safe(color.b), color.a);
3451
+ }`
3452
+ };
3453
+ const AVERAGE_SHADER = {
3454
+ vertexShader: `
3455
+ varying vec2 vUv;
3456
+ void main() {
3457
+ vUv = uv;
3458
+ gl_Position = vec4(position.xy, 0.0, 1.0);
3459
+ }`,
3460
+ fragmentShader: `
3461
+ uniform sampler2D tFrame;
3462
+ varying vec2 vUv;
3463
+ void main() {
3464
+ gl_FragColor = texture2D(tFrame, vUv);
3465
+ }`
3466
+ };
3467
+ /**
3468
+ * Keeps the running average of the composer's finished frames. They're already tone mapped, which
3469
+ * suits what gets averaged here: shadows and edges, not HDR highlights. Half float, because 32
3470
+ * frames blended into 8 bits would band in a soft shadow.
3471
+ */
3472
+ function createAccumulator(renderer) {
3473
+ const target = new THREE.WebGLRenderTarget(1, 1, {
3474
+ type: THREE.HalfFloatType,
3475
+ depthBuffer: false
3476
+ });
3477
+ const material = new THREE.ShaderMaterial({
3478
+ ...AVERAGE_SHADER,
3479
+ uniforms: { tFrame: { value: null } },
3480
+ depthTest: false,
3481
+ depthWrite: false,
3482
+ toneMapped: false,
3483
+ blending: THREE.CustomBlending,
3484
+ blendSrc: THREE.ConstantAlphaFactor,
3485
+ blendDst: THREE.OneMinusConstantAlphaFactor
3486
+ });
3487
+ const quad = new FullScreenQuad(material);
3488
+ const draw = (texture, weight) => {
3489
+ material.uniforms.tFrame.value = texture;
3490
+ material.blendAlpha = weight;
3491
+ const autoClear = renderer.autoClear;
3492
+ renderer.autoClear = false;
3493
+ quad.render(renderer);
3494
+ renderer.autoClear = autoClear;
3495
+ };
3496
+ const present = () => {
3497
+ renderer.setRenderTarget(null);
3498
+ draw(target.texture, 1);
3499
+ };
3500
+ return {
3501
+ add: (frame, index, width, height) => {
3502
+ if (target.width !== width || target.height !== height) target.setSize(width, height);
3503
+ renderer.setRenderTarget(target);
3504
+ draw(frame, 1 / (index + 1));
3505
+ present();
3506
+ },
3507
+ present,
3508
+ dispose: () => {
3509
+ target.dispose();
3510
+ material.dispose();
3511
+ quad.dispose();
3512
+ }
3513
+ };
3514
+ }
3515
+ /**
3516
+ * Without MSAA a sub-pixel feature (a sheet's bright hem edge) comes out as a dashed line, which
3517
+ * SMAA can't rebuild. At 2× DPR each CSS pixel already holds four samples, and 4× MSAA on top
3518
+ * would cost ~250 MB on a 4K target for no visible gain.
3519
+ */
3520
+ function msaaSamplesFor(pixelRatio) {
3521
+ return pixelRatio >= 2 ? 0 : 4;
3522
+ }
3523
+ function createRenderPipeline(renderer, scene, camera, width, height, options) {
3524
+ const sceneTarget = new THREE.WebGLRenderTarget(width, height, { type: THREE.HalfFloatType });
3525
+ const composer = new EffectComposer(renderer, sceneTarget);
3526
+ composer.renderTarget2.samples = 0;
3527
+ const renderFrame = (deltaTime) => {
3528
+ composer.readBuffer = sceneTarget;
3529
+ composer.writeBuffer = composer.renderTarget2;
3530
+ composer.render(deltaTime);
3531
+ };
3532
+ const renderPass = new RenderPass(scene, camera);
3533
+ composer.addPass(renderPass);
3534
+ let gtaoPass = null;
3535
+ if (options.ambientOcclusion ?? true) {
3536
+ gtaoPass = new GTAOPass(scene, camera, width, height);
3537
+ gtaoPass.blendIntensity = options.aoIntensity ?? 1;
3538
+ gtaoPass.updateGtaoMaterial({ screenSpaceRadius: true });
3539
+ composer.addPass(gtaoPass);
3540
+ }
3541
+ let clampPass = null;
3542
+ let bloomPass = null;
3543
+ if (options.bloom) {
3544
+ const { strength, radius, threshold } = options.bloom;
3545
+ clampPass = new ShaderPass(HDR_CLAMP_SHADER);
3546
+ composer.addPass(clampPass);
3547
+ bloomPass = new UnrealBloomPass(new THREE.Vector2(width, height), strength, radius, threshold);
3548
+ composer.addPass(bloomPass);
3549
+ }
3550
+ const edgePass = new EdgeDetectionPass(scene, camera, width, height, typeof options.edgeDetection === "object" ? options.edgeDetection : {});
3551
+ edgePass.enabled = !!options.edgeDetection;
3552
+ composer.addPass(edgePass);
3553
+ const smaaPass = new SMAAPass();
3554
+ composer.addPass(smaaPass);
3555
+ const outputPass = new OutputPass();
3556
+ composer.addPass(outputPass);
3557
+ renderer.toneMapping = options.toneMapping;
3558
+ renderer.toneMappingExposure = options.toneMappingExposure;
3559
+ let accumulator = null;
3560
+ const aoPixelRatioCap = options.aoPixelRatio ?? 1;
3561
+ let aoScale = 1;
3562
+ if (gtaoPass) {
3563
+ const setAoSize = gtaoPass.setSize.bind(gtaoPass);
3564
+ gtaoPass.setSize = (w, h) => setAoSize(Math.max(1, Math.round(w * aoScale)), Math.max(1, Math.round(h * aoScale)));
3565
+ }
3566
+ const applyPixelRatio = (pixelRatio) => {
3567
+ aoScale = Math.min(pixelRatio, aoPixelRatioCap) / pixelRatio;
3568
+ const samples = msaaSamplesFor(pixelRatio);
3569
+ if (sceneTarget.samples !== samples) {
3570
+ sceneTarget.samples = samples;
3571
+ sceneTarget.dispose();
3572
+ }
3573
+ composer.setPixelRatio(pixelRatio);
3574
+ };
3575
+ applyPixelRatio(renderer.getPixelRatio());
3576
+ composer.setSize(width, height);
3577
+ return {
3578
+ render: renderFrame,
3579
+ setSize: (w, h, pixelRatio) => {
3580
+ applyPixelRatio(pixelRatio);
3581
+ composer.setSize(w, h);
3582
+ },
3583
+ setCamera: (cam) => {
3584
+ renderPass.camera = cam;
3585
+ edgePass.camera = cam;
3586
+ if (!gtaoPass) return;
3587
+ gtaoPass.camera = cam;
3588
+ const isPerspective = cam.isPerspectiveCamera ? 1 : 0;
3589
+ if (gtaoPass.gtaoMaterial.defines.PERSPECTIVE_CAMERA !== isPerspective) {
3590
+ gtaoPass.gtaoMaterial.defines.PERSPECTIVE_CAMERA = isPerspective;
3591
+ gtaoPass.gtaoMaterial.needsUpdate = true;
3592
+ }
3593
+ },
3594
+ setEdgeDetection: (enabled) => {
3595
+ edgePass.enabled = enabled;
3596
+ },
3597
+ edgeDetectionEnabled: () => edgePass.enabled,
3598
+ accumulate: (index) => {
3599
+ composer.renderToScreen = false;
3600
+ renderFrame(0);
3601
+ composer.renderToScreen = true;
3602
+ accumulator ??= createAccumulator(renderer);
3603
+ const { texture, width, height } = composer.readBuffer;
3604
+ accumulator.add(texture, index, width, height);
3605
+ },
3606
+ presentAccumulated: () => accumulator?.present(),
3607
+ setAoWeight: (weight) => {
3608
+ if (!gtaoPass) return;
3609
+ gtaoPass.enabled = weight > 0;
3610
+ gtaoPass.blendIntensity = (options.aoIntensity ?? 1) * weight;
3611
+ },
3612
+ dispose: () => {
3613
+ composer.dispose();
3614
+ accumulator?.dispose();
3615
+ gtaoPass?.dispose();
3616
+ clampPass?.dispose();
3617
+ bloomPass?.dispose();
3618
+ edgePass.dispose();
3619
+ smaaPass.dispose();
3620
+ outputPass.dispose();
3621
+ }
3622
+ };
3623
+ }
3624
+ // src/render/scene-setup/pipeline-controller.ts
3625
+ function createPipelineController(params) {
3626
+ const { renderer, scene, getActiveCamera, getCanvasSize, config, requestRender } = params;
3627
+ let pipeline = null;
3628
+ let aoEnabled = !!config.render.ambientOcclusion;
3629
+ let edgeFallbackActive = false;
3630
+ const bakedOptions = () => ({
3631
+ ambientOcclusion: aoEnabled,
3632
+ bloom: config.render.bloom,
3633
+ toneMapping: config.render.toneMapping,
3634
+ toneMappingExposure: config.render.toneMappingExposure
3635
+ });
3636
+ let baked = bakedOptions();
3637
+ const isStale = () => {
3638
+ const wanted = bakedOptions();
3639
+ return wanted.ambientOcclusion !== baked.ambientOcclusion || wanted.bloom !== baked.bloom || wanted.toneMapping !== baked.toneMapping || wanted.toneMappingExposure !== baked.toneMappingExposure;
3640
+ };
3641
+ const build = () => {
3642
+ const { width, height } = getCanvasSize();
3643
+ const built = createRenderPipeline(renderer, scene, getActiveCamera(), Math.max(1, width), Math.max(1, height), {
3644
+ toneMapping: config.render.toneMapping ?? THREE.NeutralToneMapping,
3645
+ toneMappingExposure: config.render.toneMappingExposure ?? 1,
3646
+ ambientOcclusion: aoEnabled,
3647
+ aoIntensity: config.render.aoIntensity,
3648
+ aoPixelRatio: config.render.aoPixelRatio,
3649
+ bloom: config.render.bloom,
3650
+ edgeDetection: false
3651
+ });
3652
+ built.setSize(Math.max(1, width), Math.max(1, height), config.render.pixelRatio);
3653
+ return built;
3654
+ };
3655
+ const sync = () => {
3656
+ if (!(aoEnabled || edgeFallbackActive || !!config.render.bloom)) {
3657
+ pipeline?.dispose();
3658
+ pipeline = null;
3659
+ requestRender();
3660
+ return;
3661
+ }
3662
+ if (!pipeline || isStale()) {
3663
+ pipeline?.dispose();
3664
+ pipeline = build();
3665
+ baked = bakedOptions();
3666
+ }
3667
+ pipeline.setEdgeDetection(edgeFallbackActive);
3668
+ requestRender();
3669
+ };
3670
+ return {
3671
+ get: () => pipeline,
3672
+ sync,
3673
+ rebuild: () => {
3674
+ pipeline?.dispose();
3675
+ pipeline = null;
3676
+ sync();
3677
+ },
3678
+ setAmbientOcclusion: (enabled) => {
3679
+ aoEnabled = enabled;
3680
+ sync();
3681
+ },
3682
+ setEdgeFallback: (active) => {
3683
+ if (active === edgeFallbackActive) return;
3684
+ edgeFallbackActive = active;
3685
+ sync();
3686
+ },
3687
+ isEdgeFallbackActive: () => edgeFallbackActive,
3688
+ dispose: () => {
3689
+ pipeline?.dispose();
3690
+ pipeline = null;
3691
+ }
3692
+ };
3693
+ }
3694
+ // src/render/scene-setup/setup-controls.ts
3695
+ function setupControls(camera, canvas, config) {
3696
+ const controls = new OrbitControls(camera, canvas);
3697
+ const target = config.camera.target;
3698
+ if (target) controls.target.set(target.x, target.y, target.z);
3699
+ controls.enableDamping = config.controls.enableDamping || false;
3700
+ controls.dampingFactor = config.controls.dampingFactor || .05;
3701
+ controls.autoRotate = config.controls.autoRotate || false;
3702
+ controls.autoRotateSpeed = config.controls.autoRotateSpeed || .5;
3703
+ controls.enableZoom = config.controls.enableZoom ?? true;
3704
+ controls.enablePan = config.controls.enablePan ?? true;
3705
+ controls.minDistance = config.controls.minDistance || .001;
3706
+ controls.maxDistance = config.controls.maxDistance || Infinity;
3707
+ controls.screenSpacePanning = false;
3708
+ controls.maxPolarAngle = Math.PI;
3709
+ controls.update();
3710
+ return controls;
3711
+ }
3712
+ // src/render/scene-setup/setup-environment.ts
3713
+ function setupEnvironment(scene, renderer, config, isDisposed) {
3714
+ if (config.environment.enableEnvironmentLighting) {
3715
+ createReflectionEnvironment(scene, renderer, environmentRotationFor(config.environment.sceneUp ?? defaultUp));
3716
+ new HDRLoader().load(config.environment.hdrPath || "/baseHDR.hdr", function(envMap) {
3717
+ if (isDisposed()) {
3718
+ envMap.dispose();
3719
+ return;
3720
+ }
3721
+ if (!envMap?.image) {
3722
+ getLogger().warn("HDR loaded without image data; skipping environment map.");
3723
+ envMap?.dispose();
3724
+ config.events.onReady?.();
3725
+ return;
3726
+ }
3727
+ envMap.mapping = THREE.EquirectangularReflectionMapping;
3728
+ const pmrem = new THREE.PMREMGenerator(renderer);
3729
+ pmrem.compileEquirectangularShader();
3730
+ const prefiltered = pmrem.fromEquirectangular(envMap).texture;
3731
+ pmrem.dispose();
3732
+ scene.environment = prefiltered;
3733
+ scene.environmentIntensity = config.environment.environmentIntensity ?? 1;
3734
+ const envRotation = environmentRotationFor(config.environment.sceneUp ?? defaultUp);
3735
+ scene.environmentRotation.copy(envRotation);
3736
+ if (config.environment.showEnvironment) {
3737
+ scene.background = envMap;
3738
+ scene.backgroundRotation.copy(envRotation);
3739
+ } else envMap.dispose();
3740
+ config.events.onReady?.();
3741
+ }, void 0, function(error) {
3742
+ if (isDisposed()) return;
3743
+ getLogger().warn("HDR texture could not be loaded, falling back to basic lighting:", error);
3744
+ config.events.onReady?.();
3745
+ });
3746
+ } else config.events.onReady?.();
3747
+ }
3748
+ function addFloor(scene, config) {
3749
+ const floorSize = config.floor.size;
3750
+ const floorGeometry = new THREE.PlaneGeometry(floorSize, floorSize);
3751
+ const floorColor = typeof config.floor.color === "string" ? new THREE.Color(config.floor.color) : config.floor.color;
3752
+ const floorMaterial = new THREE.MeshStandardMaterial({
3753
+ color: floorColor,
3754
+ roughness: config.floor.roughness,
3755
+ metalness: config.floor.metalness,
3756
+ side: THREE.DoubleSide
3757
+ });
3758
+ const floor = new THREE.Mesh(floorGeometry, floorMaterial);
3759
+ floor.userData.id = "floor";
3760
+ floor.name = "floor";
3761
+ const up = (config.environment?.sceneUp || defaultUp).clone().normalize();
3762
+ floor.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), up);
3763
+ floor.position.set(0, 0, 0);
3764
+ if (config.floor.receiveShadow && config.render.enableShadows) floor.receiveShadow = true;
3765
+ scene.add(floor);
3766
+ }
3767
+ // src/render/scene-setup/setup-events.ts
3768
+ /** How far the base color moves toward the selection color. 1 would discard the texture underneath. */
3769
+ const SELECTION_COLOR_MIX = .75;
3770
+ /** Above ~2 the tint clips to pure red and the geometry's shading disappears with it. */
3771
+ const SELECTION_EMISSIVE_INTENSITY = .6;
3772
+ /** A metallic surface reflects the environment rather than showing its albedo tint. */
3773
+ const SELECTION_MAX_METALNESS = .2;
3774
+ /** Below this a highlight reads as a ghost, so the selection is forced opaque. */
3775
+ const SELECTION_MIN_OPACITY = 1;
3776
+ /**
3777
+ * Lighter than the default selection red on purpose: amber and red are close hues under red-green
3778
+ * color blindness, so what tells hover from selected there is brightness.
3779
+ */
3780
+ const HOVER_COLOR = "#ffb020";
3781
+ /** Linear RGB distance. Closer than this, a tint barely changes the surface. */
3782
+ const MIN_TINT_DISTANCE = .35;
3783
+ /** Taken by a surface already close to the tint. Blue stays distinct under every common deficiency. */
3784
+ const FALLBACK_TINT = new THREE.Color("#2f7bff");
3785
+ function readableTint(material, tint) {
3786
+ const base = material.color;
3787
+ if (!base) return tint;
3788
+ return Math.hypot(base.r - tint.r, base.g - tint.g, base.b - tint.b) < MIN_TINT_DISTANCE ? FALLBACK_TINT : tint;
3789
+ }
3790
+ /**
3791
+ * Recolors a material clone to mark it selected. Exported for tests: the highlight reads against
3792
+ * whatever the active look does to lighting, and that coupling has broken it silently before.
3793
+ *
3794
+ * Meshes tint via `emissive` AND base color. Emissive alone is additive on top of what the surface
3795
+ * already reflects, so on a bright white model under a key light it only shifts the surface slightly
3796
+ * pink — the brighter the look, the weaker the highlight. Moving the albedo toward the selection
3797
+ * color is what makes it hold at any exposure; the emissive on top keeps it reading as lit rather
3798
+ * than as a flat sticker in shadowed areas. Lines/points have no emissive channel, so their `color`
3799
+ * is replaced outright.
3800
+ *
3801
+ * Mutates `material` in place — pass a clone, never a material the scene still shares.
3802
+ */
3803
+ function tintForSelection(material, color, isMesh) {
3804
+ const selectionColor = readableTint(material, color);
3805
+ if (isMesh && "emissive" in material) {
3806
+ const mesh = material;
3807
+ mesh.emissive = selectionColor.clone();
3808
+ mesh.emissiveIntensity = SELECTION_EMISSIVE_INTENSITY;
3809
+ mesh.color.lerp(selectionColor, SELECTION_COLOR_MIX);
3810
+ mesh.metalness = Math.min(mesh.metalness, SELECTION_MAX_METALNESS);
3811
+ if (mesh.opacity < SELECTION_MIN_OPACITY) {
3812
+ mesh.opacity = SELECTION_MIN_OPACITY;
3813
+ mesh.transparent = false;
3814
+ mesh.depthWrite = true;
3815
+ }
3816
+ return;
3817
+ }
3818
+ if ("color" in material) material.color = selectionColor.clone();
3819
+ }
3820
+ function setupEventHandlers(canvas, scene, cameraController, config) {
3821
+ const selectedObjects = /* @__PURE__ */ new Set();
3822
+ const originalMaterials = /* @__PURE__ */ new Map();
3823
+ const memberHighlights = [];
3824
+ /** Which member of a merged mesh is selected, so a click on a sibling isn't a no-op. */
3825
+ let selectedMemberIndex = null;
3826
+ let selectedByEntries = false;
3827
+ const raycaster = new THREE.Raycaster();
3828
+ const mouse = new THREE.Vector2();
3829
+ const mouseDownPosition = new THREE.Vector2();
3830
+ const getActiveCamera = () => cameraController.getActiveCamera();
3831
+ const isFullyVisible = (object) => {
3832
+ let current = object;
3833
+ while (current) {
3834
+ if (!current.visible) return false;
3835
+ current = current.parent;
3836
+ }
3837
+ return true;
3838
+ };
3839
+ const fitToView = () => {
3840
+ const box = computeContentBounds(scene);
3841
+ if (box.isEmpty()) {
3842
+ getLogger().warn("No objects to fit to view");
3843
+ return;
3844
+ }
3845
+ cameraController.frameBounds(box, false);
3846
+ };
3847
+ const selectionColorObj = typeof config.events.selectionColor === "string" ? new THREE.Color(config.events.selectionColor) : config.events.selectionColor instanceof THREE.Color ? config.events.selectionColor : new THREE.Color("#ff0000");
3848
+ const hoverColorObj = new THREE.Color(HOVER_COLOR);
3849
+ let hoverObject = null;
3850
+ let hoverOriginal = null;
3851
+ let hoverMember = null;
3852
+ const disposeIfDetached = (object, original) => {
3853
+ let root = object;
3854
+ while (root.parent) root = root.parent;
3855
+ if (root === scene) return;
3856
+ if (original instanceof THREE.Material) original.dispose();
3857
+ else original.forEach((m) => m.dispose());
3858
+ };
3859
+ const clearHover = () => {
3860
+ hoverMember?.restore();
3861
+ hoverMember?.material.dispose();
3862
+ hoverMember = null;
3863
+ if (hoverObject && hoverOriginal) {
3864
+ const clone = hoverObject.material;
3865
+ if (clone instanceof THREE.Material) clone.dispose();
3866
+ hoverObject.material = hoverOriginal;
3867
+ disposeIfDetached(hoverObject, hoverOriginal);
3868
+ }
3869
+ hoverObject = null;
3870
+ hoverOriginal = null;
3871
+ };
3872
+ /**
3873
+ * Previews an outliner row in the viewport. Fires no events, and yields to a selection already
3874
+ * tinting the same thing.
3875
+ */
3876
+ const setHover = (object, memberIndex = null) => {
3877
+ clearHover();
3878
+ if (!object) return;
3879
+ if (memberIndex !== null && object instanceof THREE.Mesh) {
3880
+ const material = getBaseMaterial(object).clone();
3881
+ tintForSelection(material, hoverColorObj, true);
3882
+ hoverMember = {
3883
+ restore: highlightMembers(object, /* @__PURE__ */ new Set([memberIndex]), material, "hover"),
3884
+ material
3885
+ };
3886
+ return;
3887
+ }
3888
+ if (selectedObjects.has(object)) return;
3889
+ const target = object;
3890
+ if (!(target.material instanceof THREE.Material)) return;
3891
+ const clone = cloneForTint(target.material);
3892
+ tintForSelection(clone, hoverColorObj, object instanceof THREE.Mesh);
3893
+ hoverObject = target;
3894
+ hoverOriginal = target.material;
3895
+ target.material = clone;
3896
+ };
3897
+ const clearSelection = () => {
3898
+ for (const { restore, material } of memberHighlights) {
3899
+ restore();
3900
+ material.dispose();
3901
+ }
3902
+ memberHighlights.length = 0;
3903
+ selectedObjects.forEach((obj) => {
3904
+ const restorable = obj;
3905
+ if (originalMaterials.has(obj)) {
3906
+ const original = originalMaterials.get(obj);
3907
+ const clone = restorable.material;
3908
+ if (clone instanceof THREE.Material) clone.dispose();
3909
+ else if (Array.isArray(clone)) clone.forEach((m) => m.dispose());
3910
+ restorable.material = original;
3911
+ originalMaterials.delete(obj);
3912
+ disposeIfDetached(obj, original);
3913
+ }
3914
+ });
3915
+ selectedObjects.clear();
3916
+ selectedMemberIndex = null;
3917
+ selectedByEntries = false;
3918
+ };
3919
+ /**
3920
+ * Tints the picked object. On a merged mesh only the given members are tinted, so clicking one
3921
+ * wall doesn't light up every wall sharing its material — see `merged-picking.ts`.
3922
+ */
3923
+ const applyHighlight = (object, members) => {
3924
+ if (members && object instanceof THREE.Mesh) {
3925
+ const clone = getBaseMaterial(object).clone();
3926
+ tintForSelection(clone, selectionColorObj, true);
3927
+ memberHighlights.push({
3928
+ restore: highlightMembers(object, members, clone),
3929
+ material: clone
3930
+ });
3931
+ return true;
3932
+ }
3933
+ const target = object;
3934
+ if (!(target.material instanceof THREE.Material)) return false;
3935
+ const clone = cloneForTint(target.material);
3936
+ tintForSelection(clone, selectionColorObj, object instanceof THREE.Mesh);
3937
+ originalMaterials.set(object, target.material);
3938
+ target.material = clone;
3939
+ return true;
3940
+ };
3941
+ /**
3942
+ * Selection made outside the viewport. An empty list only drops such a selection, so a host
3943
+ * syncing its own empty state can't wipe what a click on the canvas just selected.
3944
+ */
3945
+ const selectEntries = (targets) => {
3946
+ if (targets.length === 0 && !selectedByEntries) return;
3947
+ clearHover();
3948
+ clearSelection();
3949
+ const membersByMesh = /* @__PURE__ */ new Map();
3950
+ for (const { object, memberIndex } of targets) {
3951
+ if (memberIndex === null) {
3952
+ selectedObjects.add(object);
3953
+ applyHighlight(object, null);
3954
+ continue;
3955
+ }
3956
+ let members = membersByMesh.get(object);
3957
+ if (!members) membersByMesh.set(object, members = /* @__PURE__ */ new Set());
3958
+ members.add(memberIndex);
3959
+ }
3960
+ for (const [object, members] of membersByMesh) {
3961
+ selectedObjects.add(object);
3962
+ applyHighlight(object, members);
3963
+ }
3964
+ selectedByEntries = targets.length > 0;
3965
+ };
3966
+ let cachedDiagonal = null;
3967
+ let cachedChildCount = -1;
3968
+ const updatePickThresholds = () => {
3969
+ if (cachedDiagonal === null || scene.children.length !== cachedChildCount) {
3970
+ const box = computeContentBounds(scene);
3971
+ cachedDiagonal = box.isEmpty() ? 1 : box.getSize(new THREE.Vector3()).length();
3972
+ cachedChildCount = scene.children.length;
3973
+ }
3974
+ raycaster.params.Points.threshold = cachedDiagonal * .01;
3975
+ };
3976
+ /**
3977
+ * Nearest visible, pickable object under the pointer, or null. Viewer aids are excluded: the grid
3978
+ * is a huge camera-following plane, so double-clicking through a thin curve onto it would frame a
3979
+ * box the size of the fade radius and fling the camera out.
3980
+ */
3981
+ const pickAt = (clientX, clientY) => {
3982
+ const rect = canvas.getBoundingClientRect();
3983
+ mouse.x = (clientX - rect.left) / rect.width * 2 - 1;
3984
+ mouse.y = -((clientY - rect.top) / rect.height) * 2 + 1;
3985
+ updatePickThresholds();
3986
+ raycaster.setFromCamera(mouse, getActiveCamera());
3987
+ for (const hit of raycaster.intersectObjects(scene.children, true)) if (isFullyVisible(hit.object) && !isViewerAid(hit.object)) return hit;
3988
+ return null;
3989
+ };
3990
+ const handleMouseDown = (event) => {
3991
+ mouseDownPosition.set(event.clientX, event.clientY);
3992
+ };
3993
+ const handleCanvasClick = (event) => {
3994
+ const currentMousePosition = new THREE.Vector2(event.clientX, event.clientY);
3995
+ if (mouseDownPosition.distanceTo(currentMousePosition) > 5) return;
3996
+ const hit = pickAt(event.clientX, event.clientY);
3997
+ if (!hit) {
3998
+ clearSelection();
3999
+ config.events?.onBackgroundClicked?.({
4000
+ x: mouse.x,
4001
+ y: mouse.y
4002
+ });
4003
+ return;
4004
+ }
4005
+ const clickedObject = hit.object;
4006
+ const resolved = resolveHitMember(hit);
4007
+ const hitMemberIndex = resolved?.index ?? null;
4008
+ if (!selectedByEntries && selectedObjects.has(clickedObject) && hitMemberIndex === selectedMemberIndex) return;
4009
+ clearHover();
4010
+ clearSelection();
4011
+ selectedObjects.add(clickedObject);
4012
+ selectedMemberIndex = hitMemberIndex;
4013
+ applyHighlight(clickedObject, resolved ? /* @__PURE__ */ new Set([resolved.index]) : null);
4014
+ config.events?.onObjectSelected?.(clickedObject);
4015
+ if (resolved) config.events?.onMeshMetadataClicked?.({
4016
+ source: clickedObject.userData?.source,
4017
+ name: resolved.member.name,
4018
+ layer: resolved.member.layer,
4019
+ trackingKey: resolved.member.trackingKey,
4020
+ metadata: resolved.member.metadata
4021
+ });
4022
+ else if (clickedObject instanceof THREE.Mesh && Object.keys(clickedObject.userData).length > 0) config.events?.onMeshMetadataClicked?.(clickedObject.userData);
4023
+ };
4024
+ const handleDoubleClick = (event) => {
4025
+ const hit = pickAt(event.clientX, event.clientY);
4026
+ if (!hit) {
4027
+ if (config.events?.enableDoubleClickZoom) cameraController.frameBounds(computeContentBounds(scene), true);
4028
+ return;
4029
+ }
4030
+ const target = hit.object;
4031
+ config.events?.onMeshDoubleClicked?.(target);
4032
+ if (!config.events?.enableDoubleClickZoom) return;
4033
+ const resolved = resolveHitMember(hit);
4034
+ const box = resolved && target instanceof THREE.Mesh ? memberBounds(target, resolved.member) : new THREE.Box3().setFromObject(target);
4035
+ if (box.isEmpty()) return;
4036
+ cameraController.frameBounds(box, true);
4037
+ };
4038
+ const handleKeydown = (event) => {
4039
+ if (!config.events?.enableKeyboardControls) return;
4040
+ switch (event.key.toLowerCase()) {
4041
+ case "f":
4042
+ event.preventDefault();
4043
+ fitToView();
4044
+ break;
4045
+ case "escape":
4046
+ event.preventDefault();
4047
+ clearSelection();
4048
+ break;
4049
+ case " ":
4050
+ event.preventDefault();
4051
+ fitToView();
4052
+ }
4053
+ };
4054
+ if (config.events?.enableClickToFocus) {
4055
+ canvas.addEventListener("mousedown", handleMouseDown);
4056
+ canvas.addEventListener("click", handleCanvasClick);
4057
+ canvas.addEventListener("dblclick", handleDoubleClick);
4058
+ }
4059
+ if (config.events?.enableKeyboardControls) {
4060
+ canvas.setAttribute("tabindex", "0");
4061
+ canvas.addEventListener("keydown", handleKeydown);
4062
+ }
4063
+ const dispose = () => {
4064
+ canvas.removeEventListener("mousedown", handleMouseDown);
4065
+ canvas.removeEventListener("click", handleCanvasClick);
4066
+ canvas.removeEventListener("dblclick", handleDoubleClick);
4067
+ canvas.removeEventListener("keydown", handleKeydown);
4068
+ clearHover();
4069
+ clearSelection();
4070
+ };
4071
+ return {
4072
+ dispose,
4073
+ fitToView,
4074
+ clearSelection,
4075
+ selectEntries,
4076
+ setHover
4077
+ };
4078
+ }
4079
+ // src/render/scene-setup/setup-renderer.ts
4080
+ function setupRenderer(canvas, config, pixelRatio) {
4081
+ const renderer = new THREE.WebGLRenderer({
4082
+ antialias: config.render.antialias,
4083
+ canvas,
4084
+ alpha: true,
4085
+ powerPreference: "high-performance",
4086
+ preserveDrawingBuffer: config.render.preserveDrawingBuffer,
4087
+ logarithmicDepthBuffer: false
4088
+ });
4089
+ const parent = canvas.parentElement;
4090
+ const width = parent ? parent.clientWidth : window.innerWidth;
4091
+ const height = parent ? parent.clientHeight : window.innerHeight;
4092
+ if (parent) {
4093
+ canvas.style.width = "100%";
4094
+ canvas.style.height = "100%";
4095
+ canvas.style.display = "block";
4096
+ }
4097
+ renderer.setSize(width, height, false);
4098
+ renderer.setPixelRatio(pixelRatio);
4099
+ if (config.render.enableShadows) {
4100
+ renderer.shadowMap.enabled = true;
4101
+ renderer.shadowMap.type = THREE.VSMShadowMap;
4102
+ }
4103
+ renderer.toneMapping = config.render.toneMapping;
4104
+ renderer.toneMappingExposure = config.render.toneMappingExposure ?? 1;
4105
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
4106
+ renderer.sortObjects = true;
4107
+ return renderer;
4108
+ }
4109
+ // src/render/scene-setup/init-three.ts
4110
+ const initThree = function(canvas, options) {
4111
+ const config = applyDefaults(options || {});
4112
+ const sceneUp = config.environment?.sceneUp || defaultUp;
4113
+ const fullPixelRatio = config.render.pixelRatio ?? Math.min(window.devicePixelRatio, 2);
4114
+ const pixelRatio = lookPixelRatio(fullPixelRatio, lookPreset(config.look, config.render.lowPower));
4115
+ config.render.pixelRatio = pixelRatio;
4116
+ const scene = createScene(config);
4117
+ const camera = createCamera(config, canvas);
4118
+ camera.up.copy(sceneUp);
4119
+ const renderer = setupRenderer(canvas, config, pixelRatio);
4120
+ publishMaxAnisotropy(renderer.capabilities.getMaxAnisotropy());
4121
+ options?.onMaxAnisotropy?.(renderer.capabilities.getMaxAnisotropy());
4122
+ const controls = setupControls(camera, canvas, config);
4123
+ const cameraController = createCameraController({
4124
+ scene,
4125
+ perspective: camera,
4126
+ controls,
4127
+ onActiveCameraChange: () => {},
4128
+ up: sceneUp
4129
+ });
4130
+ const getActiveCamera = () => cameraController.getActiveCamera();
4131
+ let disposed = false;
4132
+ setupEnvironment(scene, renderer, config, () => disposed);
4133
+ const lights = setupLighting(scene, config);
4134
+ const sunlight = lights.sun;
4135
+ const updateShadowBounds = () => {
4136
+ if (sunlight) fitShadowToContent(sunlight, computeContentBounds(scene));
4137
+ };
4138
+ if (config.floor?.enabled) addFloor(scene, config);
4139
+ const floorMesh = config.floor?.enabled ? scene.children.find((child) => child.userData.id === "floor") ?? null : null;
4140
+ const grid = config.grid.enabled ? createGrid({
4141
+ cellSize: config.grid.cellSize,
4142
+ majorEvery: config.grid.majorEvery,
4143
+ cellColor: config.grid.cellColor,
4144
+ majorColor: config.grid.majorColor,
4145
+ fadeDistance: config.grid.fadeDistance,
4146
+ plane: config.grid.plane
4147
+ }) : null;
4148
+ if (grid) scene.add(grid.object);
4149
+ const updateGridScale = () => {
4150
+ if (grid) grid.fitToContent(computeContentBounds(scene));
4151
+ };
4152
+ const gizmo = config.gizmo.enabled ? createViewGizmo({
4153
+ camera,
4154
+ domElement: canvas,
4155
+ controller: cameraController
4156
+ }) : null;
4157
+ const gridPlane = config.grid.plane ?? upToAxis(sceneUp);
4158
+ const gridNormal = new THREE.Vector3(gridPlane === "x" ? 1 : 0, gridPlane === "y" ? 1 : 0, gridPlane === "z" ? 1 : 0);
4159
+ const floorNormal = sceneUp.clone().normalize();
4160
+ const groundNormals = () => {
4161
+ const normals = [];
4162
+ if (grid?.object.visible) normals.push(gridNormal);
4163
+ if (config.floor.enabled && floorMesh?.visible) normals.push(floorNormal);
4164
+ return normals;
4165
+ };
4166
+ const nearFitter = config.camera.dynamicNear ? createNearPlaneFitter({
4167
+ camera,
4168
+ scene,
4169
+ groundNormals
4170
+ }) : null;
4171
+ const labelLayer = createLabelLayer(canvas.parentElement ?? canvas, scene);
4172
+ const measureTool = config.measure.enabled ? createMeasureTool({
4173
+ canvas,
4174
+ scene,
4175
+ getActiveCamera,
4176
+ getViewTarget: () => controls.target,
4177
+ requestRender: () => requestRender(),
4178
+ labelLayer,
4179
+ options: {
4180
+ snapPixels: config.measure.snapPixels,
4181
+ color: config.measure.color,
4182
+ labelClassName: config.measure.labelClassName,
4183
+ displayUnit: config.measure.displayUnit,
4184
+ format: config.measure.format
4185
+ }
4186
+ }) : null;
4187
+ const eventHandlers = config.events.enableEventHandlers !== false ? setupEventHandlers(canvas, scene, cameraController, config) : {
4188
+ dispose: () => {},
4189
+ fitToView: () => {},
4190
+ clearSelection: () => {},
4191
+ selectEntries: () => {},
4192
+ setHover: () => {}
4193
+ };
4194
+ const tools = createToolRegistry();
4195
+ if (measureTool) tools.register({
4196
+ id: "measure",
4197
+ tool: measureTool,
4198
+ priority: 0
4199
+ });
4200
+ if (gizmo) tools.register({
4201
+ id: "gizmo",
4202
+ tool: gizmo,
4203
+ priority: -100
4204
+ });
4205
+ const DRAG_SLOP_PX = 5;
4206
+ let pressX = 0;
4207
+ let pressY = 0;
4208
+ const handlePointerDown = (event) => {
4209
+ pressX = event.clientX;
4210
+ pressY = event.clientY;
4211
+ };
4212
+ const wasDrag = (event) => Math.hypot(event.clientX - pressX, event.clientY - pressY) > DRAG_SLOP_PX;
4213
+ const handleToolClick = (event) => {
4214
+ if (wasDrag(event)) return;
4215
+ if (tools.handleClick(event)) event.stopImmediatePropagation();
4216
+ };
4217
+ canvas.addEventListener("mousedown", handlePointerDown, { capture: true });
4218
+ canvas.addEventListener("click", handleToolClick, { capture: true });
4219
+ const handleToolMove = (event) => tools.handleMove(event);
4220
+ canvas.addEventListener("mousemove", handleToolMove, { passive: true });
4221
+ let requestRender = () => {};
4222
+ const applyEdges = (root, overrides) => {
4223
+ addEdgesAsync(root, {
4224
+ color: config.edges.color,
4225
+ darken: config.edges.darken,
4226
+ width: config.edges.width,
4227
+ thresholdAngle: config.edges.thresholdAngle,
4228
+ distanceFade: config.edges.distanceFade,
4229
+ maxTriangles: config.edges.maxTriangles,
4230
+ maxSegments: config.edges.maxSegments,
4231
+ maxOverlays: config.edges.maxOverlays,
4232
+ ...overrides
4233
+ }).then(() => {
4234
+ updateEdgeFallback(root);
4235
+ requestRender();
4236
+ });
4237
+ };
4238
+ const updateEdgeFallback = (root) => {
4239
+ if (config.edges.screenSpaceFallback === false) return;
4240
+ let hasSkippedMeshes = false;
4241
+ root.traverse((object) => {
4242
+ const skipped = object.userData?.edgesSkipped;
4243
+ if (skipped === "triangle-cap" || skipped === "overlay-budget") hasSkippedMeshes = true;
4244
+ });
4245
+ pipeline.setEdgeFallback(hasSkippedMeshes);
4246
+ };
4247
+ const clearEdges = (root) => {
4248
+ removeEdges(root);
4249
+ pipeline.setEdgeFallback(false);
4250
+ requestRender();
4251
+ };
4252
+ const parent = canvas.parentElement;
4253
+ const getCanvasSize = () => parent ? {
4254
+ width: parent.clientWidth,
4255
+ height: parent.clientHeight
4256
+ } : {
4257
+ width: window.innerWidth,
4258
+ height: window.innerHeight
4259
+ };
4260
+ const pipeline = createPipelineController({
4261
+ renderer,
4262
+ scene,
4263
+ getActiveCamera,
4264
+ getCanvasSize,
4265
+ config,
4266
+ requestRender: () => requestRender()
4267
+ });
4268
+ pipeline.sync();
4269
+ const appearance = createAppearanceController({
4270
+ scene,
4271
+ renderer,
4272
+ lights,
4273
+ config,
4274
+ pipeline,
4275
+ fullPixelRatio,
4276
+ requestRender: () => requestRender()
4277
+ });
4278
+ let restoreSun;
4279
+ const refiner = createIdleRefiner({
4280
+ scene,
4281
+ renderer,
4282
+ getPipeline: () => pipeline.get(),
4283
+ frames: config.render.idleRefineFrames,
4284
+ lightOrbitFrameMs: config.render.lightOrbitFrameMs,
4285
+ jitterLights: (index) => {
4286
+ if (sunlight) restoreSun = jitterSun(sunlight, index);
4287
+ appearance.jitterEmissiveLights(index);
4288
+ },
4289
+ restoreLights: () => {
4290
+ restoreSun?.();
4291
+ appearance.settleEmissiveLights();
4292
+ }
4293
+ });
4294
+ const { animate, dispose: disposeAnimation, invalidate, renderNow } = createAnimationLoop(renderer, scene, camera, getActiveCamera, cameraController, controls, getCanvasSize, () => config.render.pixelRatio, config.events.onFrame, grid, gizmo, () => pipeline.get(), labelLayer, nearFitter, config.render.onDemand ?? true, refiner);
4295
+ requestRender = invalidate;
4296
+ animate();
4297
+ scene.up.set(sceneUp.x, sceneUp.y, sceneUp.z);
4298
+ updateShadowBounds();
4299
+ updateGridScale();
4300
+ const captureImage = (type = "image/png", quality) => {
4301
+ renderNow();
4302
+ return new Promise((resolve) => renderer.domElement.toBlob(resolve, type, quality));
4303
+ };
4304
+ const addUserGeometry = (object, appId) => {
4305
+ object.userData.source = appId === void 0 ? SOURCE_USER : appSource(appId);
4306
+ scene.add(object);
4307
+ requestRender();
4308
+ };
4309
+ const removeUserGeometry = (object) => {
4310
+ object.removeFromParent();
4311
+ disposeObjectTree(object);
4312
+ requestRender();
4313
+ };
4314
+ const clearUserGeometry = (appId) => {
4315
+ scene.children.filter((child) => isOwnedBy(child, appId)).forEach((object) => {
4316
+ object.removeFromParent();
4317
+ disposeObjectTree(object);
4318
+ });
4319
+ requestRender();
4320
+ };
4321
+ const dispose = () => {
4322
+ if (disposed) return;
4323
+ disposed = true;
4324
+ disposeAnimation();
4325
+ eventHandlers.dispose();
4326
+ canvas.removeEventListener("mousedown", handlePointerDown, { capture: true });
4327
+ canvas.removeEventListener("click", handleToolClick, { capture: true });
4328
+ canvas.removeEventListener("mousemove", handleToolMove);
4329
+ measureTool?.dispose();
4330
+ labelLayer?.dispose();
4331
+ gizmo?.dispose();
4332
+ grid?.dispose();
4333
+ pipeline.dispose();
4334
+ cameraController.dispose();
4335
+ controls.dispose();
4336
+ renderer.dispose();
4337
+ renderer.forceContextLoss();
4338
+ disposeSceneResources(scene);
4339
+ };
4340
+ return {
4341
+ scene,
4342
+ camera,
4343
+ controls,
4344
+ renderer,
4345
+ cameraController,
4346
+ grid,
4347
+ gizmo,
4348
+ measureTool,
4349
+ labelLayer,
4350
+ tools,
4351
+ applyEdges,
4352
+ clearEdges,
4353
+ invalidate,
4354
+ captureImage,
4355
+ setAmbientOcclusion: pipeline.setAmbientOcclusion,
4356
+ setLook: appearance.setLook,
4357
+ syncEmissiveLights: appearance.syncEmissiveLights,
4358
+ setFillLights: appearance.setFillLights,
4359
+ setEnvironmentIntensity: appearance.setEnvironmentIntensity,
4360
+ setToneMappingExposure: appearance.setToneMappingExposure,
4361
+ setAoIntensity: appearance.setAoIntensity,
4362
+ getMaterialAppearance: appearance.getMaterialAppearance,
4363
+ updateShadowBounds,
4364
+ updateGridScale,
4365
+ dispose,
4366
+ fitToView: eventHandlers.fitToView,
4367
+ clearSelection: eventHandlers.clearSelection,
4368
+ selectEntries: (targets) => {
4369
+ eventHandlers.selectEntries(targets);
4370
+ invalidate();
4371
+ },
4372
+ setHover: (object, memberIndex) => {
4373
+ eventHandlers.setHover(object, memberIndex);
4374
+ invalidate();
4375
+ },
4376
+ addUserGeometry,
4377
+ removeUserGeometry,
4378
+ clearUserGeometry
4379
+ };
4380
+ };
4381
+ export { DEFAULT_LOOK, ErrorCodes, LOOKS, LOOK_PRESETS, SOURCE_COMPUTE, SOURCE_USER, VisualizationError, appIdFromSource, appSource, enableDebugLogging, getLogger, initThree, isHostOwned, isOwnedBy, materialAppearanceForLook, pickThreshold, pointerToNdc, setLogger, snapToVertex, updateScene };
4382
+
108
4383
  //# sourceMappingURL=render.js.map