@selvajs/visualization 1.6.0-beta.8 → 1.6.0-beta.9

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