import { useMemo, useRef, useState } from 'react'; import type { RefObject } from 'react'; import { useFrame } from '@react-three/fiber'; import * as THREE from 'three'; import type { TTSObject } from '@tts/shared'; import { useBounds } from '@react-three/drei'; import Scene from './Scene'; import { CardObjectMesh } from './CardMesh'; /** How many cards to show to each side of the active card. */ const HALF_WINDOW = 3; /** Angular spacing between adjacent cards in the arc, in radians. */ const ARC_STEP = 0.32; /** Minimum radius of the arc, in world units. */ const ARC_RADIUS = 3.2; /** Extra clearance between the active card's edge and its neighbors, in world units. */ const ARC_PADDING = 0.2; /** * A deck carousel: the deck's contained cards are fanned in a 3D arc with the * active card front and center. Prev/next controls step through the deck, each * card animating to its new slot. Side cards are turned 90° in y (album flow) * so only the active card's face is framed; all neighbors edge-on around it. * * The camera is fitted to just the active card (not the whole carousel): the * shared scene's auto-fit is disabled and `useBounds` refits whenever the * selection changes. * * Only a window of cards around the active one is rendered (the rest stay * hidden), so large decks stay lean. Falls back to a single card (the deck * object itself) when there are no contained cards. */ export default function DeckViewer({ object }: { object: TTSObject }) { const cards = (object.ContainedObjects ?? []).filter( (o) => o.CardID != null || o.CustomImage != null, ); const count = cards.length; const [active, setActive] = useState(0); const hasCards = count > 0; const centerRef = useRef(null); // The active card's world-space width, measured once it's laid out. Used to // widen the arc so neighbors clear the card's edges (a fixed radius only fits // square cards; wider cards clip their neighbors). const [cardWidth, setCardWidth] = useState(null); const step = (dir: number) => setActive((a) => (a + dir + count) % count); const radius = arcRadius(cardWidth); const visible = useMemo( () => cards .map((card, i) => ({ card, i, k: i - active })) .filter((v) => Math.abs(v.k) <= HALF_WINDOW), [cards, active], ); // The active card always settles to the arc center, so the camera only needs // to frame it once on mount. const didFit = useRef(false); return ( ) : undefined } > {hasCards ? ( <> {visible.map(({ card, i, k }) => ( // Key by the card's index (stable across renders) so the element // persists and tweens as its slot changes; the index-path key is // unique even though cards in a deck share the same GUID. ))} ) : ( )} ); } /** * Fits the camera once, on the first frame, to frame the active card from the * front. Runs in a frame callback (not an effect) so the active card has been * moved to its arc slot by its own `useFrame` first — otherwise the group is * still at the origin and the camera would frame the carousel center. * * The card's front face points toward +Z, so the camera is placed directly in * front of it and looks straight at it, rather than keeping its initial side * angle (which drei's `fit()` would do). The active card always settles in the * same slot, so no refit is needed while navigating — that would restart the * camera tween on every step and feel laggy. */ function FitActive({ targetRef, didFit, onMeasure, }: { targetRef: RefObject; didFit: RefObject; onMeasure: (width: number) => void; }) { const bounds = useBounds(); useFrame(() => { const node = targetRef.current; if (!node) return; bounds.refresh(node); const { size, center, distance } = bounds.getSize(); // Report the active card's width so the arc radius can widen for wide // cards (see `arcRadius`). Runs every frame until the radius settles. onMeasure(size.x); if (didFit.current) return; didFit.current = true; // The card's front face points toward +Z, so put the camera in front of it // and look straight at it. bounds .moveTo([center.x, center.y, center.z + distance]) .lookAt({ target: center }); }); return null; } /** A single card that tweens into its arc slot each frame. */ function CarouselCard({ card, k, radius, groupRef, }: { card: TTSObject; k: number; radius: number; groupRef?: RefObject; }) { const localRef = useRef(null); const group = groupRef ?? localRef; // Start at the target so the first render doesn't tween into place. const state = useRef(slotTransform(k, radius)); const target = useMemo(() => slotTransform(k, radius), [k, radius]); useFrame((_, dt) => { const g = group.current; if (!g) return; // Smooth per-frame damping independent of frame rate. const f = 1 - Math.pow(0.0001, dt); const t = target; const s = state.current; s.x += (t.x - s.x) * f; s.z += (t.z - s.z) * f; s.rot += (t.rot - s.rot) * f; s.scale += (t.scale - s.scale) * f; g.position.set(s.x, 0, s.z); g.rotation.y = s.rot; g.scale.setScalar(s.scale); }); return ( ); } interface Slots { x: number; z: number; rot: number; scale: number; } /** World transform for a card at arc offset `k` (0 = front and center). */ function slotTransform(k: number, radius: number): Slots { const ang = k * ARC_STEP; // Side cards turn edge-on (album flow); the active card stays forward. const turn = k === 0 ? 0 : Math.sign(k) * (Math.PI / 2); return { x: Math.sin(ang) * radius, z: Math.cos(ang) * radius, rot: turn, scale: 1.15 - 0.15 * Math.abs(k), }; } /** * Arc radius that keeps the nearest neighbors clear of the active card's * edges. A neighbor at `k = 1` sits at `x = sin(ARC_STEP) * radius`, so the * radius must exceed `cardWidth / 2 / sin(ARC_STEP)` for the neighbor to clear * the card's half-width (plus padding). Falls back to the minimum when the * card width isn't known yet. */ function arcRadius(cardWidth: number | null): number { if (cardWidth == null) return ARC_RADIUS; return Math.max(ARC_RADIUS, (cardWidth / 2 + ARC_PADDING) / Math.sin(ARC_STEP)); } /** Prev/next controls and a counter, rendered as the scene overlay. */ function CarouselControls({ active, count, onStep, }: { active: number; count: number; onStep: (dir: number) => void; }) { const prev = () => onStep(-1); const next = () => onStep(1); return (
{active + 1} / {count}
); }