hyperspace-jam / ShapeManager.js
Solshine's picture
Upload folder using huggingface_hub
18ce6eb verified
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);
}
}