// diorama.js import {mat4Ortho, mat4LookAt, mat4Multiply} from './math.js'; export class Diorama { constructor(elementId, world, requestRenderCallback) { this.element = document.getElementById(elementId); this.world = world; this.requestRender = requestRenderCallback; // View State this.target = [0.5, 0, 0.5]; this.radius = 4; this.theta = 135; this.phi = 30; // Matrices this.projMatrix = new Float32Array(16); this.viewMatrix = new Float32Array(16); this.viewProjMatrix = new Float32Array(16); // Interaction State this.isDragging = false; this.dragButton = 0; this.touchMode = ''; this.lastMouse = {x: 0, y: 0}; this.lastPinchDist = 0; this.checkpoint = null; this.initUI(); this.attachEvents(); } initUI() { this.element.style.position = "relative"; this.resetBtn = document.createElement('button'); this.resetBtn.textContent = 'Reset View'; this.resetBtn.style.cssText = ` position: absolute; top: 1ch; right: 1ch; padding: 0.5ch 1ch; background: rgba(0,0,0,0.7); color: white; border: 1px solid gray; cursor: pointer; display: none; z-index: 10; `; this.resetBtn.addEventListener('mousedown', e => e.stopPropagation()); this.resetBtn.addEventListener('touchstart', e => e.stopPropagation()); this.resetBtn.addEventListener('click', () => { this.loadState(); this.resetBtn.style.display = 'none'; }); this.element.appendChild(this.resetBtn); } 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.requestRender(); } centerView() { if (this.world.blocks.size === 0) { this.target = [0.5, 0.5, 0.5]; return; } let minX = Infinity, minY = Infinity, minZ = Infinity; let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; for (const block of this.world.blocks.values()) { minX = Math.min(minX, block.x); minY = Math.min(minY, block.y); minZ = Math.min(minZ, block.z); maxX = Math.max(maxX, block.x + 1); maxY = Math.max(maxY, block.y + 1); maxZ = Math.max(maxZ, block.z + 1); } this.target = [(minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2]; this.requestRender(); } updateMatrices(aspectRatio) { const t = this.theta * Math.PI / 180; const p = this.phi * Math.PI / 180; this.viewDir = [ Math.cos(p) * Math.sin(t), Math.sin(p), Math.cos(p) * Math.cos(t) ]; this.upDir = [ -Math.sin(p) * Math.sin(t), Math.cos(p), -Math.sin(p) * Math.cos(t), ]; this.eyePos = [ this.target[0] + this.radius * this.viewDir[0], this.target[1] + this.radius * this.viewDir[1], this.target[2] + this.radius * this.viewDir[2], ]; const size = this.radius * 0.5; mat4Ortho(this.projMatrix, -size * aspectRatio, size * aspectRatio, -size, size, -50, 50); mat4LookAt( this.viewMatrix, this.eyePos[0], this.eyePos[1], this.eyePos[2], this.target[0], this.target[1], this.target[2], this.upDir[0], this.upDir[1], this.upDir[2], ); mat4Multiply(this.viewProjMatrix, this.projMatrix, this.viewMatrix); return this.viewProjMatrix; } // --- 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()); const notifyChange = () => { if (this.checkpoint) this.resetBtn.style.display = 'block'; this.requestRender(); }; // --- MOUSE EVENTS --- this.element.addEventListener('mousedown', (e) => { e.preventDefault(); this.isDragging = true; this.lastMouse = {x: e.clientX, y: e.clientY}; this.dragButton = e.button; if (this.dragButton === 0) { if (e.ctrlKey || e.metaKey) this.dragButton = 2; else if (e.shiftKey) this.dragButton = 1; } 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); notifyChange(); }); // --- TOUCH EVENTS --- this.element.addEventListener('touchstart', (e) => { e.preventDefault(); 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); notifyChange(); } 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; this.lastMouse = {x: cx, y: cy}; this.lastPinchDist = dist; this.pan(dx, dy); this.zoom(-dDist); notifyChange(); } }, {passive: false}); const onTouchEnd = (e) => { if (e.touches.length === 0) { this.isDragging = false; } else if (e.touches.length === 1) { 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); } }