Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
644 lines
22 KiB
TypeScript
644 lines
22 KiB
TypeScript
import * as THREE from "three";
|
|
import type { AgentGUIAgentAvatarPresentation } from "./model/agentGuiAgentAvatarPresentation";
|
|
import {
|
|
coverImageTexture,
|
|
roundedIconTexture,
|
|
vinylRecordTexture
|
|
} from "./agentGuiHeroCarouselTextures";
|
|
|
|
// Three.js scene behind the empty-hero agent carousel, modelled after
|
|
// animos.app's "Wheel Carousel": same-sized vinyl records ride the rim of a
|
|
// giant wheel whose hub sits far below the stage. The focused agent stands
|
|
// upright at the top of the wheel; neighbours tilt tangentially and sink down
|
|
// the sides, and the wheel ticks forward with a springy overshoot. The wheel
|
|
// is a closed loop, so the carousel wraps seamlessly with no teleports.
|
|
|
|
const CAMERA_FOV_DEG = 14;
|
|
const CAMERA_Z = 7.5;
|
|
// The wheel aims for about this many slots around its full rim; the icon
|
|
// sequence repeats as often as needed to get close, which also fixes the
|
|
// slot angle (2*PI / slots). A broad virtual wheel keeps the visible records
|
|
// on a shallow arc instead of exposing an obvious circular silhouette.
|
|
const WHEEL_TARGET_SLOTS = 40;
|
|
// Center-to-center distance between neighbouring tiles along the rim; tiles
|
|
// are 1 unit wide, so the remainder is the visible gap. The wheel radius is
|
|
// derived from this, so wider spacing also grows the wheel itself.
|
|
const TILE_SPACING = 1.85;
|
|
// Compress the visible portion of the wheel into a shallow arc. Horizontal
|
|
// spacing still follows the circular loop, while vertical drop and tangent
|
|
// tilt are reduced so the records read as a row rather than a ring.
|
|
const VISIBLE_ARC_CURVATURE = 0.9;
|
|
// Side fade-out is a CSS gradient mask on the canvas element (see
|
|
// agentactivity.css): tiles dissolve spatially as they approach the stage
|
|
// edges instead of fading per-tile by angle.
|
|
// Opacity fades continuously with distance from the focused slot. The CSS mask
|
|
// still softens the physical canvas edges, while this range fade makes every
|
|
// record step down progressively as it moves away from the center.
|
|
const MIN_RECORD_OPACITY = 0.22;
|
|
const RECORD_FADE_RANGE_SLOTS = 4.5;
|
|
const RECORD_FADE_CURVE = 1.45;
|
|
// Records past the immediate neighbours dissolve to fully transparent so no
|
|
// half-faded sliver lingers at the stage edges. The kill band begins beyond
|
|
// the adjacent records and reaches zero just after two slots.
|
|
const RECORD_EDGE_KILL_START_SLOTS = 1.2;
|
|
const RECORD_EDGE_KILL_END_SLOTS = 2.05;
|
|
// Keep the wheel tactile while giving each click or gesture a more deliberate
|
|
// settle. A near-critical spring preserves a hint of physical follow-through
|
|
// without the loose overshoot of the previous setting.
|
|
const SPRING_STIFFNESS = 120;
|
|
const SPRING_DAMPING_RATIO = 0.92;
|
|
const SPRING_MIN_LAUNCH_VELOCITY = 2.6;
|
|
const SPRING_SETTLE_EPSILON = 0.001;
|
|
const SPRING_SETTLE_VELOCITY = 0.02;
|
|
const MAX_FRAME_DELTA_SECONDS = 0.032;
|
|
const BADGE_CORNER_RADIUS = 0.5;
|
|
const BADGE_DIAMETER = 0.36;
|
|
// +0.08 units ~= a 4px rightward/downward nudge on-screen at the carousel's
|
|
// default render scale (camera FOV 14deg, z=7.5, canvas 112px tall x 0.8
|
|
// layer scale => ~48.6px per world unit).
|
|
const BADGE_OFFSET = 0.48;
|
|
const MAX_PIXEL_RATIO = 2;
|
|
const RECORD_MODEL_SCALE = 1.3;
|
|
const RECORD_MODEL_RADIUS = 0.45;
|
|
const RECORD_MODEL_THICKNESS = 0.03;
|
|
const RECORD_MODEL_TILT_X = -0.11;
|
|
const RECORD_MODEL_SIDE_TILT_FACTOR = 0.18;
|
|
const RECORD_MODEL_MAX_SIDE_TILT = 0.16;
|
|
const RECORD_RENDER_RANGE_SLOTS = 3.4;
|
|
const RECORD_EDGE_SEGMENTS = 48;
|
|
|
|
// Signed ring offset of tile `index` for a continuous scroll position, in
|
|
// (-count / 2, count / 2].
|
|
function ringOffset(index: number, scroll: number, count: number): number {
|
|
if (count <= 1) {
|
|
return index - scroll;
|
|
}
|
|
let offset = (index - scroll) % count;
|
|
const half = count / 2;
|
|
if (offset > half) {
|
|
offset -= count;
|
|
} else if (offset < -half) {
|
|
offset += count;
|
|
}
|
|
return offset;
|
|
}
|
|
|
|
interface AgentGuiHeroCarouselTile {
|
|
badgeMesh: THREE.Mesh<THREE.CircleGeometry, THREE.MeshBasicMaterial>;
|
|
edgeMaterial: THREE.MeshStandardMaterial;
|
|
edgeMesh: THREE.Mesh<THREE.CylinderGeometry, THREE.MeshStandardMaterial>;
|
|
coverTexture: THREE.Texture | null;
|
|
faceMaterial: THREE.MeshBasicMaterial;
|
|
faceMesh: THREE.Mesh<THREE.PlaneGeometry, THREE.MeshBasicMaterial>;
|
|
poseGroup: THREE.Group;
|
|
ready: boolean;
|
|
vinylTexture: THREE.Texture | null;
|
|
}
|
|
|
|
export interface AgentGuiHeroCarouselSceneOptions {
|
|
canvas: HTMLCanvasElement;
|
|
items: readonly AgentGUIAgentAvatarPresentation[];
|
|
loadedBadgeImages: readonly (HTMLImageElement | null)[];
|
|
loadedImages: readonly (HTMLImageElement | null)[];
|
|
loadedCoverImages: readonly (HTMLImageElement | null)[];
|
|
// Fired once the wheel settles on an integer slot after an animated move.
|
|
onSettle: (index: number) => void;
|
|
}
|
|
|
|
export class AgentGuiHeroCarouselScene {
|
|
// Returns null when a WebGL context is unavailable (e.g. jsdom tests); the
|
|
// component keeps its hidden DOM switcher working without visuals.
|
|
static create(
|
|
options: AgentGuiHeroCarouselSceneOptions
|
|
): AgentGuiHeroCarouselScene | null {
|
|
try {
|
|
return new AgentGuiHeroCarouselScene(options);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private readonly renderer: THREE.WebGLRenderer;
|
|
private readonly scene: THREE.Scene;
|
|
private readonly camera: THREE.PerspectiveCamera;
|
|
private readonly edgeGeometry: THREE.CylinderGeometry;
|
|
private readonly faceGeometry: THREE.PlaneGeometry;
|
|
private readonly raycaster = new THREE.Raycaster();
|
|
private readonly tiles: AgentGuiHeroCarouselTile[] = [];
|
|
private readonly textures = new Set<THREE.Texture>();
|
|
// Number of distinct agents; the wheel holds agentCount * repeats tiles
|
|
// (the icon sequence repeated), and scroll/target count TILE slots.
|
|
private readonly agentCount: number;
|
|
private readonly tileCount: number;
|
|
private readonly wheelRadius: number;
|
|
private readonly onSettle: (index: number) => void;
|
|
private scroll = 0;
|
|
private target = 0;
|
|
private velocity = 0;
|
|
private renderFrameHandle: number | null = null;
|
|
private springFrameHandle: number | null = null;
|
|
private lastFrameAt: number | null = null;
|
|
private disposed = false;
|
|
|
|
private constructor(options: AgentGuiHeroCarouselSceneOptions) {
|
|
this.agentCount = options.items.length;
|
|
const repeats = Math.max(
|
|
1,
|
|
Math.round(WHEEL_TARGET_SLOTS / Math.max(this.agentCount, 1))
|
|
);
|
|
this.tileCount = this.agentCount * repeats;
|
|
// Rim spacing fixes the wheel size: radius = arc spacing / slot angle.
|
|
this.wheelRadius = (TILE_SPACING * this.tileCount) / (Math.PI * 2);
|
|
this.onSettle = options.onSettle;
|
|
this.renderer = new THREE.WebGLRenderer({
|
|
canvas: options.canvas,
|
|
alpha: true,
|
|
antialias: true
|
|
});
|
|
this.renderer.setClearColor(0x000000, 0);
|
|
this.scene = new THREE.Scene();
|
|
this.camera = new THREE.PerspectiveCamera(CAMERA_FOV_DEG, 1, 0.1, 50);
|
|
this.camera.position.set(0, 0, CAMERA_Z);
|
|
|
|
// The label artwork remains color-stable on an unlit face plane. These
|
|
// lights shape the thin cylinder underneath so its rim and side wall react
|
|
// like a real pressed record as it moves around the wheel.
|
|
this.scene.add(new THREE.AmbientLight(0xffffff, 1.25));
|
|
const keyLight = new THREE.DirectionalLight(0xffffff, 2.4);
|
|
keyLight.position.set(-2.5, 3.5, 5);
|
|
this.scene.add(keyLight);
|
|
const rimLight = new THREE.DirectionalLight(0x9fb7ff, 0.9);
|
|
rimLight.position.set(4, -1.5, 2.5);
|
|
this.scene.add(rimLight);
|
|
|
|
// Every repeated record has the same shape. Share the GPU vertex buffers
|
|
// and keep the edge tessellation above the pixel density visible here.
|
|
this.faceGeometry = new THREE.PlaneGeometry(1, 1);
|
|
this.edgeGeometry = new THREE.CylinderGeometry(
|
|
RECORD_MODEL_RADIUS,
|
|
RECORD_MODEL_RADIUS,
|
|
RECORD_MODEL_THICKNESS,
|
|
RECORD_EDGE_SEGMENTS,
|
|
1,
|
|
true
|
|
);
|
|
|
|
// The icon sequence repeats around the wheel; every copy of an agent's
|
|
// record shares one face texture but keeps its own materials (per-record
|
|
// fade). A shallow cylinder supplies actual thickness beneath the face.
|
|
for (let slot = 0; slot < this.tileCount; slot++) {
|
|
const agentIndex = slot % this.agentCount;
|
|
const poseGroup = new THREE.Group();
|
|
const recordGroup = new THREE.Group();
|
|
recordGroup.scale.setScalar(RECORD_MODEL_SCALE);
|
|
const faceMaterial = new THREE.MeshBasicMaterial({
|
|
transparent: true,
|
|
depthWrite: false,
|
|
visible: false
|
|
});
|
|
const faceMesh = new THREE.Mesh(this.faceGeometry, faceMaterial);
|
|
faceMesh.position.z = RECORD_MODEL_THICKNESS / 2 + 0.002;
|
|
faceMesh.userData.agentIndex = agentIndex;
|
|
|
|
const edgeMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0x070708,
|
|
metalness: 0.42,
|
|
roughness: 0.28,
|
|
transparent: true,
|
|
depthWrite: false
|
|
});
|
|
const edgeMesh = new THREE.Mesh(this.edgeGeometry, edgeMaterial);
|
|
edgeMesh.rotation.x = Math.PI / 2;
|
|
|
|
const badgeMaterial = new THREE.MeshBasicMaterial({
|
|
transparent: true,
|
|
depthWrite: false,
|
|
// The solid circle is the owner marker's asset-independent fallback.
|
|
// Keep it visible while the optional remote avatar is loading or when
|
|
// that avatar cannot safely become a WebGL texture.
|
|
visible: options.items[slot % this.agentCount]?.badge != null
|
|
});
|
|
const badgeMesh = new THREE.Mesh(
|
|
new THREE.CircleGeometry(BADGE_DIAMETER / 2, 32),
|
|
badgeMaterial
|
|
);
|
|
badgeMesh.position.set(
|
|
BADGE_OFFSET,
|
|
-BADGE_OFFSET,
|
|
RECORD_MODEL_THICKNESS / 2 + 0.01
|
|
);
|
|
badgeMesh.userData.agentIndex = agentIndex;
|
|
|
|
recordGroup.add(edgeMesh, faceMesh);
|
|
poseGroup.add(recordGroup, badgeMesh);
|
|
poseGroup.visible = false;
|
|
poseGroup.userData.agentIndex = agentIndex;
|
|
this.scene.add(poseGroup);
|
|
this.tiles.push({
|
|
badgeMesh,
|
|
edgeMaterial,
|
|
edgeMesh,
|
|
coverTexture: null,
|
|
faceMaterial,
|
|
faceMesh,
|
|
poseGroup,
|
|
ready: false,
|
|
vinylTexture: null
|
|
});
|
|
}
|
|
options.items.forEach((item, agentIndex) => {
|
|
const image = options.loadedImages[agentIndex] ?? null;
|
|
if (image) {
|
|
this.applyImageTexture(image, agentIndex);
|
|
}
|
|
const coverImage = options.loadedCoverImages[agentIndex] ?? null;
|
|
if (coverImage) {
|
|
this.applyCoverImageTexture(coverImage, agentIndex);
|
|
}
|
|
const badgeImage = options.loadedBadgeImages[agentIndex] ?? null;
|
|
if (item.badge?.iconUrl && badgeImage) {
|
|
this.applyBadgeImageTexture(badgeImage, agentIndex);
|
|
}
|
|
});
|
|
|
|
this.applyPoses();
|
|
}
|
|
|
|
setSize(width: number, height: number): void {
|
|
if (this.disposed || width <= 0 || height <= 0) {
|
|
return;
|
|
}
|
|
this.renderer.setPixelRatio(
|
|
Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO)
|
|
);
|
|
this.renderer.setSize(width, height, false);
|
|
this.camera.aspect = width / height;
|
|
this.camera.updateProjectionMatrix();
|
|
// setSize clears the drawing buffer. Render synchronously so a window
|
|
// resize never exposes that cleared frame while the next animation frame
|
|
// is pending during interactive window dragging.
|
|
this.renderer.render(this.scene, this.camera);
|
|
}
|
|
|
|
// Agent index of the tile slot the wheel is heading to.
|
|
targetIndex(): number {
|
|
const slot =
|
|
((Math.round(this.target) % this.tileCount) + this.tileCount) %
|
|
this.tileCount;
|
|
return slot % this.agentCount;
|
|
}
|
|
|
|
// Advances the wheel one tile slot (= the next/previous agent, since the
|
|
// icon sequence repeats); returns the normalized agent index.
|
|
stepBy(direction: 1 | -1): number {
|
|
this.target += direction;
|
|
this.primeSpringMotion();
|
|
this.animate();
|
|
return this.targetIndex();
|
|
}
|
|
|
|
// Spins the wheel to the nearest copy of agent `index`.
|
|
moveTo(index: number, animateMove = true): void {
|
|
const agent =
|
|
((index % this.agentCount) + this.agentCount) % this.agentCount;
|
|
if (this.targetIndex() === agent) {
|
|
if (!animateMove) {
|
|
this.scroll = this.target;
|
|
this.velocity = 0;
|
|
this.applyPoses();
|
|
this.requestRender();
|
|
}
|
|
return;
|
|
}
|
|
// Among the repeated copies of this agent, pick the shortest spin.
|
|
let best: number | null = null;
|
|
for (let copy = 0; copy * this.agentCount < this.tileCount; copy++) {
|
|
const offset = ringOffset(
|
|
agent + copy * this.agentCount,
|
|
this.target,
|
|
this.tileCount
|
|
);
|
|
if (best === null || Math.abs(offset) < Math.abs(best)) {
|
|
best = offset;
|
|
}
|
|
}
|
|
this.target += best ?? 0;
|
|
if (animateMove) {
|
|
this.primeSpringMotion();
|
|
this.animate();
|
|
return;
|
|
}
|
|
this.scroll = this.target;
|
|
this.velocity = 0;
|
|
this.applyPoses();
|
|
this.requestRender();
|
|
}
|
|
|
|
// Canvas-relative pointer coordinates -> agent index, or null.
|
|
pick(x: number, y: number, width: number, height: number): number | null {
|
|
const tile = this.pickTile(x, y, width, height);
|
|
const index = tile?.poseGroup.userData.agentIndex;
|
|
return typeof index === "number" ? index : null;
|
|
}
|
|
|
|
hover(x: number, y: number, width: number, height: number): number | null {
|
|
const tile = this.pickTile(x, y, width, height);
|
|
const index = tile?.poseGroup.userData.agentIndex;
|
|
return typeof index === "number" ? index : null;
|
|
}
|
|
|
|
private pickTile(
|
|
x: number,
|
|
y: number,
|
|
width: number,
|
|
height: number
|
|
): AgentGuiHeroCarouselTile | null {
|
|
if (this.disposed || width <= 0 || height <= 0) {
|
|
return null;
|
|
}
|
|
const pointer = new THREE.Vector2(
|
|
(x / width) * 2 - 1,
|
|
-(y / height) * 2 + 1
|
|
);
|
|
this.raycaster.setFromCamera(pointer, this.camera);
|
|
const meshes = this.tiles
|
|
.filter(
|
|
(tile) =>
|
|
tile.poseGroup.visible &&
|
|
tile.faceMaterial.visible &&
|
|
tile.faceMaterial.opacity > 0.05
|
|
)
|
|
.flatMap((tile) =>
|
|
tile.badgeMesh.material.visible
|
|
? [tile.faceMesh, tile.badgeMesh]
|
|
: [tile.faceMesh]
|
|
);
|
|
const hit = this.raycaster.intersectObjects(meshes, false)[0];
|
|
if (!hit) {
|
|
return null;
|
|
}
|
|
return (
|
|
this.tiles.find(
|
|
(tile) => tile.faceMesh === hit.object || tile.badgeMesh === hit.object
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
dispose(): void {
|
|
this.disposed = true;
|
|
this.cancelScheduledFrames();
|
|
for (const tile of this.tiles) {
|
|
tile.badgeMesh.geometry.dispose();
|
|
tile.badgeMesh.material.dispose();
|
|
tile.faceMaterial.dispose();
|
|
tile.edgeMaterial.dispose();
|
|
}
|
|
for (const texture of this.textures) {
|
|
texture.dispose();
|
|
}
|
|
this.textures.clear();
|
|
this.faceGeometry.dispose();
|
|
this.edgeGeometry.dispose();
|
|
// Do NOT force a context loss here: React StrictMode replays the mount
|
|
// effect on the SAME canvas element, and a forced loss would hand the
|
|
// second scene a dead context (white "sad canvas"). Disposing the
|
|
// renderer releases its GL resources; the context itself is reclaimed
|
|
// with the canvas element.
|
|
this.renderer.dispose();
|
|
}
|
|
|
|
private cancelScheduledFrames(): void {
|
|
if (this.renderFrameHandle !== null) {
|
|
cancelAnimationFrame(this.renderFrameHandle);
|
|
this.renderFrameHandle = null;
|
|
}
|
|
if (this.springFrameHandle !== null) {
|
|
cancelAnimationFrame(this.springFrameHandle);
|
|
this.springFrameHandle = null;
|
|
}
|
|
this.lastFrameAt = null;
|
|
}
|
|
|
|
private prefersReducedMotion(): boolean {
|
|
return (
|
|
typeof window.matchMedia === "function" &&
|
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
);
|
|
}
|
|
|
|
private animate(): void {
|
|
if (this.disposed) {
|
|
return;
|
|
}
|
|
if (this.prefersReducedMotion()) {
|
|
this.scroll = this.target;
|
|
this.velocity = 0;
|
|
this.applyPoses();
|
|
this.requestRender();
|
|
this.onSettle(this.targetIndex());
|
|
return;
|
|
}
|
|
if (this.springFrameHandle === null) {
|
|
// A one-shot resize/texture render must never masquerade as an active
|
|
// spring. Cancel it and let the interaction frame render immediately.
|
|
if (this.renderFrameHandle !== null) {
|
|
cancelAnimationFrame(this.renderFrameHandle);
|
|
this.renderFrameHandle = null;
|
|
}
|
|
this.lastFrameAt = null;
|
|
this.springFrameHandle = requestAnimationFrame(this.frame);
|
|
}
|
|
}
|
|
|
|
private primeSpringMotion(): void {
|
|
const delta = this.target - this.scroll;
|
|
const direction = Math.sign(delta);
|
|
if (direction === 0) {
|
|
return;
|
|
}
|
|
// Preserve an already-fast motion in the correct direction. Fresh clicks
|
|
// and reversals receive a small impulse so visible movement begins on the
|
|
// first animation frame instead of waiting for the spring to accelerate.
|
|
if (this.velocity * direction < SPRING_MIN_LAUNCH_VELOCITY) {
|
|
this.velocity = direction * SPRING_MIN_LAUNCH_VELOCITY;
|
|
}
|
|
}
|
|
|
|
private readonly frame = (now: number): void => {
|
|
this.springFrameHandle = null;
|
|
if (this.disposed) {
|
|
return;
|
|
}
|
|
const dt =
|
|
this.lastFrameAt === null
|
|
? 1 / 60
|
|
: Math.min((now - this.lastFrameAt) / 1000, MAX_FRAME_DELTA_SECONDS);
|
|
this.lastFrameAt = now;
|
|
const delta = this.target - this.scroll;
|
|
if (
|
|
Math.abs(delta) <= SPRING_SETTLE_EPSILON &&
|
|
Math.abs(this.velocity) <= SPRING_SETTLE_VELOCITY
|
|
) {
|
|
this.scroll = this.target;
|
|
this.velocity = 0;
|
|
this.applyPoses();
|
|
this.renderer.render(this.scene, this.camera);
|
|
this.onSettle(this.targetIndex());
|
|
return;
|
|
}
|
|
// Underdamped spring: the wheel ticks into place with a slight overshoot.
|
|
const damping = 2 * Math.sqrt(SPRING_STIFFNESS) * SPRING_DAMPING_RATIO;
|
|
this.velocity += (SPRING_STIFFNESS * delta - damping * this.velocity) * dt;
|
|
this.scroll += this.velocity * dt;
|
|
this.applyPoses();
|
|
this.renderer.render(this.scene, this.camera);
|
|
this.springFrameHandle = requestAnimationFrame(this.frame);
|
|
};
|
|
|
|
private requestRender(): void {
|
|
if (
|
|
this.disposed ||
|
|
this.renderFrameHandle !== null ||
|
|
this.springFrameHandle !== null
|
|
) {
|
|
return;
|
|
}
|
|
this.renderFrameHandle = requestAnimationFrame(() => {
|
|
this.renderFrameHandle = null;
|
|
if (!this.disposed) {
|
|
this.renderer.render(this.scene, this.camera);
|
|
}
|
|
});
|
|
}
|
|
|
|
private applyImageTexture(image: HTMLImageElement, agentIndex: number): void {
|
|
if (this.disposed) {
|
|
return;
|
|
}
|
|
const vinylTexture = vinylRecordTexture(image, () => this.requestRender());
|
|
this.textures.add(vinylTexture);
|
|
for (const tile of this.tiles) {
|
|
if (tile.poseGroup.userData.agentIndex === agentIndex) {
|
|
tile.faceMaterial.map = vinylTexture;
|
|
tile.faceMaterial.visible = true;
|
|
tile.faceMaterial.needsUpdate = true;
|
|
tile.vinylTexture = vinylTexture;
|
|
tile.ready = true;
|
|
}
|
|
}
|
|
this.applyPoses();
|
|
}
|
|
|
|
private applyCoverImageTexture(
|
|
image: HTMLImageElement,
|
|
agentIndex: number
|
|
): void {
|
|
if (this.disposed) {
|
|
return;
|
|
}
|
|
const texture = coverImageTexture(image, () => this.requestRender());
|
|
this.textures.add(texture);
|
|
for (const tile of this.tiles) {
|
|
if (tile.poseGroup.userData.agentIndex === agentIndex) {
|
|
tile.coverTexture = texture;
|
|
}
|
|
}
|
|
this.applyPoses();
|
|
}
|
|
|
|
private applyBadgeImageTexture(
|
|
image: HTMLImageElement,
|
|
agentIndex: number
|
|
): void {
|
|
if (this.disposed) {
|
|
return;
|
|
}
|
|
let texture: THREE.CanvasTexture | null = null;
|
|
try {
|
|
texture = roundedIconTexture(
|
|
image,
|
|
() => this.requestRender(),
|
|
BADGE_CORNER_RADIUS
|
|
);
|
|
// Force the upload before replacing the fallback material. This makes
|
|
// Canvas/WebGL failures transactional instead of leaving a visible
|
|
// material pointing at a texture that can never upload.
|
|
this.renderer.initTexture(texture);
|
|
} catch {
|
|
texture?.dispose();
|
|
this.requestRender();
|
|
return;
|
|
}
|
|
this.textures.add(texture);
|
|
for (const tile of this.tiles) {
|
|
if (tile.poseGroup.userData.agentIndex === agentIndex) {
|
|
tile.badgeMesh.material.map = texture;
|
|
tile.badgeMesh.material.visible = true;
|
|
tile.badgeMesh.material.needsUpdate = true;
|
|
}
|
|
}
|
|
this.requestRender();
|
|
}
|
|
|
|
private applyPoses(): void {
|
|
const step = (Math.PI * 2) / Math.max(this.tileCount, 1);
|
|
this.tiles.forEach((tile, index) => {
|
|
// Angle from the top of the wheel; the focused tile (offset 0) stands
|
|
// upright at 12 o'clock, neighbours ride down the rim.
|
|
const offset = ringOffset(index, this.scroll, this.tileCount);
|
|
const visible =
|
|
tile.ready && Math.abs(offset) <= RECORD_RENDER_RANGE_SLOTS;
|
|
tile.poseGroup.visible = visible;
|
|
if (!visible) {
|
|
return;
|
|
}
|
|
const angle = offset * step;
|
|
const isCenterTile = Math.abs(offset) < 0.5;
|
|
const faceTexture = isCenterTile ? tile.vinylTexture : tile.coverTexture;
|
|
if (tile.faceMaterial.map !== faceTexture) {
|
|
tile.faceMaterial.map = faceTexture;
|
|
tile.faceMaterial.needsUpdate = true;
|
|
}
|
|
const x = this.wheelRadius * Math.sin(angle);
|
|
const y =
|
|
this.wheelRadius * (Math.cos(angle) - 1) * VISIBLE_ARC_CURVATURE;
|
|
tile.poseGroup.position.set(x, y, 0);
|
|
// Tangent to the rim: the tile's top edge keeps pointing away from the
|
|
// wheel's hub.
|
|
// The focused slot is fully opaque and records fade progressively by
|
|
// distance, restoring the wheel's visible center-to-edge range gradient.
|
|
const fadeProgress = THREE.MathUtils.clamp(
|
|
1 - Math.abs(offset) / RECORD_FADE_RANGE_SLOTS,
|
|
0,
|
|
1
|
|
);
|
|
const rangeOpacity = Math.pow(
|
|
THREE.MathUtils.smoothstep(fadeProgress, 0, 1),
|
|
RECORD_FADE_CURVE
|
|
);
|
|
tile.poseGroup.rotation.set(
|
|
RECORD_MODEL_TILT_X,
|
|
THREE.MathUtils.clamp(
|
|
-angle * RECORD_MODEL_SIDE_TILT_FACTOR,
|
|
-RECORD_MODEL_MAX_SIDE_TILT,
|
|
RECORD_MODEL_MAX_SIDE_TILT
|
|
),
|
|
-angle * VISIBLE_ARC_CURVATURE
|
|
);
|
|
const edgeKill =
|
|
1 -
|
|
THREE.MathUtils.smoothstep(
|
|
Math.abs(offset),
|
|
RECORD_EDGE_KILL_START_SLOTS,
|
|
RECORD_EDGE_KILL_END_SLOTS
|
|
);
|
|
const opacity =
|
|
(MIN_RECORD_OPACITY + (1 - MIN_RECORD_OPACITY) * rangeOpacity) *
|
|
edgeKill;
|
|
tile.faceMaterial.opacity = opacity;
|
|
tile.edgeMaterial.opacity = isCenterTile ? opacity : 0;
|
|
tile.badgeMesh.material.opacity = opacity;
|
|
});
|
|
}
|
|
}
|