// 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; // 0: Left, 1: Middle, 2: Right this.lastMouse = {x: 0, y: 0}; this.attachEvents(); } attachEvents() { // Prevent the browser context menu on right-click this.element.addEventListener('contextmenu', e => e.preventDefault()); this.element.addEventListener('mousedown', (e) => { e.preventDefault(); this.isDragging = true; this.dragButton = e.button; this.lastMouse = {x: e.clientX, y: e.clientY}; // Visual feedback based on action 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) { // LEFT CLICK: Orbit this.theta -= dx * 0.4; this.phi += dy * 0.4; this.phi = Math.max(-90, Math.min(90, this.phi)); } else if (this.dragButton === 1) { // MIDDLE CLICK: Pan const t = this.theta * Math.PI / 180; const p = this.phi * Math.PI / 180; // Camera's local Right vector mapped to world space const rightX = Math.cos(t); const rightZ = -Math.sin(t); // Camera's local Up vector mapped to world space const upX = -Math.sin(p) * Math.sin(t); const upY = Math.cos(p); const upZ = -Math.sin(p) * Math.cos(t); // Scale pan speed based on zoom radius so it feels consistent 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; } else if (this.dragButton === 2) { // RIGHT CLICK: Zoom this.radius += dy * 0.05; this.radius = Math.max(1, Math.min(50, this.radius)); } this.onChange(); }); this.element.addEventListener('wheel', (e) => { e.preventDefault(); this.radius += e.deltaY * 0.01; this.radius = Math.max(1, Math.min(50, this.radius)); this.onChange(); }); } updateMatrices(aspectRatio) { 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); // Calculate dynamic Up vector to prevent gimbal lock at exactly +/- 90 degrees 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); // Pass the dynamic Up vector to the LookAt matrix 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; } }