From 1507390e1fb3eba5c4d74e1ac096a00977f3bcc2 Mon Sep 17 00:00:00 2001 From: silentoplayz Date: Tue, 13 Oct 2026 16:33:12 -0400 Subject: [PATCH] feat: rotate, pan and zoom the voice avatar --- .../CallOverlay/VoiceAvatar.svelte | 67 ++++++++++++++++++- src/lib/utils/voice-avatar-renderer.ts | 37 +++++++++- 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/src/lib/components/chat/MessageInput/CallOverlay/VoiceAvatar.svelte b/src/lib/components/chat/MessageInput/CallOverlay/VoiceAvatar.svelte index 8b51a7bab5..1818a4d253 100644 --- a/src/lib/components/chat/MessageInput/CallOverlay/VoiceAvatar.svelte +++ b/src/lib/components/chat/MessageInput/CallOverlay/VoiceAvatar.svelte @@ -35,6 +35,52 @@ } const dispatch = createEventDispatcher(); let canvas: HTMLCanvasElement; + const pointers = new Map(); + let moved = 0, + velocity = 0, + lastMove = 0, + lastTap = 0; + const pointerDown = (event: PointerEvent) => { + if (!renderer) return; + canvas.setPointerCapture(event.pointerId); + pointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); + if (pointers.size === 1) moved = velocity = 0; + }; + const pointerMove = (event: PointerEvent) => { + const point = pointers.get(event.pointerId); + if (!point || !renderer) return; + const dx = event.clientX - point.x, + dy = event.clientY - point.y; + const other = [...pointers.values()].find((p) => p !== point); + const before = other ? Math.hypot(point.x - other.x, point.y - other.y) : 0; + point.x = event.clientX; + point.y = event.clientY; + moved += Math.hypot(dx, dy); + if (other) { + renderer.panView(dx / 2, dy / 2); + renderer.zoomView(before / Math.max(1, Math.hypot(point.x - other.x, point.y - other.y))); + velocity = 0; + } else if (event.buttons & 6 || event.shiftKey) { + renderer.panView(dx, dy); + velocity = 0; + } else { + renderer.rotateView(dx, dy); + velocity = velocity * 0.5 + (dx / Math.max(1, event.timeStamp - lastMove)) * 1000 * 0.5; + } + lastMove = event.timeStamp; + }; + const pointerUp = (event: PointerEvent) => { + if (!pointers.delete(event.pointerId) || !renderer || pointers.size) return; + if (moved < 5) { + if (event.timeStamp - lastTap < 300) renderer.resetView(); + lastTap = event.timeStamp; + } else if (velocity && event.timeStamp - lastMove < 50) renderer.spinView(velocity); + }; + const wheel = (event: WheelEvent) => { + if (!renderer) return; + event.preventDefault(); + renderer.zoomView(Math.exp(event.deltaY * (event.deltaMode ? 0.05 : 0.001))); + }; // The parent keys this component by file, keeping settings changes inexpensive. onMount(() => { @@ -162,13 +208,30 @@ }); - + diff --git a/src/lib/utils/voice-avatar-renderer.ts b/src/lib/utils/voice-avatar-renderer.ts index c245e39fb2..2adb8c60eb 100644 --- a/src/lib/utils/voice-avatar-renderer.ts +++ b/src/lib/utils/voice-avatar-renderer.ts @@ -219,6 +219,11 @@ export async function createAvatarRenderer( gestureElapsed = 0; let enabled = true; let cameraDistance = 0; + let yaw = 0, + pitch = 0, + spin = 0, + zoom = 1; + const pan = new THREE.Vector2(); const switchClip = (clip: THREE.AnimationClip, once: boolean) => { if (outgoing) { outgoing.stop(); @@ -282,6 +287,30 @@ export async function createAvatarRenderer( return 'started' as const; }, cancelGesture, + rotateView(dx: number, dy: number) { + const scale = (2 * Math.PI) / Math.max(height, 1); + yaw -= dx * scale; + pitch = THREE.MathUtils.clamp(pitch + dy * scale, -1.3, 1.3); + spin = 0; + }, + spinView(velocity: number) { + spin = (-velocity * 2 * Math.PI) / Math.max(height, 1); + }, + panView(dx: number, dy: number) { + const scale = + (2 * cameraDistance * zoom * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2))) / + Math.max(height, 1); + pan.x -= dx * scale; + pan.y += dy * scale; + }, + zoomView(scale: number) { + zoom = THREE.MathUtils.clamp(zoom * scale, 0.3, 3); + }, + resetView() { + yaw = pitch = spin = 0; + zoom = 1; + pan.set(0, 0); + }, render(delta: number, config: VoiceAvatarConfig, input: AvatarSignals, reducedMotion: boolean) { const dt = Math.min(delta, 0.05); elapsed += dt; @@ -420,8 +449,14 @@ export async function createAvatarRenderer( (Math.max(fitHeight, fitWidth / camera.aspect) * 1.2) / (2 * Math.tan(Math.PI / 12)); // Widen before a limb reaches the edge; never zoom in/out on each gesture. cameraDistance = Math.max(distance, cameraDistance); - camera.position.set(center.x, center.y, center.z + cameraDistance); + spin = reducedMotion || Math.abs(spin) < 0.01 ? 0 : spin * Math.exp(-dt * 3); + yaw += spin * dt; + camera.position + .setFromSphericalCoords(cameraDistance * zoom, Math.PI / 2 - pitch, yaw) + .add(center); camera.lookAt(center); + camera.translateX(pan.x); + camera.translateY(pan.y); renderer.render(scene, camera); }, dispose() {