SOURCE / PINNED RELEASE
Made of little things.
Funclet
- Release
- 0b497cfc2f66…
- Author-recorded commit
- 1e531b24f490…
- License
- LICENSE
- Author’s source reference
- nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-6b7a37cac1d
Archive hash verified: 71d97a89ed8c40c2…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import {
ACESFilmicToneMapping, AmbientLight, BoxGeometry, CanvasTexture, CircleGeometry, CylinderGeometry,
DirectionalLight, EdgesGeometry, InstancedMesh, Matrix4, ExtrudeGeometry, Group, LineBasicMaterial,
LineSegments, Mesh, MeshBasicMaterial, MeshStandardMaterial, OrthographicCamera,
PCFSoftShadowMap, PlaneGeometry, Raycaster, Scene, ShaderMaterial, Shape,
ShadowMaterial, SRGBColorSpace, TorusGeometry, Vector2, WebGLRenderer,
} from 'three';
import type { BufferGeometry, Material, Object3D, Texture } from 'three';
import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js';
export interface ReceiverState {
title: string; station: string; status: string; playing: boolean; pauseAction: boolean; volume: number;
canPlay: boolean; canVolume: boolean;
}
interface Controls { toggle(): void; volume(value: number): void; volumeWheel(event: WheelEvent): void }
const INK = '#1d1c13', PAPER = '#fef9ea', RED = '#c5001c', CYAN = '#00c8d8';
/** A view of shell-owned playback. No audio, networking or persistent state lives here. */
export class ReceiverScene {
private renderer: WebGLRenderer | undefined;
private scene = new Scene();
private camera = new OrthographicCamera(-5, 5, 4, -4, .1, 80);
private cube = new Group();
private knob = new Group();
private key = new Group();
private playIcon = new Group();
private pauseIcon = new Group();
private raycaster = new Raycaster();
private targets: Object3D[] = [];
private textures = new Set<Texture>();
private signal: ShaderMaterial;
private display = document.createElement('canvas');
private displayTexture: CanvasTexture;
private reduced = matchMedia('(prefers-reduced-motion: reduce)');
// Phone GPUs share memory with the browser and often have very high screen DPRs.
private mobile = matchMedia('(pointer: coarse)').matches;
private observer: ResizeObserver;
private visibilityObserver: IntersectionObserver;
private inView = true;
private state: ReceiverState = {title: 'CHOOSE A FREQUENCY', station: 'WAVEFUNC / RADIO EVERYWHERE', status: 'OFF AIR', playing: false, pauseAction: false, volume: .75, canPlay: false, canVolume: false};
private frame = 0;
private previousTime = 0;
private elapsed = 0;
private pointer = new Vector2();
private cameraOffset = new Vector2();
private disposed = false;
private lost = false;
private contextLosses = 0;
private suspended = false;
private width = 0;
private height = 0;
private drag: { id: number; x: number; y: number; volume: number; kind: string; moved: boolean } | undefined;
private keyMaterial = new MeshStandardMaterial({color: RED, roughness: .45});
private ink = new MeshStandardMaterial({color: INK, roughness: .72});
private cream = new MeshStandardMaterial({color: PAPER, roughness: .68});
private cyan = new MeshStandardMaterial({color: CYAN, roughness: .45});
constructor(private host: HTMLElement, private controls: Controls) {
this.display.width = 1024; this.display.height = 160;
this.displayTexture = this.texture(this.display);
this.signal = new ShaderMaterial({
uniforms: {uTime: {value: 0}, uActive: {value: 0}},
vertexShader: 'varying vec2 vUv; void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }',
fragmentShader: `
varying vec2 vUv; uniform float uTime; uniform float uActive;
void main() {
vec2 p=vUv; vec3 ink=vec3(.018,.085,.082); vec3 cyan=vec3(0.,.79,.85);
float grid=step(.965,fract(p.x*40.))+step(.93,fract(p.y*8.));
float amplitude=mix(.012,.24,uActive);
float wave=.5+amplitude*sin(p.x*21.-uTime*2.7)*sin(p.x*7.+uTime*.8);
float trace=1.-smoothstep(.012,.033,abs(p.y-wave));
float echo=1.-smoothstep(.006,.016,abs(p.y-(.5+amplitude*.55*sin(p.x*32.+uTime*1.8))));
float sweep=pow(max(0.,1.-abs(p.x-fract(uTime*.13))),22.)*uActive;
float scan=.94+.06*sin(p.y*240.);
vec3 color=ink+cyan*(grid*.07+trace*.9+echo*.22+sweep*.12);
gl_FragColor=vec4(color*scan,1.);
}`,
});
this.observer = new ResizeObserver(this.resize);
this.visibilityObserver = new IntersectionObserver(entries => {
this.inView = entries[0]?.isIntersecting ?? false;
if (this.inView) this.wake();
else { cancelAnimationFrame(this.frame); this.frame=0; this.previousTime=0; }
});
try {
this.renderer = new WebGLRenderer({antialias: !this.mobile, alpha: true, powerPreference: 'low-power'});
// Three reports shader failures through this callback rather than throwing.
this.renderer.debug.onShaderError = () => { throw new Error('Receiver shader unavailable'); };
this.renderer.outputColorSpace = SRGBColorSpace;
this.renderer.toneMapping = ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.15;
this.renderer.shadowMap.enabled = !this.mobile;
this.renderer.shadowMap.type = PCFSoftShadowMap;
this.renderer.domElement.setAttribute('aria-hidden', 'true');
this.renderer.domElement.className = 'receiver-canvas';
this.host.prepend(this.renderer.domElement);
this.build();
this.positionCamera(0);
this.host.dataset.renderer = 'webgl';
this.observer.observe(this.host);
this.visibilityObserver.observe(this.host);
this.host.addEventListener('wheel', this.wheel, { passive: false });
this.host.addEventListener('pointerdown', this.pointerDown);
this.host.addEventListener('pointermove', this.pointerMove);
this.host.addEventListener('pointerup', this.pointerUp);
this.host.addEventListener('pointerleave', this.pointerLeave);
this.host.addEventListener('pointercancel', this.cancelDrag);
this.host.addEventListener('lostpointercapture', this.cancelDrag);
this.renderer.domElement.addEventListener('webglcontextlost', this.contextLost);
this.renderer.domElement.addEventListener('webglcontextrestored', this.contextRestored);
this.reduced.addEventListener('change', this.motionChanged);
document.addEventListener('visibilitychange', this.visibilityChanged);
window.addEventListener('blur', this.cancelDrag);
this.update(this.state);
} catch {
this.failGraphics();
}
}
private texture(canvas: HTMLCanvasElement) {
const texture = new CanvasTexture(canvas); texture.colorSpace = SRGBColorSpace;
this.textures.add(texture); return texture;
}
private mesh(geometry: BufferGeometry, material: Material, parent: Object3D, x=0, y=0, z=0) {
const mesh = new Mesh(geometry, material); mesh.position.set(x,y,z);
mesh.castShadow = true; mesh.receiveShadow = true; parent.add(mesh); return mesh;
}
private box(parent: Object3D, w: number, h: number, d: number, material: Material, x=0,y=0,z=0, radius=.04) {
return this.mesh(new RoundedBoxGeometry(w,h,d,2,radius), material, parent,x,y,z);
}
private label(text: string, w: number, h: number, parent: Object3D, x: number,y: number,z: number, color=INK) {
const canvas=document.createElement('canvas'); canvas.width=1024; canvas.height=Math.max(80,Math.round(1024*h/w));
const ctx=canvas.getContext('2d')!;
ctx.fillStyle=color;ctx.textAlign='center';ctx.textBaseline='middle';
ctx.font=`900 ${Math.round(canvas.height*.68)}px Arial, sans-serif`;
ctx.fillText(text,512,canvas.height*.53,990);
const mesh=this.mesh(new PlaneGeometry(w,h),new MeshStandardMaterial({map:this.texture(canvas),transparent:true,depthWrite:false,roughness:.8,polygonOffset:true,polygonOffsetFactor:-1}),parent,x,y,z);
mesh.castShadow=false; mesh.receiveShadow=false; return mesh;
}
private build() {
this.scene.add(new AmbientLight(0xffffff, 2.2));
const sun=new DirectionalLight(0xfff4dc, 4.5);sun.position.set(-3,8,5);sun.castShadow=true;
sun.shadow.mapSize.set(1024,1024);sun.shadow.camera.left=-8;sun.shadow.camera.right=8;
sun.shadow.camera.top=7;sun.shadow.camera.bottom=-7;sun.shadow.normalBias=.035;
this.scene.add(sun);
const fill=new DirectionalLight(0xbcefff, 1.1);fill.position.set(5,2,-4);this.scene.add(fill);
const ground=this.mesh(new PlaneGeometry(200,200),new MeshBasicMaterial({color:PAPER,toneMapped:false}),this.scene,0,-1.5,0);
ground.rotation.x=-Math.PI/2;ground.castShadow=false;
// A small contact-shadow texture replaces a second full scene render on phones.
let shadowMaterial: Material = new ShadowMaterial({opacity:.17});
if (this.mobile) {
shadowMaterial.dispose();
const canvas=document.createElement('canvas');canvas.width=128;canvas.height=128;
const ctx=canvas.getContext('2d')!;
const gradient=ctx.createRadialGradient(64,64,20,64,64,64);
gradient.addColorStop(0,'rgba(29,28,19,.25)');gradient.addColorStop(1,'rgba(29,28,19,0)');
ctx.fillStyle=gradient;ctx.fillRect(0,0,128,128);
shadowMaterial=new MeshBasicMaterial({map:this.texture(canvas),transparent:true,depthWrite:false,toneMapped:false});
}
const shadow=this.mesh(new PlaneGeometry(this.mobile?10:200,this.mobile?5:200),shadowMaterial,this.scene,0,-1.49,0);
shadow.rotation.x=-Math.PI/2;shadow.castShadow=false;
// The plinth and receiver are real solids, including the front controls.
this.box(this.scene,7,.16,2.5,this.ink,0,-1.36,0,.025);
this.box(this.scene,6.9,.13,2.4,this.cream,-.05,-1.25,0,.025);
const radio=new Group();this.scene.add(radio);
this.box(radio,6.55,2.95,1.65,this.ink,0,.325,0,.12);
this.box(radio,6.35,2.77,1.64,this.cream,0,.34,.045,.09);
this.box(radio,6.16,2.60,.1,this.ink,0,.33,.9,.05);
this.box(radio,6.02,2.46,.08,this.cream,0,.34,.96,.045);
// Case vents, exposed screws, a carry handle and a telescopic antenna.
for(let i=0;i<12;i++) this.box(radio,.055,.035,.75,this.ink,-2.55+i*.16,1.737,-.08,.01);
this.box(radio,2.15,.13,.22,this.ink,.35,2.25,-.25);
for(const x of [-.65,1.35]) this.box(radio,.14,.5,.22,this.ink,x,1.97,-.25);
for(const x of [-2.88,2.88]) for(const y of [-.73,1.42]) {
const screw=this.mesh(new CylinderGeometry(.045,.045,.025,12),this.ink,radio,x,y,1.015);screw.rotation.x=Math.PI/2;
}
const antenna=new Group();antenna.position.set(-2.78,1.71,-.35);antenna.rotation.z=.21;radio.add(antenna);
this.mesh(new CylinderGeometry(.027,.045,1.75,12),this.ink,antenna,0,.8,0);
this.mesh(new CylinderGeometry(.08,.08,.14,16),this.keyMaterial,antenna,0,1.68,0);
// Speaker: nested cones, perforated grille and a cyan center.
for(const [r,z,material] of [[.82,1.02,this.ink],[.72,1.06,this.cream],[.67,1.08,this.ink]] as const) {
this.mesh(new CircleGeometry(r,64),material,radio,-2.06,.3,z);
}
const holes: [number,number][]=[];
for(let ix=-7;ix<=7;ix++) for(let iy=-7;iy<=7;iy++) if(ix*ix+iy*iy<=43) holes.push([ix,iy]);
const grille=new InstancedMesh(new CircleGeometry(.019,6),this.cream,holes.length);
holes.forEach(([x,y],i)=>grille.setMatrixAt(i,new Matrix4().makeTranslation(-2.06+x*.095,.3+y*.095,1.089)));
radio.add(grille);
const ring=this.mesh(new TorusGeometry(.25,.018,8,48),this.cyan,radio,-2.06,.3,1.1);ring.castShadow=false;
this.mesh(new CircleGeometry(.07,24),this.cyan,radio,-2.06,.3,1.105);
this.label('WAVEFUNC',1.35,.16,radio,-2.06,-.77,1.002);
// Recessed nameplate and procedural CRT-style signal shader.
this.box(radio,3.74,1.01,.12,this.ink,1,.95,1.01,.025);
const displayMaterial=new MeshBasicMaterial({map:this.displayTexture,toneMapped:false});
this.mesh(new PlaneGeometry(3.52,.52),displayMaterial,radio,1,1.15,1.083).castShadow=false;
this.mesh(new PlaneGeometry(3.52,.34),this.signal,radio,1,.7,1.084).castShadow=false;
this.label('FUNCLET — 01',1.4,.13,radio,.35,-.77,1.002);
// A spring-loaded play key with separate solid play / pause glyphs.
this.key.position.set(-.32,-.35,1.08);radio.add(this.key);
this.box(this.key,1.34,.52,.17,this.ink,.035,-.035,0,.04);
const keyFace=this.box(this.key,1.27,.48,.24,this.keyMaterial,0,0,.1,.045);
keyFace.userData.control='play';this.targets.push(keyFace);
this.playIcon.position.z=.235;this.key.add(this.playIcon);
const triangle=new Shape();triangle.moveTo(-.1,-.12);triangle.lineTo(.13,0);triangle.lineTo(-.1,.12);triangle.closePath();
this.mesh(new ExtrudeGeometry(triangle,{depth:.015,bevelEnabled:false}),this.cream,this.playIcon);
this.pauseIcon.position.z=.235;this.key.add(this.pauseIcon);
for(const x of [-.07,.07])this.box(this.pauseIcon,.055,.23,.018,this.cream,x,0,0,.005);
this.label('PLAY / PAUSE',1.27,.14,radio,-.32,.14,1.002);
// The volume pointer rotates with the real host volume.
this.knob.position.set(2.1,-.35,1.16);radio.add(this.knob);
const base=this.mesh(new CylinderGeometry(.41,.41,.1,48),this.ink,this.knob);base.rotation.x=Math.PI/2;
const dial=this.mesh(new CylinderGeometry(.34,.36,.25,48),this.cream,this.knob,0,0,.15);dial.rotation.x=Math.PI/2;
dial.userData.control='volume';this.targets.push(dial);
for(let i=0;i<24;i++) {
const angle=i*Math.PI/12;
const tooth=this.box(this.knob,.018,.045,.16,this.ink,Math.sin(angle)*.33,Math.cos(angle)*.33,.14,.004);tooth.rotation.z=-angle;
}
this.box(this.knob,.034,.16,.012,this.keyMaterial,0,.19,.284,.004);
this.label('VOLUME',.8,.14,radio,2.1,.25,1.002);
for(let i=0;i<7;i++) {
const theta=-2.3+i*4.6/6;
const tick=this.box(radio,.018,.065,.015,this.ink,2.1+Math.sin(theta)*.46,-.35+Math.cos(theta)*.46,1.02,.003);tick.rotation.z=-theta;
}
// Floating station ident: four ink-backed, physically extruded [WF] faces.
this.cube.position.set(1.65,3.2,.05);this.cube.rotation.set(.12,-.5,-.08);this.scene.add(this.cube);
this.box(this.cube,1.42,1.42,1.42,this.ink,0,0,0,.055);
const cubeEdge=new LineSegments(new EdgesGeometry(new BoxGeometry(1.45,1.45,1.45)),new LineBasicMaterial({color:INK}));this.cube.add(cubeEdge);
for(let i=0;i<4;i++) {
const face=new Group();face.rotation.y=i*Math.PI/2;this.cube.add(face);
this.box(face,1.3,1.3,.02,i%2?this.cyan:this.cream,0,0,.718,.015);
this.lettering(face,.741);
this.label('SIGNAL IN MOTION',1.1,.085,face,0,-.48,.74);
}
const top=this.box(this.cube,1.3,.025,1.3,this.keyMaterial,0,.718,0,.015);top.receiveShadow=false;
const orbit=this.mesh(new TorusGeometry(1.12,.013,6,96),this.ink,this.scene,1.65,3.11,.05);
orbit.rotation.set(1.2,.2,-.2);
}
private lettering(parent: Object3D,z: number) {
// Block letter strokes avoid font/model loaders and remain crisp in silhouette.
const bar=(x:number,y:number,w:number,h:number,angle=0)=>{const m=this.box(parent,w,h,.065,this.ink,x,y,z,.008);m.rotation.z=angle;};
for(const sign of [-1,1]) {bar(sign*.52,0,.05,.52);bar(sign*.47,.235,.14,.05);bar(sign*.47,-.235,.14,.05);}
bar(-.32,0,.075,.46,.18);bar(-.08,0,.075,.46,-.18);
bar(-.24,-.11,.07,.27,-.27);bar(-.16,-.11,.07,.27,.27);
bar(.13,0,.075,.46);bar(.25,.195,.27,.075);bar(.23,.02,.23,.065);
}
update(state: ReceiverState) {
if(this.disposed)return;
const changed=state.title!==this.state.title||state.station!==this.state.station||state.status!==this.state.status;
this.state={...state};
this.host.dataset.playing=String(state.playing);
this.host.dataset.motion=this.reduced.matches?'reduced':'full';
this.playIcon.visible=!state.pauseAction;this.pauseIcon.visible=state.pauseAction;
this.knob.rotation.z=2.3-state.volume*4.6;
this.keyMaterial.color.set(state.canPlay?RED:'#8f776b');
this.signal.uniforms.uActive.value=state.playing?1:0;
try { if(changed||!this.display.dataset.drawn)this.drawDisplay(); }
catch { this.failGraphics(); return; }
this.wake();
}
private drawDisplay() {
const ctx=this.display.getContext('2d')!;this.display.dataset.drawn='true';
ctx.fillStyle=INK;ctx.fillRect(0,0,1024,160);
ctx.fillStyle=this.state.playing?CYAN:'#bbb5a2';ctx.font='bold 22px monospace';
ctx.fillText(`${this.state.status} / ${this.state.station.toUpperCase()}`,24,37,976);
ctx.fillStyle=PAPER;ctx.font='bold 53px Arial, sans-serif';
const title=this.state.title.toUpperCase();
let fitted=title;while(fitted.length>1&&ctx.measureText(fitted).width>970)fitted=fitted.slice(0,-1);
ctx.fillText(fitted===title?title:fitted.slice(0,-1)+'…',24,111,976);this.displayTexture.needsUpdate=true;
}
private resize=()=>{
if(this.disposed||this.lost)return;
this.width=this.host.clientWidth;this.height=this.host.clientHeight;
if(!this.width||!this.height)return;
const aspect=this.width/this.height, halfHeight=Math.max(3.4,4.4/aspect);
this.camera.left=-halfHeight*aspect;this.camera.right=halfHeight*aspect;
this.camera.top=halfHeight;this.camera.bottom=-halfHeight;this.camera.updateProjectionMatrix();
try {
const renderer=this.renderer;
if(!renderer)return;
const maxDimension=Math.min(renderer.capabilities.maxTextureSize,this.mobile?1024:4096);
const ratio=Math.min(devicePixelRatio,this.mobile?1.25:1.75,
Math.sqrt((this.mobile?400_000:3_000_000)/(this.width*this.height)),
maxDimension/this.width,maxDimension/this.height);
renderer.setDrawingBufferSize(this.width,this.height,ratio);
this.wake();
} catch { this.failGraphics(); }
};
setSuspended(value: boolean) {
this.suspended=value;
if(value){cancelAnimationFrame(this.frame);this.frame=0;this.previousTime=0;this.cancelDrag();}else this.wake();
}
private wake() {
if(this.frame||this.disposed||this.lost||this.suspended||!this.inView||document.hidden||!this.width||!this.height||!this.host.getClientRects().length)return;
this.frame=requestAnimationFrame(this.render);
}
private render=(time:number)=>{
this.frame=0;
if(this.disposed||this.lost||this.suspended||!this.inView||document.hidden||!this.host.getClientRects().length) {this.previousTime=0;return;}
if(this.mobile&&this.previousTime&&time-this.previousTime<1000/30-1) {this.wake();return;}
const dt=this.previousTime?Math.min((time-this.previousTime)/1000,.05):0;this.previousTime=time;
if(!this.reduced.matches) {
this.elapsed+=dt;
this.cube.rotation.y+=dt*(this.state.playing ? .65 : .2);
this.cube.position.y=3.2+Math.sin(this.elapsed*1.4)*.06;
if(this.state.playing)this.signal.uniforms.uTime.value+=dt;
}
// Hold the camera steady during a gesture so the physical control stays under the pointer.
if(!this.drag)this.positionCamera(dt);
try { this.renderer?.render(this.scene,this.camera); }
catch { this.failGraphics(); return; }
if(!this.reduced.matches)this.wake();else this.previousTime=0;
};
private positionCamera(dt: number) {
const reduced=this.reduced.matches;
if(reduced)this.cameraOffset.set(0,0);
else this.cameraOffset.lerp(this.pointer,1-Math.exp(-dt*4));
const driftX=reduced?0:Math.sin(this.elapsed*.22)*.28;
const driftY=reduced?0:Math.sin(this.elapsed*.3)*.16;
this.camera.position.set(7+driftX+this.cameraOffset.x*1.1,5.6+driftY+this.cameraOffset.y*.65,12-this.cameraOffset.x*.4);
this.camera.lookAt(0,1,0);
}
private pointerLeave=()=>{if(!this.drag){this.pointer.set(0,0);this.host.style.cursor='default';this.wake();}};
private wheel=(event:WheelEvent)=>{
if(this.state.canVolume&&this.hit(event)==='volume')this.controls.volumeWheel(event);
};
private hit(event: Pick<MouseEvent, 'clientX' | 'clientY'>) {
const rect=this.host.getBoundingClientRect();
const point=new Vector2((event.clientX-rect.left)/rect.width*2-1,-(event.clientY-rect.top)/rect.height*2+1);
this.raycaster.setFromCamera(point,this.camera);
return this.raycaster.intersectObjects(this.targets,false)[0]?.object.userData.control as string|undefined;
}
private pointerDown=(event:PointerEvent)=>{
if(event.button!==0||this.drag)return;
const kind=this.hit(event);
if(!kind||(kind==='play'&&!this.state.canPlay)||(kind==='volume'&&!this.state.canVolume))return;
event.preventDefault();this.host.setPointerCapture(event.pointerId);
this.drag={id:event.pointerId,x:event.clientX,y:event.clientY,volume:this.state.volume,kind,moved:false};
if(kind==='play')this.key.position.z=1.035;
this.wake();
};
private pointerMove=(event:PointerEvent)=>{
const drag=this.drag;
if(!drag&&event.pointerType==='mouse') {
const rect=this.host.getBoundingClientRect();
this.pointer.set(Math.max(-1,Math.min(1,(event.clientX-rect.left)/rect.width*2-1)),Math.max(-1,Math.min(1,1-(event.clientY-rect.top)/rect.height*2)));
this.wake();
}
if(!drag){const kind=this.hit(event);this.host.style.cursor=kind==='volume'&&this.state.canVolume?'ew-resize':kind==='play'&&this.state.canPlay?'pointer':'default';return;}
if(event.pointerId!==drag.id)return;
const dx=event.clientX-drag.x,dy=event.clientY-drag.y;
if(Math.hypot(dx,dy)>6)drag.moved=true;
if(drag.kind==='volume')this.controls.volume(Math.max(0,Math.min(1,drag.volume+(dx-dy)/160)));
};
private pointerUp=(event:PointerEvent)=>{
const drag=this.drag;if(!drag||event.pointerId!==drag.id)return;
if(drag.kind==='play'&&!drag.moved&&this.hit(event)==='play'&&this.state.canPlay)this.controls.toggle();
this.cancelDrag();
};
private cancelDrag=()=>{
const drag=this.drag;this.drag=undefined;
if(drag&&this.host.hasPointerCapture(drag.id))this.host.releasePointerCapture(drag.id);
this.key.position.z=1.08;this.wake();
};
private motionChanged=()=>{this.host.dataset.motion=this.reduced.matches?'reduced':'full';this.previousTime=0;this.wake();};
private visibilityChanged=()=>{this.cancelDrag();this.previousTime=0;if(document.hidden){cancelAnimationFrame(this.frame);this.frame=0;}else this.wake();};
private contextLost=(event:Event)=>{
event.preventDefault();this.lost=true;this.host.dataset.renderer='fallback';
cancelAnimationFrame(this.frame);this.frame=0;this.previousTime=0;this.cancelDrag();
// Recover a transient reset once; do not repeatedly pressure an exhausted GPU.
if(++this.contextLosses>1)this.failGraphics();
};
private contextRestored=()=>{
if(this.disposed)return;
this.lost=false;this.host.dataset.renderer='webgl';
this.textures.forEach(texture=>{texture.needsUpdate=true;});this.resize();
};
private failGraphics() {
this.host.dataset.renderer='fallback';
this.dispose();
}
dispose() {
if(this.disposed)return;
this.disposed=true;cancelAnimationFrame(this.frame);this.cancelDrag();this.observer.disconnect();this.visibilityObserver.disconnect();
this.host.removeEventListener('wheel',this.wheel);
this.host.removeEventListener('pointerdown',this.pointerDown);this.host.removeEventListener('pointermove',this.pointerMove);
this.host.removeEventListener('pointerup',this.pointerUp);this.host.removeEventListener('pointerleave',this.pointerLeave);this.host.removeEventListener('pointercancel',this.cancelDrag);
this.host.removeEventListener('lostpointercapture',this.cancelDrag);
this.reduced.removeEventListener('change',this.motionChanged);document.removeEventListener('visibilitychange',this.visibilityChanged);
window.removeEventListener('blur',this.cancelDrag);
const geometries=new Set<BufferGeometry>(),materials=new Set<Material>([this.signal,this.keyMaterial,this.ink,this.cream,this.cyan]);
this.scene.traverse(object=>{
if(object instanceof DirectionalLight)object.shadow.dispose();
if(object instanceof Mesh||object instanceof LineSegments){geometries.add(object.geometry);const m=object.material;(Array.isArray(m)?m:[m]).forEach(material=>materials.add(material));}
});
geometries.forEach(geometry=>geometry.dispose());materials.forEach(material=>material.dispose());this.textures.forEach(texture=>texture.dispose());
this.renderer?.domElement.removeEventListener('webglcontextlost',this.contextLost);this.renderer?.domElement.removeEventListener('webglcontextrestored',this.contextRestored);
// Return the context to the browser even when the iframe remains alive for playback.
const renderer=this.renderer;this.renderer=undefined;
try { renderer?.dispose(); renderer?.forceContextLoss(); }
finally { renderer?.domElement.remove(); }
}
}
