import * as THREE from 'three/webgpu'; import { Panel, Readouts, arcSamples, canvasSphere, createStage, fatal, studioLights } from './kit'; import type { SurfaceSample } from '../../src/modes/mode'; /** * Demo — "pointer events are not a path". * * White dots are the raw samples the browser gave us: they bunch up where the hand slowed * down and stretch out where it swept. Violet dots are what the modes actually consume — * the same stroke resampled at a fixed step, each one carrying a tangent frame. */ const RADIUS = 1; interface PathPoint { pos: THREE.Vector3; normal: THREE.Vector3; side: THREE.Vector3; dist: number; } /** The resampler every ribbon-based mode runs before it builds anything. */ function buildPath(samples: SurfaceSample[], step: number): PathPoint[] { const pts: PathPoint[] = []; let travelled = 0; let next = 0; const tangent = new THREE.Vector3(); for (let i = 0; i < samples.length; i++) { if (i > 0) travelled += samples[i].local.distanceTo(samples[i - 1].local); if (travelled < next && i !== samples.length - 1) continue; next = travelled + step; const a = samples[Math.max(i - 1, 0)]; const b = samples[Math.min(i + 1, samples.length - 1)]; tangent.subVectors(b.local, a.local); if (tangent.lengthSq() < 1e-8) tangent.set(1, 0, 0); tangent.normalize(); const normal = samples[i].localNormal.clone().normalize(); const side = new THREE.Vector3().crossVectors(tangent, normal).normalize(); pts.push({ pos: samples[i].local.clone(), normal, side, dist: travelled }); } return pts; } const stage = await createStage({ cameraPos: [0, 0.15, 3.2], fov: 40, environment: true, orbit: true, }).catch((err) => { fatal(err); return null; }); if (stage) { const { scene } = stage; studioLights(scene); scene.add(canvasSphere(RADIUS, 96)); // `uneven` bunches samples at the ends and stretches them through the middle — what a // real hand does, and what makes raw pointer data useless for spacing anything. const samples = arcSamples({ radius: RADIUS, from: new THREE.Vector3(-0.85, -0.35, 0.85), to: new THREE.Vector3(0.9, 0.55, 0.75), count: 46, wobble: 0.1, wobbleFreq: 1.8, uneven: true, }); // ---------- raw samples ---------- // The two sets are offset to opposite sides of the stroke. Drawn on top of each other you // just get one violet smear and the whole point of the demo is lost. const LANE = 0.075; /** The across direction at a sample, so each lane can be pushed off the path. */ function sideAt(i: number): THREE.Vector3 { const a = samples[Math.max(i - 1, 0)].local; const b = samples[Math.min(i + 1, samples.length - 1)].local; const tangent = new THREE.Vector3().subVectors(b, a); if (tangent.lengthSq() < 1e-8) tangent.set(1, 0, 0); return tangent.normalize().cross(samples[i].localNormal).normalize(); } const rawMat = new THREE.MeshBasicMaterial({ color: 0xf2f4fa, toneMapped: false }); const rawDots = new THREE.InstancedMesh(new THREE.SphereGeometry(0.017, 10, 8), rawMat, samples.length); rawDots.frustumCulled = false; { const m = new THREE.Matrix4(); const q = new THREE.Quaternion(); const s = new THREE.Vector3(1, 1, 1); const p = new THREE.Vector3(); for (let i = 0; i < samples.length; i++) { p.copy(samples[i].local).multiplyScalar(1.014).addScaledVector(sideAt(i), -LANE); rawDots.setMatrixAt(i, m.compose(p, q, s)); } rawDots.instanceMatrix.needsUpdate = true; } scene.add(rawDots); // ---------- resampled path ---------- const MAX_POINTS = 400; const pathMat = new THREE.MeshBasicMaterial({ color: 0xc9a4ff, toneMapped: false }); const pathDots = new THREE.InstancedMesh(new THREE.SphereGeometry(0.021, 12, 8), pathMat, MAX_POINTS); pathDots.frustumCulled = false; pathDots.count = 0; scene.add(pathDots); const frameGeo = new THREE.BufferGeometry(); frameGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(MAX_POINTS * 6), 3)); const frames = new THREE.LineSegments( frameGeo, new THREE.LineBasicMaterial({ color: 0x5ad6ff, toneMapped: false, transparent: true, opacity: 0.9 }), ); frames.frustumCulled = false; scene.add(frames); let step = 0.045; let showFrames = true; const out = new Readouts(); const readRaw = out.add('Raw samples', String(samples.length)); const readRawGap = out.add('Raw spacing', '—', 'bad'); const readPath = out.add('Resampled points', '—', 'hi'); const readPathGap = out.add('Resampled spacing', '—', 'good'); function rebuild(): void { const path = buildPath(samples, step); const n = Math.min(path.length, MAX_POINTS); const m = new THREE.Matrix4(); const q = new THREE.Quaternion(); const s = new THREE.Vector3(1, 1, 1); const p = new THREE.Vector3(); for (let i = 0; i < n; i++) { p.copy(path[i].pos).multiplyScalar(1.014).addScaledVector(path[i].side, LANE); pathDots.setMatrixAt(i, m.compose(p, q, s)); } pathDots.count = n; pathDots.instanceMatrix.needsUpdate = true; // One tick per point, laid along `side` — the direction ribbons expand into. const attr = frameGeo.getAttribute('position') as THREE.BufferAttribute; const arr = attr.array as Float32Array; arr.fill(0); for (let i = 0; i < n; i++) { const c = path[i].pos.clone().multiplyScalar(1.014).addScaledVector(path[i].side, LANE); const a = c.clone().addScaledVector(path[i].side, -0.055); const b = c.clone().addScaledVector(path[i].side, 0.055); arr.set([a.x, a.y, a.z, b.x, b.y, b.z], i * 6); } attr.needsUpdate = true; frameGeo.setDrawRange(0, showFrames ? n * 2 : 0); readPath(String(n)); readPathGap(`${step.toFixed(3)} (fixed)`); } { let min = Infinity; let max = 0; for (let i = 1; i < samples.length; i++) { const d = samples[i].local.distanceTo(samples[i - 1].local); min = Math.min(min, d); max = Math.max(max, d); } readRaw(String(samples.length)); readRawGap(`${min.toFixed(3)} → ${max.toFixed(3)}`); } const ui = new Panel('Resampling'); ui.slider({ label: 'Step (world units)', value: step, min: 0.015, max: 0.14, step: 0.005, format: (v) => v.toFixed(3), onChange: (v) => { step = v; rebuild(); }, }); ui.check({ label: 'Raw samples (white)', value: true, onChange: (v) => { rawDots.visible = v; }, }); ui.check({ label: 'Resampled path (violet)', value: true, onChange: (v) => { pathDots.visible = v; }, }); ui.check({ label: 'Show tangent frames', value: true, onChange: (v) => { showFrames = v; rebuild(); }, }); ui.note( 'Crystals scatter one cluster per 0.0625 units, fissures step the crack every ' + '0.025. Neither can be spaced off raw events.', ); rebuild(); }