From d9cc88769fb2b8860db8529d62e7385eddb03e58 Mon Sep 17 00:00:00 2001 From: ZuluYokohama Date: Sat, 6 Jun 2026 01:18:10 -0500 Subject: [PATCH 1/3] feat(studio): add tested rotary-dial instance geometry --- .../src/lib/__tests__/dialInstances.test.ts | 27 +++++++++++++++++++ ui/studio/src/lib/dialInstances.ts | 21 +++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 ui/studio/src/lib/__tests__/dialInstances.test.ts create mode 100644 ui/studio/src/lib/dialInstances.ts 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..335f274 --- /dev/null +++ b/ui/studio/src/lib/__tests__/dialInstances.test.ts @@ -0,0 +1,27 @@ +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); + } + }); +}); diff --git a/ui/studio/src/lib/dialInstances.ts b/ui/studio/src/lib/dialInstances.ts new file mode 100644 index 0000000..75159df --- /dev/null +++ b/ui/studio/src/lib/dialInstances.ts @@ -0,0 +1,21 @@ +// 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; + scale: 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, + scale: 1, + }); + } + return out; +} From 79e3615197b68642bb5b8334c60c710c727a4030 Mon Sep 17 00:00:00 2001 From: ZuluYokohama Date: Sat, 6 Jun 2026 01:19:29 -0500 Subject: [PATCH 2/3] feat(studio): render glowing RotarySlider dial via instanced mesh (O(1)) --- ui/studio/src/components/MatrixScene.tsx | 17 ++++---- ui/studio/src/components/RotaryDial.tsx | 52 ++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 ui/studio/src/components/RotaryDial.tsx 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..67b99b6 --- /dev/null +++ b/ui/studio/src/components/RotaryDial.tsx @@ -0,0 +1,52 @@ +"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; + +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.scale.setScalar(t.scale); + 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 ( + + + + + + {/* glowing hub */} + + + + + + ); +} From e8f88126ea0e10d19d586deb521ae53cc4fd07e3 Mon Sep 17 00:00:00 2001 From: ZuluYokohama Date: Sat, 6 Jun 2026 01:25:33 -0500 Subject: [PATCH 3/3] refactor(studio): drop unused dial scale field; name hub segments; add ring edge-case tests - remove speculative InstanceTransform.scale (YAGNI; dummy default scale=1 is identical) - name HUB_SEGMENTS so its value isn't read as coupled to TICK_COUNT - document the deliberate toneMapped={false} brightness choice - test final-tick wrap-around (i/count off-by-one guard) and count=1 boundary Co-Authored-By: Claude Opus 4.8 (1M context) --- ui/studio/src/components/RotaryDial.tsx | 5 +++-- ui/studio/src/lib/__tests__/dialInstances.test.ts | 15 +++++++++++++++ ui/studio/src/lib/dialInstances.ts | 2 -- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/ui/studio/src/components/RotaryDial.tsx b/ui/studio/src/components/RotaryDial.tsx index 67b99b6..e3fe43a 100644 --- a/ui/studio/src/components/RotaryDial.tsx +++ b/ui/studio/src/components/RotaryDial.tsx @@ -7,6 +7,7 @@ import { computeDialInstances } from '../lib/dialInstances'; const TICK_COUNT = 48; const RADIUS = 3; +const HUB_SEGMENTS = 48; export function RotaryDial() { const meshRef = useRef(null); @@ -20,7 +21,6 @@ export function RotaryDial() { transforms.forEach((t, i) => { dummy.position.set(t.position[0], t.position[1], t.position[2]); dummy.rotation.set(0, 0, t.rotationZ); - dummy.scale.setScalar(t.scale); dummy.updateMatrix(); meshRef.current!.setMatrixAt(i, dummy.matrix); }); @@ -35,6 +35,7 @@ export function RotaryDial() { + {/* 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 index 335f274..16925a0 100644 --- a/ui/studio/src/lib/__tests__/dialInstances.test.ts +++ b/ui/studio/src/lib/__tests__/dialInstances.test.ts @@ -24,4 +24,19 @@ describe('computeDialInstances', () => { 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 index 75159df..a740002 100644 --- a/ui/studio/src/lib/dialInstances.ts +++ b/ui/studio/src/lib/dialInstances.ts @@ -3,7 +3,6 @@ export interface InstanceTransform { position: [number, number, number]; rotationZ: number; - scale: number; } export function computeDialInstances(count: number, radius: number): InstanceTransform[] { @@ -14,7 +13,6 @@ export function computeDialInstances(count: number, radius: number): InstanceTra out.push({ position: [Math.cos(theta) * radius, Math.sin(theta) * radius, 0], rotationZ: theta, - scale: 1, }); } return out;