Files
tts-workshop/apps/web/src/components/viewers/DeckViewer.tsx
T
hypercross 8ebb9211c5 fix(web): frame deck carousel on active card
Fit the camera in a frame callback so the active card has settled into
its arc slot first, and aim it straight at the card's front face instead
of keeping the initial side angle. Measure the card's width and widen the
arc radius so wide cards no longer clip their neighbors.
2026-08-14 11:15:59 +08:00

246 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<THREE.Group>(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<number | null>(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 (
<Scene
fit={false}
autoRotate={false}
overlay={
hasCards ? (
<CarouselControls active={active} count={count} onStep={step} />
) : 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.
<CarouselCard
key={i}
card={card}
k={k}
radius={radius}
groupRef={k === 0 ? centerRef : undefined}
/>
))}
<FitActive
targetRef={centerRef}
didFit={didFit}
onMeasure={setCardWidth}
/>
</>
) : (
<CardObjectMesh object={object} />
)}
</Scene>
);
}
/**
* 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<THREE.Group | null>;
didFit: RefObject<boolean>;
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<THREE.Group | null>;
}) {
const localRef = useRef<THREE.Group>(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 (
<group ref={group}>
<CardObjectMesh object={card} />
</group>
);
}
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 (
<div className="absolute inset-x-0 bottom-2 z-10 flex items-center justify-center gap-3">
<button
onClick={prev}
aria-label="Previous card"
className="flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/80 text-zinc-300 backdrop-blur transition hover:bg-zinc-800 hover:text-zinc-100"
>
</button>
<span className="rounded-md bg-zinc-900/80 px-3 py-1 font-mono text-xs text-zinc-300 backdrop-blur">
{active + 1} / {count}
</span>
<button
onClick={next}
aria-label="Next card"
className="flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/80 text-zinc-300 backdrop-blur transition hover:bg-zinc-800 hover:text-zinc-100"
>
</button>
</div>
);
}