import * as THREE from 'three'; import {RoomEnvironment} from 'three/addons/environments/RoomEnvironment.js'; // Runtime-only material treatment. No geometry, hydraulic data or source GLB edits. // Surface grains are illustrative, not measured site textures. World-space detail // avoids inventing UVs for the CAD meshes, and fades below the pixel footprint. const noiseGLSL=` varying vec3 vSurfaceWorld; float grainHash(vec3 p){p=fract(p*.1031);p+=dot(p,p.yzx+33.33);return fract((p.x+p.y)*p.z);} float grainNoise(vec3 p){vec3 i=floor(p),f=fract(p);f=f*f*(3.-2.*f); return mix(mix(mix(grainHash(i),grainHash(i+vec3(1,0,0)),f.x),mix(grainHash(i+vec3(0,1,0)),grainHash(i+vec3(1,1,0)),f.x),f.y),mix(mix(grainHash(i+vec3(0,0,1)),grainHash(i+vec3(1,0,1)),f.x),mix(grainHash(i+vec3(0,1,1)),grainHash(i+vec3(1,1,1)),f.x),f.y),f.z);} `; export function grain(material,{frequency=180,amplitude=.00008,variation=.04,colorVariation=0}={}){ material.onBeforeCompile=shader=>{ shader.vertexShader=shader.vertexShader.replace('#include ','#include \nvarying vec3 vSurfaceWorld;').replace('#include ','#include \nvec4 surfacePosition=vec4(transformed,1.0);\n#ifdef USE_INSTANCING\nsurfacePosition=instanceMatrix*surfacePosition;\n#endif\nvSurfaceWorld=(modelMatrix*surfacePosition).xyz;'); shader.fragmentShader=shader.fragmentShader.replace('#include ','#include \n'+noiseGLSL) .replace('#include ',`#include float macroFade=1.-smoothstep(.2,1.2,max(length(dFdx(vSurfaceWorld)),length(dFdy(vSurfaceWorld)))*2.7);\ndiffuseColor.rgb*=1.+(grainNoise(vSurfaceWorld*2.7)-.5)*macroFade*${colorVariation.toFixed(4)};`) .replace('#include ',`#include float grainFoot=max(length(dFdx(vSurfaceWorld)),length(dFdy(vSurfaceWorld)))*${frequency.toFixed(1)}; float grainFade=1.-smoothstep(.25,1.5,grainFoot); float surfaceGrain=(grainNoise(vSurfaceWorld*${frequency.toFixed(1)})-.5)*grainFade; roughnessFactor=clamp(roughnessFactor+surfaceGrain*${variation.toFixed(4)},.08,1.);`) .replace('#include ',`#include float grainHeight=surfaceGrain*${amplitude.toFixed(6)}; vec3 gx=dFdx(-vViewPosition),gy=dFdy(-vViewPosition); vec3 rx=cross(gy,normal),ry=cross(normal,gx);float gd=dot(gx,rx); normal=normalize(abs(gd)*normal-sign(gd)*(dFdx(grainHeight)*rx+dFdy(grainHeight)*ry));`); }; material.customProgramCacheKey=()=>`zjb-grain-${frequency}-${amplitude}-${variation}-${colorVariation}`; } export function enhanceMaterials(object){ const cache=new Map(); object.traverse(mesh=>{ if(!mesh.isMesh)return; mesh.receiveShadow=true;mesh.castShadow=true; const convert=source=>{ if(cache.has(source))return cache.get(source); const m=new THREE.MeshPhysicalMaterial();THREE.MeshStandardMaterial.prototype.copy.call(m,source); m.name=source.name;m.envMapIntensity=.85; const name=m.name.toLowerCase();let surface='paint'; // Convert display paint swatches from sRGB; the source palette was very pale // under environment illumination. Preserve blue / red / green identities. if(/v11_blue/.test(name))m.color.set(0x176184); if(/v11_red/.test(name))m.color.set(0xad322c); if(/v11_out/.test(name))m.color.set(0x267f74); if(/v11_white|v11_wall/.test(name))m.color.set(0xcbd0ce); if(/v11_floor/.test(name))m.color.set(0x8c9692); if(/v3_roofinlay/.test(name))m.color.set(0x59646a); else if(/v3_roof|v3_white/.test(name))m.color.set(0xd8dcda); if(/v3_v9_road/.test(name))m.color.set(0x3e4648); if(/v3.*concrete/.test(name))m.color.set(0x929b96); if(/roof/.test(name)){surface='roof-metal';m.metalness=.5;m.roughness=.46;m.clearcoat=.12;m.envMapIntensity=.65;grain(m,{frequency:90,amplitude:.00002,variation:.045});}else if(/glass|water/.test(name)){ surface='glass';m.metalness=.05;m.roughness=.19;m.clearcoat=1;m.clearcoatRoughness=.09;m.envMapIntensity=1.2; // Retain alpha visibility and avoid transmission rendering through inspection cutaways. }else if(/steel|lattice|perforated/.test(name)){ surface='metal';m.metalness=.88;m.roughness=.3;m.envMapIntensity=1.1;grain(m,{frequency:260,amplitude:.00002,variation:.13}); }else if(/concrete|floor|wall|road/.test(name)){ surface='mineral';m.metalness=0;m.roughness=/floor/.test(name)?.64:.91;m.envMapIntensity=.3; grain(m,{frequency:/road/.test(name)?25:75,amplitude:.001,variation:.16,colorVariation:.1}); }else if(/seat/.test(name)){ surface='seat';m.metalness=.04;m.roughness=.66;grain(m,{frequency:360,amplitude:.00012,variation:.15}); }else if(/light|diffuser|screen/.test(name)){ surface='luminaire';m.metalness=0;m.roughness=.38;m.emissive.set(0xffe5b7);m.emissiveIntensity=.55; }else{ m.metalness=/dark|black/.test(name)?.58:.23;m.roughness=/roof/.test(name)?.36:.3; m.clearcoat=.4;m.clearcoatRoughness=.25;grain(m); } // Facade coatings need their own optical treatment. Keep generic glass // (instruments, water and interiors) separate from the station curtain wall. if(/v3_glass|coatedcurtainglass/.test(name)){ surface='glass';const coated=/coatedcurtain/.test(name); m.color.set(coated?0x466b82:0x577e90);m.metalness=coated?.42:.18; m.roughness=coated?.1:.13;m.opacity=coated?.78:.58; m.transparent=true;m.depthWrite=false;m.clearcoat=.65; m.clearcoatRoughness=.08;m.envMapIntensity=1.6; } m.userData={...source.userData,surfaceTreatment:surface,appearanceRevision:29};cache.set(source,m);return m; }; mesh.material=Array.isArray(mesh.material)?mesh.material.map(convert):convert(mesh.material); mesh.castShadow=!(Array.isArray(mesh.material)?mesh.material:[mesh.material]).every(m=>m.userData.surfaceTreatment==='glass');mesh.receiveShadow=mesh.castShadow; }); return cache; } // Restore the authored appearance on every mode change. In network overview, // opaque buildings fade more than glass so the curtain wall remains legible. export function applyBuildingContext(material,fade){ const original=material.userData.original??{opacity:material.opacity,transparent:material.transparent,depthWrite:material.depthWrite}; material.userData.original=original; const facade=/v3_glass|coatedcurtainglass/i.test(material.name); material.opacity=fade?(facade?Math.min(original.opacity,.38):.12):original.opacity; material.transparent=fade||original.transparent; material.depthWrite=fade?false:original.depthWrite;material.needsUpdate=true; } const presets={ day:{background:0xe2e8e9,ground:0xb9c1bd,key:0xffefdc,keyPower:3.1,fill:.32,env:.45,rim:.85,exposure:.95}, studio:{background:0x242d34,ground:0x303a40,key:0xfff5e9,keyPower:2.6,fill:.24,env:.8,rim:1.3,exposure:.95}, evening:{background:0x555d69,ground:0x707574,key:0xffc185,keyPower:3.0,fill:.22,env:.5,rim:.9,exposure:.95}, }; export function createAppearance(renderer,scene){ renderer.outputColorSpace=THREE.SRGBColorSpace; renderer.toneMapping=THREE.ACESFilmicToneMapping; renderer.shadowMap.enabled=true;renderer.shadowMap.type=THREE.PCFShadowMap;renderer.shadowMap.autoUpdate=false; const pmrem=new THREE.PMREMGenerator(renderer),room=new RoomEnvironment(); const environment=pmrem.fromScene(room,.04);room.dispose();pmrem.dispose();scene.environment=environment.texture; const fill=new THREE.HemisphereLight(0xcde3ff,0x716658,.75),sun=new THREE.DirectionalLight(0xffefdc,3.2),rim=new THREE.DirectionalLight(0xc1dbff,1.); sun.castShadow=true;sun.shadow.mapSize.set(2048,2048);sun.shadow.radius=3;sun.shadow.bias=-.00008;sun.shadow.normalBias=.018; scene.add(fill,sun,sun.target,rim,rim.target); const floorMat=new THREE.MeshStandardMaterial({color:0xb9c1bd,roughness:.94,metalness:0});grain(floorMat,{frequency:45,amplitude:.0006,variation:.12,colorVariation:.035}); const floor=new THREE.Mesh(new THREE.PlaneGeometry(1,1),floorMat);floor.rotation.x=-Math.PI/2;floor.receiveShadow=true;floor.userData.displayOnly=true;floor.name='Presentation ground (not CAD)';scene.add(floor); let current='day',shadowEnabled=true,lastBox,lastMode; const state={revision:27,preset:current,environment:'offline studio PMREM',shadows:true,proceduralSurfaces:true,groundIsPresentationOnly:true}; function refresh(){if(lastMode==='hydraulic'){let y=Infinity;for(const o of scene.children)if(o.visible&&o.isGroup){const b=new THREE.Box3().setFromObject(o);if(!b.isEmpty())y=Math.min(y,b.min.y-.3);}if(Number.isFinite(y))floor.position.y=y;}renderer.shadowMap.needsUpdate=true;} function frame(box,mode){ if(box.isEmpty())return;lastBox=box.clone();lastMode=mode; const c=box.getCenter(new THREE.Vector3()),size=box.getSize(new THREE.Vector3()),radius=Math.max(size.length()/2,2),extent=Math.max(size.x,size.z,4)*.7; const p=presets[current]; sun.target.position.copy(c);sun.position.copy(c).add(new THREE.Vector3(-.8,current==='evening'?.7:1.8,.95).multiplyScalar(radius*2)); rim.target.position.copy(c);rim.position.copy(c).add(new THREE.Vector3(1,.5,-1).multiplyScalar(radius*2)); Object.assign(sun.shadow.camera,{left:-extent,right:extent,top:extent,bottom:-extent,near:.1,far:radius*9});sun.shadow.camera.updateProjectionMatrix();sun.shadow.normalBias=radius>100?.15:.055; floor.visible=mode!=='network'; let groundY=box.min.y-.06;if(mode==='hydraulic'){for(const o of scene.children)if(o.visible&&o.isGroup){const b=new THREE.Box3().setFromObject(o);if(!b.isEmpty())groundY=Math.min(groundY,b.min.y-.3);}}floor.position.set(c.x,groundY,c.z);floor.scale.setScalar(Math.max(size.x,size.z,4)*12); scene.fog=['network','hydraulic'].includes(mode)?null:new THREE.Fog(p.background,radius*8,radius*25); sun.castShadow=shadowEnabled&&mode!=='network';state.activeShadows=sun.castShadow;state.presentationGroundVisible=floor.visible;refresh(); } function preset(name){current=presets[name]?name:'day';const p=presets[current];scene.background=new THREE.Color(p.background);floorMat.color.set(p.ground);sun.color.set(p.key);sun.intensity=p.keyPower;fill.intensity=p.fill;rim.intensity=p.rim;scene.environmentIntensity=p.env;renderer.toneMappingExposure=p.exposure;state.preset=current;state.exposure=p.exposure;if(lastBox)frame(lastBox,lastMode);refresh();} preset('day'); return {state,frame,preset,refresh,exposure(value){renderer.toneMappingExposure=value;state.exposure=value;},shadows(enabled){shadowEnabled=enabled;state.shadows=enabled;if(lastBox)frame(lastBox,lastMode);},dispose(){environment.dispose();floor.geometry.dispose();floorMat.dispose();sun.shadow.dispose();scene.remove(fill,sun,sun.target,rim,rim.target,floor);}}; }