arff-instructor / scene.js
Superboss1976's picture
Upload folder using huggingface_hub
6df1646 verified
Raw History Blame Contribute Delete
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;}};
}