Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions ui/studio/src/components/MatrixScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<THREE.Mesh>(null);
const coreRef = useRef<THREE.Group>(null);
const pulse = useIntentPulse();
const lastPulseId = useRef(0);
const kick = useRef(0); // decays 1->0, scales the core on each new intent
Expand All @@ -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 (
<group>
{/* The Central Rotary Matrix Core */}
<mesh ref={coreRef} position={[0, 0, -5]}>
<torusKnotGeometry args={[3, 0.5, 128, 32]} />
<meshStandardMaterial color="#3fb950" wireframe opacity={0.3} transparent />
</mesh>
{/* The Central Rotary Slider Dial (instanced, O(1)) */}
<group ref={coreRef} position={[0, 0, -5]}>
<RotaryDial />
</group>

{/* Embedded Svelte Telemetry (Phase 3) */}
<TelemetryMonitor />
Expand Down
53 changes: 53 additions & 0 deletions ui/studio/src/components/RotaryDial.tsx
Original file line number Diff line number Diff line change
@@ -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<THREE.InstancedMesh>(null);
const groupRef = useRef<THREE.Group>(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 (
<group ref={groupRef}>
<instancedMesh ref={meshRef} args={[undefined, undefined, TICK_COUNT]}>
<boxGeometry args={[0.12, 0.5, 0.12]} />
{/* toneMapped={false} keeps the emissive glow at full brightness — deliberate, since the pipeline has no bloom/postprocessing. */}
<meshStandardMaterial
color="#3fb950"
emissive="#3fb950"
emissiveIntensity={1.4}
toneMapped={false}
/>
</instancedMesh>
{/* glowing hub */}
<mesh>
<circleGeometry args={[0.6, HUB_SEGMENTS]} />
<meshStandardMaterial color="#58a6ff" emissive="#58a6ff" emissiveIntensity={1.2} toneMapped={false} />
</mesh>
</group>
);
}
42 changes: 42 additions & 0 deletions ui/studio/src/lib/__tests__/dialInstances.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
19 changes: 19 additions & 0 deletions ui/studio/src/lib/dialInstances.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading