diff --git a/ui/studio/src/components/MatrixScene.tsx b/ui/studio/src/components/MatrixScene.tsx index 6ab8abe..6d94d69 100644 --- a/ui/studio/src/components/MatrixScene.tsx +++ b/ui/studio/src/components/MatrixScene.tsx @@ -6,9 +6,10 @@ import * as THREE from 'three'; import { AlphaRig } from './AlphaRig'; import { TelemetryMonitor } from './TelemetryMonitor'; import { useIntentPulse } from '../lib/useIntentPulse'; +import { RotaryDial } from './RotaryDial'; export function MatrixScene() { - const coreRef = useRef(null); + const coreRef = useRef(null); const pulse = useIntentPulse(); const lastPulseId = useRef(0); const kick = useRef(0); // decays 1->0, scales the core on each new intent @@ -23,20 +24,16 @@ export function MatrixScene() { // keep requesting frames while the kick decays (no-op under the default "always"). if (kick.current > 0) state.invalidate(); if (coreRef.current) { - coreRef.current.rotation.x += delta * 0.2; - coreRef.current.rotation.y += delta * 0.3; - const s = 1 + kick.current * 0.4; // visible pop on new intent - coreRef.current.scale.setScalar(s); + coreRef.current.scale.setScalar(1 + kick.current * 0.4); } }); return ( - {/* The Central Rotary Matrix Core */} - - - - + {/* The Central Rotary Slider Dial (instanced, O(1)) */} + + + {/* Embedded Svelte Telemetry (Phase 3) */} diff --git a/ui/studio/src/components/RotaryDial.tsx b/ui/studio/src/components/RotaryDial.tsx new file mode 100644 index 0000000..e3fe43a --- /dev/null +++ b/ui/studio/src/components/RotaryDial.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useLayoutEffect, useMemo, useRef } from 'react'; +import { useFrame } from '@react-three/fiber'; +import * as THREE from 'three'; +import { computeDialInstances } from '../lib/dialInstances'; + +const TICK_COUNT = 48; +const RADIUS = 3; +const HUB_SEGMENTS = 48; + +export function RotaryDial() { + const meshRef = useRef(null); + const groupRef = useRef(null); + const transforms = useMemo(() => computeDialInstances(TICK_COUNT, RADIUS), []); + const dummy = useMemo(() => new THREE.Object3D(), []); + + // O(1) per-frame cost: instance matrices are set once; only the group rotates. + useLayoutEffect(() => { + if (!meshRef.current) return; + transforms.forEach((t, i) => { + dummy.position.set(t.position[0], t.position[1], t.position[2]); + dummy.rotation.set(0, 0, t.rotationZ); + dummy.updateMatrix(); + meshRef.current!.setMatrixAt(i, dummy.matrix); + }); + meshRef.current.instanceMatrix.needsUpdate = true; + }, [transforms, dummy]); + + useFrame((_, delta) => { + if (groupRef.current) groupRef.current.rotation.z -= delta * 0.15; + }); + + return ( + + + + {/* toneMapped={false} keeps the emissive glow at full brightness — deliberate, since the pipeline has no bloom/postprocessing. */} + + + {/* glowing hub */} + + + + + + ); +} diff --git a/ui/studio/src/lib/__tests__/dialInstances.test.ts b/ui/studio/src/lib/__tests__/dialInstances.test.ts new file mode 100644 index 0000000..16925a0 --- /dev/null +++ b/ui/studio/src/lib/__tests__/dialInstances.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { computeDialInstances } from '../dialInstances'; + +describe('computeDialInstances', () => { + it('returns an empty array for non-positive counts', () => { + expect(computeDialInstances(0, 3)).toEqual([]); + expect(computeDialInstances(-5, 3)).toEqual([]); + }); + + it('produces `count` evenly-spaced ticks around a ring of the given radius', () => { + const ticks = computeDialInstances(4, 2); + expect(ticks).toHaveLength(4); + expect(ticks[0].position[0]).toBeCloseTo(2, 5); + expect(ticks[0].position[1]).toBeCloseTo(0, 5); + expect(ticks[0].rotationZ).toBeCloseTo(0, 5); + expect(ticks[1].position[0]).toBeCloseTo(0, 5); + expect(ticks[1].position[1]).toBeCloseTo(2, 5); + expect(ticks[1].rotationZ).toBeCloseTo(Math.PI / 2, 5); + }); + + it('keeps every tick on the ring (radius invariant)', () => { + for (const t of computeDialInstances(16, 3)) { + const r = Math.hypot(t.position[0], t.position[1]); + expect(r).toBeCloseTo(3, 5); + } + }); + + it('places the final tick without duplicating theta=2pi (uses i/count, not i/(count-1))', () => { + const ticks = computeDialInstances(4, 2); + expect(ticks[3].position[0]).toBeCloseTo(0, 5); + expect(ticks[3].position[1]).toBeCloseTo(-2, 5); + expect(ticks[3].rotationZ).toBeCloseTo((3 * Math.PI) / 2, 5); + }); + + it('handles count = 1 (single tick on the +X axis)', () => { + const ticks = computeDialInstances(1, 3); + expect(ticks).toHaveLength(1); + expect(ticks[0].position[0]).toBeCloseTo(3, 5); + expect(ticks[0].position[1]).toBeCloseTo(0, 5); + expect(ticks[0].rotationZ).toBeCloseTo(0, 5); + }); +}); diff --git a/ui/studio/src/lib/dialInstances.ts b/ui/studio/src/lib/dialInstances.ts new file mode 100644 index 0000000..a740002 --- /dev/null +++ b/ui/studio/src/lib/dialInstances.ts @@ -0,0 +1,19 @@ +// Pure geometry for the instanced rotary dial. No three/react imports so it runs +// in plain node under Vitest. The R3F component turns these into instance matrices. +export interface InstanceTransform { + position: [number, number, number]; + rotationZ: number; +} + +export function computeDialInstances(count: number, radius: number): InstanceTransform[] { + if (count <= 0) return []; + const out: InstanceTransform[] = []; + for (let i = 0; i < count; i += 1) { + const theta = (i / count) * Math.PI * 2; + out.push({ + position: [Math.cos(theta) * radius, Math.sin(theta) * radius, 0], + rotationZ: theta, + }); + } + return out; +}