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