diff --git a/apps/web/src/components/viewers/DeckViewer.tsx b/apps/web/src/components/viewers/DeckViewer.tsx index 8d10ff6..2a7331a 100644 --- a/apps/web/src/components/viewers/DeckViewer.tsx +++ b/apps/web/src/components/viewers/DeckViewer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import type { RefObject } from 'react'; import { useFrame } from '@react-three/fiber'; import * as THREE from 'three'; @@ -11,8 +11,10 @@ import { CardObjectMesh } from './CardMesh'; const HALF_WINDOW = 3; /** Angular spacing between adjacent cards in the arc, in radians. */ const ARC_STEP = 0.32; -/** Radius of the arc, in world units (large enough for card widths). */ +/** 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 @@ -35,8 +37,13 @@ export default function DeckViewer({ object }: { object: TTSObject }) { 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( () => @@ -70,10 +77,15 @@ export default function DeckViewer({ object }: { object: TTSObject }) { key={i} card={card} k={k} + radius={radius} groupRef={k === 0 ? centerRef : undefined} /> ))} - + ) : ( @@ -83,25 +95,44 @@ export default function DeckViewer({ object }: { object: TTSObject }) { } /** - * Fits the camera once, on mount, to frame the active card. The center 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. + * 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(); - useEffect(() => { + 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; - const node = targetRef.current; - if (node) bounds.refresh(node).fit(); - }, [bounds, targetRef, didFit]); + // 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; } @@ -110,17 +141,19 @@ function FitActive({ 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)); - const target = useMemo(() => slotTransform(k), [k]); + const state = useRef(slotTransform(k, radius)); + const target = useMemo(() => slotTransform(k, radius), [k, radius]); useFrame((_, dt) => { const g = group.current; @@ -153,18 +186,30 @@ interface Slots { } /** World transform for a card at arc offset `k` (0 = front and center). */ -function slotTransform(k: number): Slots { +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) * ARC_RADIUS, - z: Math.cos(ang) * ARC_RADIUS, + 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,