import { cameraBasis, add, mul, clamp, clampZoom } from "./math.js"; export class Input { constructor(viewport, host) { this.viewport = viewport; this.host = host; this.pointers = new Map(); this.active = null; this.ending = false; viewport.addEventListener("pointerdown", (e) => this.down(e)); viewport.addEventListener("pointermove", (e) => this.move(e)); viewport.addEventListener("pointerup", (e) => this.up(e)); viewport.addEventListener("pointercancel", (e) => { const t = this.transaction; if ( this.pointers.has(e.pointerId) || t?.pendingDown?.pointerId === e.pointerId || (t && !t.canceling && t.active.id === e.pointerId) ) this.cancel(); }); viewport.addEventListener("lostpointercapture", (e) => { if ( this.pointers.has(e.pointerId) || this.transaction?.pendingDown?.pointerId === e.pointerId ) this.cancel(); }); viewport.addEventListener("contextmenu", (e) => e.preventDefault()); viewport.addEventListener( "wheel", (e) => { e.preventDefault(); if (this.active || !host.ready()) return; host.camera.zoom = clampZoom( host.camera.zoom * Math.exp(e.deltaY * 0.001), viewport.getBoundingClientRect(), ); host.invalidate(); }, { passive: false }, ); viewport.addEventListener("pointerleave", () => { if (!this.active) host.cursor(null); }); window.addEventListener("blur", () => this.cancel()); } get busy() { return !!this.active || !!this.transaction; } rejectsTouch(e) { return ( e.pointerType === "touch" && (this.host.palmRejection() || this.active?.type === "pen") ); } checkViewport(rect = this.viewport.getBoundingClientRect()) { const owner = this.transaction?.pendingDown ?? this.active; if ( rect.width <= 0 || rect.height <= 0 || (owner && (owner.canvas !== this.viewport.querySelector("canvas") || ["left", "top", "width", "height"].some( (key) => rect[key] !== owner.rect[key], ))) ) { if (owner) { this.cancel(); this.host.message( "Viewport changed ยท current stroke canceled; completed work preserved", ); } return false; } return true; } sample(e, r = this.active?.rect ?? this.viewport.getBoundingClientRect()) { return { x: e.clientX - r.left, y: e.clientY - r.top, pressure: e.pointerType === "pen" ? e.pressure : 1, }; } world( sample, r = this.active?.rect ?? this.viewport.getBoundingClientRect(), ) { const c = this.host.camera, b = cameraBasis(c.yaw, c.pitch); return add( c.target, add( mul( b.right, (((sample.x / r.width) * 2 - 1) * c.zoom * r.width) / r.height, ), mul(b.up, (1 - (sample.y / r.height) * 2) * c.zoom), ), ); } pick(sample, rect) { const b = cameraBasis(this.host.camera.yaw, this.host.camera.pitch); const hit = this.host .engine() .bvh.raycast( add(this.world(sample, rect), mul(b.forward, 24)), mul(b.forward, -1), ); if (!hit) return null; const engine = this.host.engine(); const piece = engine.mesh.pieces?.find((p) => p.id === engine.activeId); return piece && hit.face * 3 >= piece.indexStart && hit.face * 3 < piece.indexStart + piece.indexCount ? hit : null; } down(e) { if (this.rejectsTouch(e)) return; const canvas = this.viewport.querySelector("canvas"); if (e.target !== canvas) return; if (!this.checkViewport()) return; if (!this.host.ready() || this.ending) return; if (this.pointers.has(e.pointerId) || this.pointers.size >= 2) return; e.preventDefault(); this.viewport.focus({ preventScroll: true }); this.viewport.setPointerCapture(e.pointerId); const rect = this.viewport.getBoundingClientRect(); if (e.pointerType === "pen" && this.active?.type === "touch") { this.cancel(); if (this.transaction) { this.transaction.pendingDown = { pointerId: e.pointerId, pointerType: e.pointerType, clientX: e.clientX, clientY: e.clientY, pressure: e.pressure, button: e.button, shiftKey: e.shiftKey, altKey: e.altKey, rect, canvas, }; return; } } this.start(e, rect, canvas); } start(e, rect, canvas) { const sample = this.sample(e, rect); this.pointers.set(e.pointerId, sample); if (this.pointers.size > 1) { if (this.active?.mode === "sculpt") { this.cancelStroke(); } this.active = { mode: "gesture", id: e.pointerId, type: "touch", rect, canvas, }; this.gesture = this.touchPair(); return; } const action = e.button === 1 ? "pan" : e.button === 2 ? "rotate" : this.host.navigation(); const navigate = action !== "sculpt"; const hit = navigate ? null : this.pick(sample, rect); this.active = { id: e.pointerId, type: e.pointerType, mode: navigate || !hit ? "orbit" : "sculpt", action, start: sample, last: sample, hit, rect, canvas, }; if (this.active.mode === "sculpt") { const settings = { ...this.host.settings, activeId: this.host.engine().activeId, }; if (e.shiftKey) settings.brush = "smooth"; if (e.altKey) settings.invert = !settings.invert; const engine = this.host.engine(), client = this.host.processor(); engine.begin(settings); const transaction = { engine, client, active: this.active, canceling: false, }; this.transaction = transaction; client .begin( { positions: engine.mesh.positions.slice(), masks: engine.mesh.masks.slice(), activeId: engine.activeId, settings, camera: structuredClone(this.host.camera), rect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height, }, hit, sample, }, (packet) => { this.checkViewport(); if (this.transaction !== transaction || transaction.canceling) return; const { vertices, masks, rows, touched } = packet; if (packet.type === "mask") { rows.forEach((v, i) => { engine.mesh.masks[v] = masks[i]; engine.dirty.add(v); }); for (const v of touched) engine.stroke.touched.add(v); engine.revision++; this.host.invalidate(); return; } for (let row = 0; row < rows.length; row++) { const v = rows[row], i = row * 8, j = v * 3, p = engine.mesh.positions, n = engine.mesh.normals; p[j] = vertices[i]; p[j + 1] = vertices[i + 1]; p[j + 2] = vertices[i + 2]; n[j] = vertices[i + 4]; n[j + 1] = vertices[i + 5]; n[j + 2] = vertices[i + 6]; engine.dirty.add(v); } for (const v of touched) engine.stroke.touched.add(v); engine.revision++; this.host.invalidate(); }, ) .then( (result) => this.settle(transaction, result), () => { // StrokeClient reports worker failure once through the renderer's // error handler; this continuation only settles document ownership. this.settle(transaction, { canceled: true }); }, ); } this.host.changed(); } touchPair() { const [a, b] = [...this.pointers.values()]; return a && b ? { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, distance: Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)), } : null; } pan(dx, dy) { const c = this.host.camera, b = cameraBasis(c.yaw, c.pitch), scale = (2 * c.zoom) / this.active.rect.height; c.target = add( c.target, add(mul(b.right, -dx * scale), mul(b.up, dy * scale)), ); } move(e) { if (this.host.blocked?.()) return; if (this.rejectsTouch(e)) return; if (!this.checkViewport()) return; if (!this.active && e.target.tagName !== "CANVAS") { this.host.cursor(null); return; } const sample = this.sample(e); this.host.cursor(sample); this.host.pressure(e.pointerType === "pen" ? sample.pressure : null); if (!this.active || (this.ending && this.active.mode === "sculpt")) return; if (!this.pointers.has(e.pointerId)) return; this.pointers.set(e.pointerId, sample); if (this.active.mode === "gesture") { const next = this.touchPair(); if (next && this.gesture) { this.pan(next.x - this.gesture.x, next.y - this.gesture.y); this.host.camera.zoom = clampZoom( (this.host.camera.zoom * this.gesture.distance) / next.distance, this.active.rect, ); this.host.invalidate(); } this.gesture = next; return; } if (e.pointerId !== this.active.id) return; if (this.active.mode === "sculpt") { const samples = e.getCoalescedEvents?.() ?? []; // Only Grab needs to observe the first effective pressure in the batch; // writes overwrite a single slot, never enqueue samples. if (this.host.engine().stroke.settings.brush === "grab") for (const event of samples.length ? samples : [e]) this.transaction.client.write(this.sample(event)); else this.transaction.client.write(this.sample(samples.at(-1) ?? e)); } else { const dx = sample.x - this.active.last.x, dy = sample.y - this.active.last.y; if (this.active.action === "pan") this.pan(dx, dy); else if (this.active.action === "zoom") this.host.camera.zoom = clampZoom( this.host.camera.zoom * Math.exp(dy * 0.008), this.active.rect, ); else { this.host.camera.yaw -= dx * 0.008; this.host.camera.pitch = clamp( this.host.camera.pitch + dy * 0.008, -Math.PI / 2, Math.PI / 2, ); } this.host.invalidate(); } if (this.active) this.active.last = sample; } up(e) { if (e.pointerType === "touch" && this.host.palmRejection()) return; if (this.transaction?.pendingDown?.pointerId === e.pointerId) { this.transaction.pendingDown = null; this.release(e.pointerId); return; } if (!this.checkViewport()) return; this.pointers.delete(e.pointerId); this.release(e.pointerId); if (!this.active) return; if (this.active.mode === "gesture") { if (!this.pointers.size) { this.active = null; this.host.changed(); } return; } if (e.pointerId !== this.active.id) return; if (this.active.mode === "sculpt") { this.ending = true; this.transaction.client.end(); } else { this.active = null; this.host.changed(); } } settle(transaction, result) { if (this.transaction !== transaction) return; // Resize may precede the observer callback or this completion message. this.checkViewport(); const engine = transaction.engine; if (result.canceled || transaction.canceling) { engine.end(true); if (!transaction.canceling) { const ids = [...this.pointers.keys()]; this.pointers.clear(); for (const id of ids) this.release(id); } this.host.invalidate(); } else { // Repair main's face-normal cache and picking BVH only at the boundary. if (engine.stroke.settings.brush !== "mask") engine.changed([...engine.stroke.touched]); engine.end(); this.host.committed(); } const pending = transaction.pendingDown; this.transaction = null; if (this.active === transaction.active) this.active = null; this.ending = false; if (result.message) this.host.message(result.message); if (pending) { if ( this.host.ready() && this.host.engine() === engine && this.host.processor() === transaction.client && this.viewport.hasPointerCapture(pending.pointerId) ) this.start(pending, pending.rect, pending.canvas); else this.release(pending.pointerId); } this.host.changed(); } cancelStroke() { const transaction = this.transaction; if (!transaction || transaction.canceling) return; transaction.canceling = true; this.ending = true; transaction.client.cancel(); transaction.engine.end(true); this.host.invalidate(); } cancel() { const ids = [...this.pointers.keys()]; if (this.transaction?.pendingDown) { ids.push(this.transaction.pendingDown.pointerId); this.transaction.pendingDown = null; } this.cancelStroke(); this.pointers.clear(); this.active = null; for (const id of ids) this.release(id); this.host.changed(); } release(id) { if (this.viewport.hasPointerCapture(id)) this.viewport.releasePointerCapture(id); } }