Spaces:
Running
Running
| import * as THREE from 'three'; | |
| import { createTessellationMaterial } from './ShapeTessellationShader.js'; | |
| export class ShapeManager { | |
| constructor(scene) { | |
| this.scene = scene; | |
| this.group = new THREE.Group(); | |
| this.group.renderOrder = 1.5; | |
| this.scene.add(this.group); | |
| // Anchor tracking | |
| this.anchors = []; | |
| this.fadingAnchors = []; | |
| // Internal tessellation | |
| this.tessellationMesh = null; | |
| this.tessellationMaterial = null; | |
| this.shapeFormedTime = 0; | |
| this.lastShapeType = 'none'; | |
| this.tessellationActive = false; | |
| this.PINCH_THRESHOLD = 30; // pixels screen space | |
| this.SMOOTH_FACTOR = 0.3; | |
| this.FADE_DURATION = 150; // ms | |
| // --- Pre-allocate node pool (6 nodes for active + 6 for fading) --- | |
| this.nodePool = []; | |
| for (let i = 0; i < 12; i++) { | |
| const squareGeom = new THREE.PlaneGeometry(20, 20); | |
| const squareMat = new THREE.MeshBasicMaterial({ | |
| color: 0xffffff, | |
| transparent: true, | |
| opacity: 1, | |
| depthTest: false, | |
| side: THREE.DoubleSide | |
| }); | |
| const squareMesh = new THREE.Mesh(squareGeom, squareMat); | |
| squareMesh.rotation.z = Math.PI / 4; | |
| squareMesh.visible = false; | |
| const dotGeom = new THREE.CircleGeometry(4, 16); | |
| const dotMat = new THREE.MeshBasicMaterial({ | |
| color: 0xffffff, | |
| transparent: true, | |
| opacity: 1, | |
| depthTest: false, | |
| side: THREE.DoubleSide | |
| }); | |
| const dotMesh = new THREE.Mesh(dotGeom, dotMat); | |
| dotMesh.visible = false; | |
| this.group.add(squareMesh); | |
| this.group.add(dotMesh); | |
| this.nodePool.push({ square: squareMesh, dot: dotMesh }); | |
| } | |
| // --- Pre-allocate edge pool (12 edges) --- | |
| this.edgePool = []; | |
| for (let i = 0; i < 12; i++) { | |
| const positions = new Float32Array(4 * 3); // 4 vertices × 3 coords | |
| const indices = new Uint16Array([0, 1, 2, 0, 2, 3]); | |
| const geom = new THREE.BufferGeometry(); | |
| geom.setAttribute('position', new THREE.BufferAttribute(positions, 3)); | |
| geom.setIndex(new THREE.BufferAttribute(indices, 1)); | |
| const mat = new THREE.MeshBasicMaterial({ | |
| color: 0xffffff, | |
| transparent: true, | |
| opacity: 1, | |
| depthTest: false, | |
| side: THREE.DoubleSide | |
| }); | |
| const mesh = new THREE.Mesh(geom, mat); | |
| mesh.visible = false; | |
| this.group.add(mesh); | |
| this.edgePool.push(mesh); | |
| } | |
| } | |
| update(handsData, canvasWidth, canvasHeight) { | |
| const now = performance.now(); | |
| // --- Hide all pooled meshes --- | |
| for (const node of this.nodePool) { | |
| node.square.visible = false; | |
| node.dot.visible = false; | |
| } | |
| for (const edge of this.edgePool) { | |
| edge.visible = false; | |
| } | |
| if (!handsData) handsData = []; | |
| // --- Detect anchors from each hand --- | |
| const rawAnchors = []; | |
| for (let h = 0; h < handsData.length; h++) { | |
| const points3D = handsData[h]; | |
| if (!points3D || points3D.length < 21) continue; | |
| const thumbTip = points3D[4]; | |
| const indexTip = points3D[8]; | |
| const middleTip = points3D[12]; | |
| const middleDip = points3D[10]; // for extension check | |
| // Compute average z across all landmarks for depth | |
| let avgZ = 0; | |
| for (let l = 0; l < 21; l++) { | |
| avgZ += points3D[l].z; | |
| } | |
| avgZ /= 21; | |
| // Distance between thumb and index | |
| const dx = thumbTip.x - indexTip.x; | |
| const dy = thumbTip.y - indexTip.y; | |
| const thumbIndexDist = Math.sqrt(dx * dx + dy * dy); | |
| // Check if middle finger is extended (tip significantly above dip in y) | |
| const middleExtended = (middleDip.y - middleTip.y) > 15; | |
| if (thumbIndexDist < this.PINCH_THRESHOLD) { | |
| // PINCHING — thumb+index merge into 1 anchor at midpoint | |
| rawAnchors.push({ | |
| position: new THREE.Vector3( | |
| (thumbTip.x + indexTip.x) / 2, | |
| (thumbTip.y + indexTip.y) / 2, | |
| 0 | |
| ), | |
| id: `h${h}_pinch`, | |
| avgZ | |
| }); | |
| // If middle finger extended while pinching, add it as a 2nd anchor | |
| if (middleExtended) { | |
| rawAnchors.push({ | |
| position: new THREE.Vector3(middleTip.x, middleTip.y, 0), | |
| id: `h${h}_middle`, | |
| avgZ | |
| }); | |
| } | |
| } else { | |
| // NOT PINCHING — thumb and index are separate anchors | |
| rawAnchors.push({ | |
| position: new THREE.Vector3(thumbTip.x, thumbTip.y, 0), | |
| id: `h${h}_thumb`, | |
| avgZ | |
| }); | |
| rawAnchors.push({ | |
| position: new THREE.Vector3(indexTip.x, indexTip.y, 0), | |
| id: `h${h}_index`, | |
| avgZ | |
| }); | |
| // If middle finger extended, add it too | |
| if (middleExtended) { | |
| rawAnchors.push({ | |
| position: new THREE.Vector3(middleTip.x, middleTip.y, 0), | |
| id: `h${h}_middle`, | |
| avgZ | |
| }); | |
| } | |
| } | |
| } | |
| // --- Match raw anchors to existing anchors by id --- | |
| const matchedIds = new Set(); | |
| const newAnchors = []; | |
| for (let r = 0; r < rawAnchors.length; r++) { | |
| const raw = rawAnchors[r]; | |
| let found = false; | |
| for (let a = 0; a < this.anchors.length; a++) { | |
| if (matchedIds.has(this.anchors[a].id)) continue; | |
| if (this.anchors[a].id === raw.id) { | |
| // Update existing anchor with smoothing | |
| const anchor = this.anchors[a]; | |
| anchor.smoothed.x += (raw.position.x - anchor.smoothed.x) * this.SMOOTH_FACTOR; | |
| anchor.smoothed.y += (raw.position.y - anchor.smoothed.y) * this.SMOOTH_FACTOR; | |
| anchor.depthScale = this._computeDepthScale(raw.avgZ); | |
| anchor.avgZ = raw.avgZ; | |
| newAnchors.push(anchor); | |
| matchedIds.add(anchor.id); | |
| found = true; | |
| break; | |
| } | |
| } | |
| if (!found) { | |
| // New anchor — birth it | |
| newAnchors.push({ | |
| smoothed: raw.position.clone(), | |
| id: raw.id, | |
| birthTime: now, | |
| depthScale: this._computeDepthScale(raw.avgZ), | |
| avgZ: raw.avgZ | |
| }); | |
| } | |
| } | |
| // Anchors that disappeared — move to fading | |
| for (let a = 0; a < this.anchors.length; a++) { | |
| if (!matchedIds.has(this.anchors[a].id)) { | |
| const dying = this.anchors[a]; | |
| dying.fadeStartTime = now; | |
| this.fadingAnchors.push(dying); | |
| } | |
| } | |
| this.anchors = newAnchors; | |
| // --- Update fading anchors, remove expired --- | |
| this.fadingAnchors = this.fadingAnchors.filter(fa => (now - fa.fadeStartTime) < this.FADE_DURATION); | |
| // --- Determine shape type --- | |
| const activeCount = this.anchors.length; | |
| let shapeType = 'none'; | |
| if (activeCount === 2) shapeType = 'line'; | |
| else if (activeCount === 3) shapeType = 'triangle'; | |
| else if (activeCount >= 4) shapeType = 'quad'; | |
| // --- Render active anchor nodes (using pool) --- | |
| let nodeIdx = 0; | |
| for (let i = 0; i < this.anchors.length && nodeIdx < this.nodePool.length; i++, nodeIdx++) { | |
| const anc = this.anchors[i]; | |
| const age = now - anc.birthTime; | |
| const fadeIn = Math.min(1, age / this.FADE_DURATION); | |
| const scale = anc.depthScale; | |
| const node = this.nodePool[nodeIdx]; | |
| node.square.visible = true; | |
| node.square.position.set(anc.smoothed.x, anc.smoothed.y, 3); | |
| node.square.scale.set(scale, scale, 1); | |
| node.square.material.opacity = fadeIn; | |
| node.dot.visible = true; | |
| node.dot.position.set(anc.smoothed.x, anc.smoothed.y, 3.01); | |
| node.dot.scale.set(scale, scale, 1); | |
| node.dot.material.opacity = fadeIn; | |
| } | |
| // --- Render fading anchor nodes (using pool) --- | |
| for (let i = 0; i < this.fadingAnchors.length && nodeIdx < this.nodePool.length; i++, nodeIdx++) { | |
| const fa = this.fadingAnchors[i]; | |
| const fadeOut = 1 - Math.min(1, (now - fa.fadeStartTime) / this.FADE_DURATION); | |
| const scale = fa.depthScale; | |
| const node = this.nodePool[nodeIdx]; | |
| node.square.visible = true; | |
| node.square.position.set(fa.smoothed.x, fa.smoothed.y, 3); | |
| node.square.scale.set(scale, scale, 1); | |
| node.square.material.opacity = fadeOut; | |
| node.dot.visible = true; | |
| node.dot.position.set(fa.smoothed.x, fa.smoothed.y, 3.01); | |
| node.dot.scale.set(scale, scale, 1); | |
| node.dot.material.opacity = fadeOut; | |
| } | |
| // --- Render edges (using pool) --- | |
| if (shapeType !== 'none') { | |
| this._drawEdges(shapeType, now); | |
| } | |
| // --- Internal tessellation for closed shapes --- | |
| if (shapeType === 'triangle' || shapeType === 'quad') { | |
| if (shapeType !== this.lastShapeType) { | |
| this.shapeFormedTime = now; | |
| this.lastShapeType = shapeType; | |
| this.tessellationActive = false; | |
| } | |
| if (!this.tessellationActive && (now - this.shapeFormedTime) > 200) { | |
| this.tessellationActive = true; | |
| this._createTessellationMesh(canvasWidth, canvasHeight); | |
| } | |
| if (this.tessellationActive && this.tessellationMaterial) { | |
| this.tessellationMaterial.uniforms.u_time.value = now * 0.001; | |
| this.tessellationMaterial.uniforms.u_vertexCount.value = (shapeType === 'triangle') ? 3 : 4; | |
| this.tessellationMaterial.uniforms.u_resolution.value.set(canvasWidth, canvasHeight); | |
| for (let v = 0; v < 4; v++) { | |
| if (v < this.anchors.length) { | |
| this.tessellationMaterial.uniforms.u_vertices.value[v].set( | |
| this.anchors[v].smoothed.x / (canvasWidth / 2), | |
| this.anchors[v].smoothed.y / (canvasHeight / 2) | |
| ); | |
| } | |
| } | |
| } | |
| } else { | |
| if (this.tessellationActive) { | |
| this._removeTessellationMesh(); | |
| this.tessellationActive = false; | |
| } | |
| this.lastShapeType = shapeType; | |
| } | |
| } | |
| _computeDepthScale(avgZ) { | |
| // Map z from [-0.2, 0] to [3.0, 0.5] (DRAMATIC range) | |
| const t = Math.max(0, Math.min(1, (avgZ - (-0.2)) / (0 - (-0.2)))); | |
| return 3.0 + t * (0.5 - 3.0); // lerp from 3.0 (close) to 0.5 (far) | |
| } | |
| _drawEdges(shapeType, now) { | |
| const z = 3; | |
| const anchors = this.anchors; | |
| // Compute min opacity from birth fades | |
| let minOpacity = 1; | |
| for (let i = 0; i < anchors.length; i++) { | |
| const age = now - anchors[i].birthTime; | |
| const fade = Math.min(1, age / this.FADE_DURATION); | |
| if (fade < minOpacity) minOpacity = fade; | |
| } | |
| // Average depth scale for edge width | |
| let avgDepthScale = 0; | |
| for (let i = 0; i < anchors.length; i++) { | |
| avgDepthScale += anchors[i].depthScale; | |
| } | |
| avgDepthScale /= anchors.length; | |
| const edgeWidth = 4 * avgDepthScale; | |
| // Build edge pairs | |
| const edges = []; | |
| if (shapeType === 'line') { | |
| // Two parallel lines offset ±3px perpendicular | |
| const a = anchors[0].smoothed; | |
| const b = anchors[1].smoothed; | |
| const dx = b.x - a.x; | |
| const dy = b.y - a.y; | |
| const len = Math.sqrt(dx * dx + dy * dy); | |
| if (len < 0.001) return; | |
| const nx = -dy / len * 3; | |
| const ny = dx / len * 3; | |
| edges.push([ | |
| new THREE.Vector3(a.x + nx, a.y + ny, z), | |
| new THREE.Vector3(b.x + nx, b.y + ny, z) | |
| ]); | |
| edges.push([ | |
| new THREE.Vector3(a.x - nx, a.y - ny, z), | |
| new THREE.Vector3(b.x - nx, b.y - ny, z) | |
| ]); | |
| } else if (shapeType === 'triangle') { | |
| for (let i = 0; i < 3; i++) { | |
| const j = (i + 1) % 3; | |
| edges.push([ | |
| new THREE.Vector3(anchors[i].smoothed.x, anchors[i].smoothed.y, z), | |
| new THREE.Vector3(anchors[j].smoothed.x, anchors[j].smoothed.y, z) | |
| ]); | |
| } | |
| } else if (shapeType === 'quad') { | |
| const count = Math.min(anchors.length, 4); | |
| for (let i = 0; i < count; i++) { | |
| const j = (i + 1) % count; | |
| edges.push([ | |
| new THREE.Vector3(anchors[i].smoothed.x, anchors[i].smoothed.y, z), | |
| new THREE.Vector3(anchors[j].smoothed.x, anchors[j].smoothed.y, z) | |
| ]); | |
| } | |
| } | |
| // Render each edge by updating pre-allocated buffer geometry | |
| for (let e = 0; e < edges.length && e < this.edgePool.length; e++) { | |
| const pA = edges[e][0]; | |
| const pB = edges[e][1]; | |
| const dx = pB.x - pA.x; | |
| const dy = pB.y - pA.y; | |
| const len = Math.sqrt(dx * dx + dy * dy); | |
| if (len < 0.001) continue; | |
| // Perpendicular direction for width | |
| const nx = -dy / len * (edgeWidth / 2); | |
| const ny = dx / len * (edgeWidth / 2); | |
| const mesh = this.edgePool[e]; | |
| const posAttr = mesh.geometry.getAttribute('position'); | |
| const arr = posAttr.array; | |
| // 4 vertices forming a thin rectangle | |
| arr[0] = pA.x + nx; arr[1] = pA.y + ny; arr[2] = z; | |
| arr[3] = pA.x - nx; arr[4] = pA.y - ny; arr[5] = z; | |
| arr[6] = pB.x - nx; arr[7] = pB.y - ny; arr[8] = z; | |
| arr[9] = pB.x + nx; arr[10] = pB.y + ny; arr[11] = z; | |
| posAttr.needsUpdate = true; | |
| mesh.material.opacity = minOpacity; | |
| mesh.visible = true; | |
| } | |
| } | |
| getShapeState() { | |
| const count = this.anchors.length; | |
| let type = 'none'; | |
| if (count === 2) type = 'line'; | |
| else if (count === 3) type = 'triangle'; | |
| else if (count >= 4) type = 'quad'; | |
| const anchorPositions = this.anchors.map(a => a.smoothed.clone()); | |
| return { type, anchors: anchorPositions }; | |
| } | |
| getAnchors() { | |
| return this.anchors.map(a => a.smoothed.clone()); | |
| } | |
| getAverageDepth() { | |
| if (this.anchors.length === 0) return 0; | |
| let sum = 0; | |
| for (let i = 0; i < this.anchors.length; i++) { | |
| sum += this.anchors[i].avgZ; | |
| } | |
| return sum / this.anchors.length; | |
| } | |
| getShapeArea() { | |
| if (this.anchors.length < 3) return 0; | |
| const points = this.anchors.map(a => a.smoothed); | |
| let area = 0; | |
| const n = points.length; | |
| for (let i = 0; i < n; i++) { | |
| const j = (i + 1) % n; | |
| area += points[i].x * points[j].y; | |
| area -= points[j].x * points[i].y; | |
| } | |
| area = Math.abs(area) / 2; | |
| return Math.min(1, area / 200000); | |
| } | |
| _createTessellationMesh(canvasWidth, canvasHeight) { | |
| this._removeTessellationMesh(); | |
| this.tessellationMaterial = createTessellationMaterial(); | |
| this.tessellationMaterial.uniforms.u_resolution.value.set(canvasWidth, canvasHeight); | |
| const geom = new THREE.PlaneGeometry(2, 2); | |
| this.tessellationMesh = new THREE.Mesh(geom, this.tessellationMaterial); | |
| this.tessellationMesh.renderOrder = 1.3; | |
| this.scene.add(this.tessellationMesh); | |
| } | |
| _removeTessellationMesh() { | |
| if (this.tessellationMesh) { | |
| this.scene.remove(this.tessellationMesh); | |
| this.tessellationMesh.geometry.dispose(); | |
| this.tessellationMaterial.dispose(); | |
| this.tessellationMesh = null; | |
| this.tessellationMaterial = null; | |
| } | |
| } | |
| dispose() { | |
| this._removeTessellationMesh(); | |
| // Dispose all pooled geometries and materials | |
| for (const node of this.nodePool) { | |
| node.square.geometry.dispose(); | |
| node.square.material.dispose(); | |
| node.dot.geometry.dispose(); | |
| node.dot.material.dispose(); | |
| } | |
| for (const edge of this.edgePool) { | |
| edge.geometry.dispose(); | |
| edge.material.dispose(); | |
| } | |
| while (this.group.children.length) { | |
| this.group.remove(this.group.children[0]); | |
| } | |
| this.scene.remove(this.group); | |
| } | |
| } | |