Spaces:
Running
Running
Download scene.js from Superboss1976/arff-instructor: direct link, hf CLI and curl.
- Browser
- Download file 15.8 kB
-
https://huggingface.co/spaces/Superboss1976/arff-instructor/resolve/main/scene.js
- Command line
-
hf download hf://spaces/Superboss1976/arff-instructor/scene.js
-
curl -L -o scene.js https://huggingface.co/spaces/Superboss1976/arff-instructor/resolve/main/scene.js
15.8 kB
| import * as T from 'three'; | |
| import { OrbitControls } from './vendor/OrbitControls.js'; | |
| const mat=(color,opts={})=>new T.MeshStandardMaterial({color,roughness:.65,metalness:.1,...opts}); | |
| function mesh(parent,geo,material,x=0,y=0,z=0){const o=new T.Mesh(geo,material);o.position.set(x,y,z);o.castShadow=true;o.receiveShadow=true;parent.add(o);return o;} | |
| function box(p,m,w,h,d,x=0,y=0,z=0){return mesh(p,new T.BoxGeometry(w,h,d),m,x,y,z);} | |
| function ball(p,m,rx,ry,rz,x=0,y=0,z=0){const o=mesh(p,new T.SphereGeometry(1,28,20),m,x,y,z);o.scale.set(rx,ry,rz);return o;} | |
| function cylinder(p,m,rt,rb,h,x=0,y=0,z=0){return mesh(p,new T.CylinderGeometry(rt,rb,h,28),m,x,y,z);} | |
| function textSprite(text,color='#ebf7ef',bg=null,size=128){const c=document.createElement('canvas');c.width=size*2;c.height=size;const ctx=c.getContext('2d');if(bg){ctx.fillStyle=bg;ctx.fillRect(0,0,c.width,c.height);}ctx.font=`bold ${size*.45}px Arial`;ctx.fillStyle=color;ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText(text,c.width/2,c.height/2,c.width*.92);const tex=new T.CanvasTexture(c);tex.colorSpace=T.SRGBColorSpace;return new T.Sprite(new T.SpriteMaterial({map:tex,depthTest:false}));} | |
| function renderer(container,alpha=false){const r=new T.WebGLRenderer({antialias:true,alpha,powerPreference:'low-power'});r.setPixelRatio(Math.min(devicePixelRatio,1.6));r.outputColorSpace=T.SRGBColorSpace;r.toneMapping=T.ACESFilmicToneMapping;r.toneMappingExposure=1.2;container.prepend(r.domElement);return r;} | |
| function sizeWatch(container,r,camera){const update=()=>{const w=container.clientWidth,h=container.clientHeight;if(!w||!h)return;r.setSize(w,h,false);camera.aspect=w/h;camera.updateProjectionMatrix();};new ResizeObserver(update).observe(container);update();} | |
| export function createAirfield(container,onObserve,options={}){ | |
| const scene=new T.Scene();scene.background=new T.Color('#223843');scene.fog=new T.Fog('#223843',32,105); | |
| const r=renderer(container);r.shadowMap.enabled=true;r.shadowMap.type=T.PCFSoftShadowMap; | |
| const camera=new T.PerspectiveCamera(39,1,.1,180);camera.position.set(23,18,24); | |
| const controls=new OrbitControls(camera,r.domElement);controls.target.set(0,.6,0);controls.enableDamping=true;controls.enablePan=false;controls.minDistance=15;controls.maxDistance=options.driving?65:48;controls.maxPolarAngle=Math.PI*.46;controls.minPolarAngle=.12; | |
| const hemi=new T.HemisphereLight('#dcf1ff','#37423d',2.4);scene.add(hemi); | |
| const sun=new T.DirectionalLight('#ffe0bc',3);sun.position.set(12,25,9);sun.castShadow=true;sun.shadow.mapSize.set(1024,1024);Object.assign(sun.shadow.camera,{left:-22,right:22,top:22,bottom:-22,near:.5,far:80});sun.shadow.bias=-.001;scene.add(sun); | |
| const fill=new T.DirectionalLight('#9acbff',1.4);fill.position.set(-20,7,-8);scene.add(fill); | |
| const ground=mesh(scene,new T.PlaneGeometry(240,240),mat('#293e46'),0,-.05,0);ground.rotation.x=-Math.PI/2; | |
| const grid=new T.GridHelper(100,50,'#475b60','#344a52');grid.position.y=0;grid.material.transparent=true;grid.material.opacity=.34;scene.add(grid); | |
| const runway=mat('#1d3038');box(scene,runway,70,.015,21,0,0,0); | |
| const paint=mat('#dddabe',{roughness:1});for(let x=-30;x<=30;x+=8)box(scene,paint,3,.02,.11,x,.018,-9.8); | |
| const yellow=mat('#c1a15e');box(scene,yellow,75,.015,.11,0,.01,11.5);box(scene,yellow,.11,.02,23,12,.01,0); | |
| for(let i=-6;i<7;i++){const light=ball(scene,mat('#a5dcef',{emissive:'#71c6e5',emissiveIntensity:1.5}),.065,.045,.065,i*5,.05,-10.8);light.castShadow=false;} | |
| const buildings=mat('#304954');for(let i=0;i<6;i++)box(scene,buildings,8,3+i%2,5,-27+i*11,1.5,-25-i%2*5); | |
| box(scene,mat('#3b5660'),2,8,2,23,4,-25);box(scene,mat('#6a8791',{metalness:.6}),4,2,3,23,8.4,-25); | |
| const aircraft=new T.Group();scene.add(aircraft); | |
| const white=mat('#dedfd5',{metalness:.32,roughness:.4}),wingMat=mat('#aebebc',{metalness:.5,roughness:.42}),red=mat('#b8422d'),glass=mat('#1f3b47',{metalness:.65,roughness:.12}),rubber=mat('#172226'),metal=mat('#829999',{metalness:.65}); | |
| ball(aircraft,white,8.4,1.03,1.08,0,2.05,0); | |
| ball(aircraft,glass,.82,.38,.78,6.65,2.52,0); | |
| // Wing meshes are illustrative geometry, not an aircraft rescue chart. | |
| function wing(points,material){const geo=new T.BufferGeometry();geo.setAttribute('position',new T.Float32BufferAttribute(points.flat(),3));geo.setIndex([0,1,2,0,2,3]);geo.computeVertexNormals();material.side=T.DoubleSide;return mesh(aircraft,geo,material);} | |
| for(const side of [-1,1]){ | |
| wing([[2.6,1.9,side*.6],[-2.4,1.9,side*.7],[-5.1,2.05,side*9.4],[-3.9,2.05,side*9.6]],wingMat); | |
| wing([[-3.9,2.05,side*9.6],[-5.1,2.05,side*9.4],[-5,2.7,side*9.7],[-4.35,2.75,side*9.8]],red); | |
| wing([[-5.6,2.2,side*.3],[-7.6,2.2,side*.3],[-8,2.45,side*3.5],[-7.15,2.45,side*3.6]],wingMat); | |
| for(let x=-4.7;x<5.8;x+=.57)ball(aircraft,glass,.13,.19,.035,x,2.43,side*(1.02*Math.sqrt(Math.max(.1,1-x*x/75)))); | |
| const engine=cylinder(aircraft,white,.56,.5,2.4,.2,1.28,side*3.1);engine.rotation.z=Math.PI/2; | |
| const intake=cylinder(aircraft,rubber,.46,.46,.06,1.42,1.28,side*3.1);intake.rotation.z=Math.PI/2; | |
| const fan=cylinder(aircraft,metal,.15,.25,.16,1.48,1.28,side*3.1);fan.rotation.z=Math.PI/2; | |
| box(aircraft,wingMat,.8,.7,.18,-.6,1.7,side*3.1); | |
| for(const x of [-2.5,5.5]){cylinder(aircraft,metal,.06,.06,1,x,.75,side*.68);const wheel=cylinder(aircraft,rubber,.26,.26,.25,x,.28,side*.68);wheel.rotation.x=Math.PI/2;} | |
| box(aircraft,red,10,.09,.035,0,1.95,side*.98); | |
| } | |
| wing([[-5.7,2.5,0],[-8.2,2.5,0],[-8,6,0],[-6.9,6,0]],red); | |
| const am=textSprite('AM','#ffffff');am.scale.set(1.5,.75,1);am.position.set(-7.2,4.3,.1);aircraft.add(am); | |
| const door=mat('#abb8b2');for(const side of [-1,1]){box(aircraft,door,.38,.9,.03,4.7,2.1,side*.86);box(aircraft,door,.35,.85,.03,-4.65,2.12,side*.88);} | |
| // Airport fire vehicle, staged for illustration only. | |
| const truck=new T.Group();truck.position.set(8,0,7);truck.rotation.y=-.45;scene.add(truck); | |
| box(truck,rubber,4.6,.5,2,0,.55,0);box(truck,red,4.7,1.45,2.1,0,1.35,0);box(truck,glass,1.1,.67,2.16,1.25,1.8,0);box(truck,paint,4.75,.2,2.12,0,1,0);box(truck,white,1.15,.1,2.14,1.25,2.13,0); | |
| for(const z of [-1.08,1.08])for(const x of [-1.5,-.3,1.5]){const w=cylinder(truck,rubber,.44,.44,.22,x,.5,z);w.rotation.x=Math.PI/2;const hub=cylinder(truck,metal,.21,.21,.24,x,.5,z);hub.rotation.x=Math.PI/2;} | |
| for(const z of [-1.07,1.07])for(const x of [-1.55,-.65])box(truck,mat('#798483'),.7,.7,.025,x,1.56,z); | |
| const beaconMat=mat('#719ed6',{emissive:'#2e6cc5',emissiveIntensity:2});box(truck,beaconMat,.55,.15,1.7,1.2,2.23,0);cylinder(truck,metal,.13,.17,.45,.2,2.3,0);const monitor=cylinder(truck,rubber,.11,.13,1,.6,2.5,0);monitor.rotation.z=Math.PI/2; | |
| const vehicleLabel=textSprite('RESCUE 01');vehicleLabel.scale.set(2,.55,1);vehicleLabel.position.set(8,3,7);scene.add(vehicleLabel); | |
| const hazard=new T.Group();hazard.position.set(1.7,.18,3.8);scene.add(hazard); | |
| const fireLight=new T.PointLight('#ff702c',12,12,2);fireLight.position.set(0,.8,0);hazard.add(fireLight); | |
| const flames=[];for(let i=0;i<15;i++){const f=ball(hazard,mat(i%3?'#ef8531':'#f5c354',{emissive:i%3?'#e74710':'#ffad25',emissiveIntensity:1.1,transparent:true,opacity:.78}),.19,.6,.2,Math.sin(i*3.7)*.75,.25,Math.cos(i*4.3)*.55);flames.push(f);} | |
| const smoke=[];for(let i=0;i<20;i++){const s=mesh(scene,new T.IcosahedronGeometry(1,2),mat('#26373b',{transparent:true,opacity:.37,depthWrite:false}));s.castShadow=false;s.receiveShadow=false;smoke.push(s);} | |
| hazard.visible=options.fire!==false; smoke.forEach(s=>s.visible=options.fire!==false); | |
| const trainingRoute=options.route||[[8,0,14],[14,0,14],[14,0,7],[11,0,7]];let vehicleTarget=null; | |
| const people=new T.Group();scene.add(people); | |
| if(options.driving){ | |
| truck.position.set(...trainingRoute[0]);truck.rotation.y=0;vehicleLabel.position.set(truck.position.x,3,truck.position.z); | |
| box(scene,mat('#182930'),58,.04,5,0,.035,16); | |
| for(let x=-28;x<28;x+=4)box(scene,paint,1.8,.04,.07,x,.065,16); | |
| for(const z of [13.4,18.6])box(scene,paint,58,.04,.10,0,.06,z); | |
| for(let z=14;z<=18;z+=.7)box(scene,paint,2.8,.05,.35,-4,.09,z); | |
| box(scene,mat('#e2b64b'),.18,.05,4.9,7.8,.09,16); | |
| [['BAY',-24],['BRAKE',-14],['CROSSING',-4],['ALPHA',7.8],['STAND',20]].forEach(([label,x])=>{const sign=textSprite(label,'#d4eeff','#122938',128);sign.scale.set(4,2,1);sign.position.set(x,2,20.5);scene.add(sign);}); | |
| for(let i=0;i<3;i++){const p=new T.Group();p.position.set(-4+(i%2)*.7,0,14.7+i*.9);people.add(p);ball(p,mat('#bf946f'),.18,.21,.18,0,1.55,0);box(p,mat('#e2dd44'),.45,.6,.28,0,1.04,0);for(const side of [-1,1]){box(p,rubber,.13,.68,.15,side*.13,.4,0);box(p,mat('#e2dd44'),.1,.5,.13,side*.29,1,0);}} | |
| } | |
| const pins=[];const locs=[[1.7,3.1,3.8],[4.7,3.8,-1],[8,4.8,7]]; | |
| locs.forEach((pos,i)=>{const pin=new T.Group();pin.position.set(...pos);scene.add(pin);const m=new T.MeshBasicMaterial({color:i===0?'#f9a064':'#a5dbc9',transparent:true,opacity:.95,depthTest:false});const circle=mesh(pin,new T.TorusGeometry(.36,.035,8,32),m);const label=textSprite(String(i+1).padStart(2,'0'));label.scale.set(.8,.4,1);pin.add(label);pin.userData.index=i;pins.push({group:pin,circle});if(options.driving)pin.visible=false;}); | |
| const ray=new T.Raycaster(),pointer=new T.Vector2();let down=null; r.domElement.addEventListener('pointerdown',e=>{down=[e.clientX,e.clientY];});r.domElement.addEventListener('pointerup',e=>{if(!down||Math.hypot(e.clientX-down[0],e.clientY-down[1])>6)return;const rect=r.domElement.getBoundingClientRect();pointer.set((e.clientX-rect.left)/rect.width*2-1,-(e.clientY-rect.top)/rect.height*2+1);ray.setFromCamera(pointer,camera);const hits=ray.intersectObjects(pins.map(p=>p.group),true);if(hits.length){let o=hits[0].object;while(o.parent&&o.userData.index===undefined)o=o.parent;if(o.userData.index!==undefined)onObserve(o.userData.index);}}); | |
| let paused=matchMedia('(prefers-reduced-motion: reduce)').matches,t=0,last=performance.now(),mission=0,effect='',active=true; | |
| document.addEventListener('visibilitychange',()=>{active=!document.hidden;last=performance.now();}); | |
| sizeWatch(container,r,camera); | |
| function frame(now){requestAnimationFrame(frame);const dt=Math.min((now-last)/1000,.05);last=now;if(!active||r.domElement.hidden)return;if(!paused)t+=dt;controls.update(); | |
| flames.forEach((f,i)=>{f.scale.y=.5+.35*Math.sin(t*7+i*1.8);f.position.y=f.scale.y*.48;}); | |
| smoke.forEach((s,i)=>{const u=(t*.16+i/20)%1;const shift=mission===1||effect==='shift'?2.8:1.25;s.position.set(1.7-u*shift,.9+u*6,3.8+u*2.2);const scale=.3+u*1.55;s.scale.setScalar(scale);s.material.opacity=(1-u)*.4;s.rotation.y=t*.11+i;}); | |
| pins.forEach(({group,circle})=>{circle.quaternion.copy(camera.quaternion);group.position.y=locs[group.userData.index][1]+(paused?0:Math.sin(t*1.5+group.userData.index)*.1);}); | |
| if(vehicleTarget&&!paused){truck.position.lerp(vehicleTarget,Math.min(1,dt*2.5));vehicleLabel.position.set(truck.position.x,3,truck.position.z);} | |
| fireLight.intensity=9+Math.sin(t*13)*2;beaconMat.emissiveIntensity=1+Math.max(0,Math.sin(t*5))*2;r.render(scene,camera); | |
| }requestAnimationFrame(frame); | |
| return {setPedestrians(v){people.visible=Boolean(v);},setTrainingMode(mode){hazard.visible=mode==='arff';smoke.forEach(s=>s.visible=mode==='arff');},moveVehicle(i){const p=trainingRoute[Math.max(0,Math.min(trainingRoute.length-1,i))];vehicleTarget=new T.Vector3(...p);if(matchMedia('(prefers-reduced-motion: reduce)').matches){truck.position.copy(vehicleTarget);vehicleLabel.position.set(truck.position.x,3,truck.position.z);}},setMission(i){mission=i;effect='';},setEffect(e){effect=e;},setPaused(v){paused=v;},view(v){const p=options.driving?(v==='top'?[.1,46,15.9]:v==='road'?[0,25,54]:[30,25,39]):(v==='top'?[.1,33,.2]:v==='side'?[1,7.5,30]:[23,18,24]);camera.position.set(...p);controls.target.set(0,.6,options.driving?12:0);controls.update();},observe(i){pins.forEach((p,j)=>p.group.scale.setScalar(i===j?1.35:1));}}; | |
| } | |
| export function createInstructor(container){ | |
| const scene=new T.Scene(),r=renderer(container,true),camera=new T.PerspectiveCamera(33,1,.1,60);camera.position.set(0,2.65,6.7);camera.lookAt(0,2.05,0); | |
| scene.add(new T.HemisphereLight('#ffffff','#9aab8d',3));const light=new T.DirectionalLight('#ffefd1',3);light.position.set(-3,5,5);scene.add(light);const rim=new T.DirectionalLight('#d1eff5',2);rim.position.set(4,3,-3);scene.add(rim); | |
| const figure=new T.Group();figure.rotation.y=-.05;scene.add(figure); | |
| const jacket=mat('#334b47',{roughness:.8}),trim=mat('#ddd992'),silver=mat('#d5ddce',{metalness:.45}),skin=mat('#ac7753',{roughness:.8}),dark=mat('#222f2e'),gold=mat('#d5ab46',{metalness:.25}),skinShade=mat('#805740'),white=mat('#e7e8da'),pupil=mat('#1e2522'); | |
| ball(figure,jacket,.82,.98,.4,0,.95,0);for(const side of [-1,1]){const arm=ball(figure,jacket,.3,.78,.31,side*.72,.82,.02);arm.rotation.z=side*.12;ball(figure,trim,.305,.095,.315,side*.76,.55,.02);} | |
| box(figure,trim,1.49,.13,.06,0,.65,.38);box(figure,silver,1.49,.038,.07,0,.65,.42);for(const side of [-1,1]){const t=box(figure,trim,.11,.55,.03,side*.43,1.49,.345);t.rotation.z=-side*.12;box(figure,jacket,.32,.28,.1,side*.35,1.15,.39);} | |
| box(figure,dark,.06,1.35,.08,0,1.05,.41);box(figure,dark,.26,.38,.13,-.49,1.52,.43);box(figure,mat('#53645e'),.18,.1,.025,-.49,1.61,.508);cylinder(figure,dark,.017,.017,.35,-.53,1.87,.46); | |
| cylinder(figure,dark,.29,.39,.28,0,1.86,0);cylinder(figure,skin,.24,.27,.4,0,2.03,0); | |
| const head=new T.Group();head.position.y=2.62;figure.add(head); | |
| ball(head,skin,.47,.63,.43,0,0,0);ball(head,skin,.35,.25,.32,0,-.35,.08); | |
| for(const side of [-1,1]){ball(head,skin,.09,.15,.06,side*.47,-.04,0);ball(head,skinShade,.04,.09,.025,side*.49,-.04,.048);} | |
| const eyes=[];for(const side of [-1,1]){const eye=new T.Group();eye.position.set(side*.18,.065,.385);head.add(eye);ball(eye,white,.108,.065,.043);ball(eye,pupil,.043,.048,.024,-side*.006,0,.037);ball(eye,white,.012,.012,.01,.007,.014,.06);eyes.push(eye);const brow=box(head,dark,.19,.035,.05,side*.19,.175,.40);brow.rotation.z=side*-.08;} | |
| ball(head,skin,.081,.15,.095,0,-.065,.42);ball(head,skinShade,.032,.018,.025,-.043,-.15,.48);ball(head,skinShade,.032,.018,.025,.043,-.15,.48); | |
| const mouth=ball(head,mat('#523f34'),.143,.027,.025,0,-.267,.403);const lowerLip=ball(head,skinShade,.14,.025,.033,0,-.304,.4); | |
| ball(head,skinShade,.22,.028,.035,0,-.395,.32); | |
| // Raised face shield leaves the face visible; this is an instructor in a briefing. | |
| const helmet=mesh(head,new T.SphereGeometry(.57,40,24,0,Math.PI*2,0,Math.PI/2),gold,0,.32,-.005);helmet.scale.set(1,1.05,1.04); | |
| const brim=cylinder(head,gold,.67,.69,.07,0,.32,.025);brim.scale.z=1.07; | |
| box(head,gold,.08,.13,1.06,0,.77,-.02);box(head,dark,.25,.27,.03,0,.56,.535);box(head,silver,.17,.13,.035,0,.575,.556); | |
| const shield=ball(head,mat('#607b71',{metalness:.55,roughness:.16,transparent:true,opacity:.65}),.49,.18,.08,0,.39,.56);shield.rotation.x=-.25; | |
| for(const side of [-1,1]){ball(head,dark,.075,.075,.04,side*.53,.32,.12);box(head,dark,.028,.44,.025,side*.43,-.15,.19);} | |
| const chest=textSprite('AM', '#e7ead2');chest.scale.set(.33,.165,1);chest.position.set(.35,1.39,.52);figure.add(chest); | |
| let speaking=false,paused=matchMedia('(prefers-reduced-motion: reduce)').matches,t=0,last=performance.now();sizeWatch(container,r,camera); | |
| function frame(now){requestAnimationFrame(frame);const dt=Math.min((now-last)/1000,.05);last=now;if(document.hidden)return;if(!paused)t+=dt;head.rotation.y=Math.sin(t*.62)*.06;head.rotation.z=Math.sin(t*.41)*.015;figure.position.y=Math.sin(t*1.35)*.015;const blink=(t%4.7)>4.55?.1:1;eyes.forEach(e=>e.scale.y=blink);const open=speaking&&!paused?.25+Math.abs(Math.sin(t*12)*Math.cos(t*7)):.02;mouth.scale.y=.028+open*.085;lowerLip.position.y=-.3-open*.055;head.rotation.x=speaking&&!paused?Math.sin(t*2.4)*.025:0;r.render(scene,camera);}requestAnimationFrame(frame); | |
| return {speak(v){speaking=v;},setPaused(v){paused=v;}}; | |
| } | |