Back to Funclet
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.

src/receiver-scene.ts
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(); }
  }
}