// camera.js import {mat4Ortho, mat4LookAt, mat4Multiply} from './math.js'; export class Camera { constructor(element, onChangeCallback) { this.element = element; this.onChange = onChangeCallback; this.target = [0.5, 0, 0.5]; this.radius = 4; this.theta = 135; this.phi = 30; this.projMatrix = new Float32Array(16); this.viewMatrix = new Float32Array(16); this.viewProjMatrix = new Float32Array(16); this.isDragging = false; this.dragButton = 0; this.touchMode = ''; this.lastMouse = {x: 0, y: 0}; this.lastPinchDist = 0; this.attachEvents(); } saveState() { this.checkpoint = { target: [...this.target], radius: this.radius, theta: this.theta, phi: this.phi, } } loadState() { if (!this.checkpoint) return; this.target = [...this.checkpoint.target]; this.radius = this.checkpoint.radius; this.theta = this.checkpoint.theta; this.phi = this.checkpoint.phi; this.onChange(); } // --- INTERACTION MATH --- orbit(dx, dy) { this.theta -= dx * 0.4; this.phi += dy * 0.4; this.phi = Math.max(-90, Math.min(90, this.phi)); } pan(dx, dy) { const t = this.theta * Math.PI / 180; const p = this.phi * Math.PI / 180; const rightX = Math.cos(t); const rightZ = -Math.sin(t); const upX = -Math.sin(p) * Math.sin(t); const upY = Math.cos(p); const upZ = -Math.sin(p) * Math.cos(t); const panSpeed = this.radius * 0.0025; this.target[0] += (-rightX * dx + upX * dy) * panSpeed; this.target[1] += (upY * dy) * panSpeed; this.target[2] += (-rightZ * dx + upZ * dy) * panSpeed; } zoom(delta) { this.radius += delta * 0.05; this.radius = Math.max(1, Math.min(50, this.radius)); } // --- EVENT LISTENERS --- attachEvents() { this.element.addEventListener('contextmenu', e => e.preventDefault()); // --- MOUSE EVENTS --- this.element.addEventListener('mousedown', (e) => { e.preventDefault(); this.isDragging = true; this.lastMouse = {x: e.clientX, y: e.clientY}; // Base button this.dragButton = e.button; // Modifier Overrides (Accessibility) if (this.dragButton === 0) { if (e.ctrlKey || e.metaKey) this.dragButton = 2; // Ctrl = Zoom else if (e.shiftKey) this.dragButton = 1; // Shift = Pan } if (this.dragButton === 1) this.element.style.cursor = 'move'; else if (this.dragButton === 2) this.element.style.cursor = 'ns-resize'; else this.element.style.cursor = 'grabbing'; }); window.addEventListener('mouseup', () => { this.isDragging = false; this.element.style.cursor = 'grab'; }); window.addEventListener('mousemove', (e) => { if (!this.isDragging) return; const dx = e.clientX - this.lastMouse.x; const dy = e.clientY - this.lastMouse.y; this.lastMouse = {x: e.clientX, y: e.clientY}; if (this.dragButton === 0) this.orbit(dx, dy); else if (this.dragButton === 1) this.pan(dx, dy); else if (this.dragButton === 2) this.zoom(dy); this.onChange(); }); // --- TOUCH EVENTS --- this.element.addEventListener('touchstart', (e) => { e.preventDefault(); // Prevents mobile browser pull-to-refresh / page scrolling this.isDragging = true; if (e.touches.length === 1) { this.touchMode = 'orbit'; this.lastMouse = {x: e.touches[0].clientX, y: e.touches[0].clientY}; } else if (e.touches.length >= 2) { this.touchMode = 'pan-zoom'; const t1 = e.touches[0], t2 = e.touches[1]; this.lastMouse = { x: (t1.clientX + t2.clientX) / 2, y: (t1.clientY + t2.clientY) / 2 }; this.lastPinchDist = Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY); } }, {passive: false}); this.element.addEventListener('touchmove', (e) => { if (!this.isDragging) return; e.preventDefault(); if (this.touchMode === 'orbit' && e.touches.length === 1) { const dx = e.touches[0].clientX - this.lastMouse.x; const dy = e.touches[0].clientY - this.lastMouse.y; this.lastMouse = {x: e.touches[0].clientX, y: e.touches[0].clientY}; this.orbit(dx, dy); this.onChange(); } else if (this.touchMode === 'pan-zoom' && e.touches.length >= 2) { const t1 = e.touches[0], t2 = e.touches[1]; const cx = (t1.clientX + t2.clientX) / 2; const cy = (t1.clientY + t2.clientY) / 2; const dist = Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY); const dx = cx - this.lastMouse.x; const dy = cy - this.lastMouse.y; const dDist = dist - this.lastPinchDist; // Positive if spreading fingers this.lastMouse = {x: cx, y: cy}; this.lastPinchDist = dist; this.pan(dx, dy); this.zoom(-dDist); // Pinching out zooms in this.onChange(); } }, {passive: false}); const onTouchEnd = (e) => { if (e.touches.length === 0) { this.isDragging = false; } else if (e.touches.length === 1) { // Seamlessly fall back to orbit if one finger is lifted this.touchMode = 'orbit'; this.lastMouse = {x: e.touches[0].clientX, y: e.touches[0].clientY}; } }; this.element.addEventListener('touchend', onTouchEnd); this.element.addEventListener('touchcancel', onTouchEnd); } updateMatrices(aspectRatio) { // ... (Keep existing updateMatrices code exactly as it is) ... const t = this.theta * Math.PI / 180; const p = this.phi * Math.PI / 180; const eyeX = this.target[0] + this.radius * Math.cos(p) * Math.sin(t); const eyeY = this.target[1] + this.radius * Math.sin(p); const eyeZ = this.target[2] + this.radius * Math.cos(p) * Math.cos(t); const upX = -Math.sin(p) * Math.sin(t); const upY = Math.cos(p); const upZ = -Math.sin(p) * Math.cos(t); const size = this.radius * 0.5; mat4Ortho(this.projMatrix, -size * aspectRatio, size * aspectRatio, -size, size, -50, 50); mat4LookAt(this.viewMatrix, eyeX, eyeY, eyeZ, this.target[0], this.target[1], this.target[2], upX, upY, upZ); mat4Multiply(this.viewProjMatrix, this.projMatrix, this.viewMatrix); return this.viewProjMatrix; } }