refactor(web): code-split the three.js stack out of the main bundle
This commit is contained in:
@@ -1,31 +1,6 @@
|
||||
import { useTexture } from '@react-three/drei';
|
||||
import { useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import type { TTSObject } from '@tts/shared';
|
||||
import {
|
||||
extrudeShapeParts,
|
||||
roundedRectShape,
|
||||
type ExtrudedGeometry,
|
||||
} from '@tts/mesh';
|
||||
import Scene from './Scene';
|
||||
import { assetUrl } from '@tts/http';
|
||||
import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution';
|
||||
import { flipTexture } from './flipTexture';
|
||||
import { getSharedGeometry, objectTint, tintedColor } from './sharedResources';
|
||||
|
||||
/** Longer card dimension, in world units. */
|
||||
const CARD_LENGTH = 2;
|
||||
/** Corner radius as a fraction of the shorter card edge. */
|
||||
const CORNER_RADIUS = 0.05;
|
||||
/** Thickness of the card. */
|
||||
const CARD_THICKNESS = 0.06;
|
||||
|
||||
// A 1x1 transparent placeholder so `useTexture` always receives a valid URL.
|
||||
// Without it, the face/back hooks would be called conditionally, which breaks
|
||||
// React's rules of hooks when switching between objects with different URL
|
||||
// combinations (e.g. a deck with a back vs. a `CardCustom` with only an image).
|
||||
const FALLBACK_URL =
|
||||
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||
import { CardObjectMesh } from './CardMesh';
|
||||
|
||||
/**
|
||||
* A playing card: a thin rounded rect with the face texture on the front and
|
||||
@@ -57,132 +32,4 @@ export default function CardViewer({ object }: { object: TTSObject }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The card mesh for an object, exported so the full-setup view can compose it
|
||||
* into a shared scene.
|
||||
*/
|
||||
export function CardObjectMesh({ object }: { object: TTSObject }) {
|
||||
const { cardId, faceUrl, backUrl, numWidth, numHeight, uniqueBack } =
|
||||
resolveCardConfig(object);
|
||||
|
||||
return (
|
||||
<CardMesh
|
||||
faceUrl={faceUrl}
|
||||
backUrl={backUrl}
|
||||
numWidth={numWidth}
|
||||
numHeight={numHeight}
|
||||
uniqueBack={uniqueBack}
|
||||
cardId={cardId}
|
||||
tint={objectTint(object)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Rendered inside the Canvas so `useTexture` can access the R3F store.
|
||||
// Exported so the full-setup view can compose it into a shared scene.
|
||||
export function CardMesh({
|
||||
faceUrl,
|
||||
backUrl,
|
||||
numWidth,
|
||||
numHeight,
|
||||
uniqueBack,
|
||||
cardId,
|
||||
tint,
|
||||
}: {
|
||||
faceUrl?: string;
|
||||
backUrl?: string;
|
||||
numWidth?: number;
|
||||
numHeight?: number;
|
||||
uniqueBack: boolean;
|
||||
cardId?: number;
|
||||
tint: THREE.Color;
|
||||
}) {
|
||||
// Always call both hooks so the hook count is stable across renders. The
|
||||
// placeholder is used only when a URL is absent; presence is checked via the
|
||||
// URL strings below, not the texture objects.
|
||||
const face = useTexture(faceUrl ? assetUrl(faceUrl) : FALLBACK_URL);
|
||||
const back = useTexture(backUrl ? assetUrl(backUrl) : FALLBACK_URL);
|
||||
|
||||
// Front texture: the sprite cell from the sheet (or the full image when there
|
||||
// is no grid). Cloned so the sprite offset/repeat don't leak into other cards
|
||||
// that share the same sheet URL (drei caches textures globally by URL).
|
||||
const faceMap = useMemo(() => {
|
||||
if (!faceUrl) return null;
|
||||
const tex = face.clone();
|
||||
const { repeatX, repeatY, offsetX, offsetY } = spriteUv(cardId, numWidth, numHeight);
|
||||
tex.repeat.set(repeatX, repeatY);
|
||||
tex.offset.set(offsetX, offsetY);
|
||||
return tex;
|
||||
}, [faceUrl, face, cardId, numWidth, numHeight]);
|
||||
|
||||
// Back texture: a single full image (tile) unless the deck has unique backs,
|
||||
// in which case it's a sheet too. Flipped left/right so it reads correctly
|
||||
// instead of being mirrored on the back face.
|
||||
const backMap = useMemo(() => {
|
||||
if (!backUrl) return null;
|
||||
const tex = back.clone();
|
||||
const { repeatX, repeatY, offsetX, offsetY } = uniqueBack
|
||||
? spriteUv(cardId, numWidth, numHeight)
|
||||
: { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 };
|
||||
tex.repeat.set(repeatX, repeatY);
|
||||
tex.offset.set(offsetX, offsetY);
|
||||
return flipTexture(tex);
|
||||
}, [backUrl, back, uniqueBack, cardId, numWidth, numHeight]);
|
||||
|
||||
// Build the rounded-rect geometry from the card sprite's aspect ratio. The
|
||||
// front and back faces each get their own material; the walls are a solid
|
||||
// white, matching TTS card tinting. Geometry is shared across cards of the
|
||||
// same size so the full-setup view reuses it; the face/back materials stay
|
||||
// per-card because each card clones its texture for sprite UVs.
|
||||
const { frontGeo, backGeo, wallsGeo } = useMemo(() => {
|
||||
const img = (faceUrl ? face.image : backUrl ? back.image : undefined) as
|
||||
| HTMLImageElement
|
||||
| undefined;
|
||||
const aspect = cardAspect(img, numWidth, numHeight);
|
||||
const width = CARD_LENGTH * aspect;
|
||||
const height = CARD_LENGTH;
|
||||
// Radius scales with the shorter edge so corners look proportional and
|
||||
// stay circular (no scaling distortion).
|
||||
const shape = roundedRectShape(width, height, CORNER_RADIUS * Math.min(width, height));
|
||||
const parts = extrudeShapeParts(shape, { height: CARD_THICKNESS });
|
||||
const key = `card:${width}:${height}:${CARD_THICKNESS}`;
|
||||
return {
|
||||
frontGeo: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
|
||||
backGeo: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
|
||||
wallsGeo: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
|
||||
};
|
||||
}, [faceUrl, face, backUrl, back, numWidth, numHeight]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh geometry={frontGeo}>
|
||||
<meshStandardMaterial
|
||||
color={tintedColor(faceMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint)}
|
||||
map={faceMap ?? undefined}
|
||||
roughness={0.6}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh geometry={backGeo}>
|
||||
<meshStandardMaterial
|
||||
color={tintedColor(backMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint)}
|
||||
map={backMap ?? undefined}
|
||||
roughness={0.6}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh geometry={wallsGeo}>
|
||||
<meshStandardMaterial color={tintedColor(new THREE.Color('#ffffff'), tint)} roughness={0.6} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
|
||||
function toGeometry(extruded: ExtrudedGeometry) {
|
||||
const { positions, normals, uvs, indices } = extruded;
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
||||
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
||||
geo.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||
return geo;
|
||||
}
|
||||
export { CardObjectMesh, CardMesh } from './CardMesh';
|
||||
Reference in New Issue
Block a user