refactor(web): code-split the three.js stack out of the main bundle
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import { useTexture } from '@react-three/drei';
|
||||
import { useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import type { TTSObject } from '@tts/shared';
|
||||
import {
|
||||
circleShape,
|
||||
extrudeShapeParts,
|
||||
traceToShape,
|
||||
traceToUvBounds,
|
||||
type ExtrudedGeometry,
|
||||
} from '@tts/mesh';
|
||||
import { traceImage } from '@tts/http';
|
||||
import { assetUrl } from '@tts/http';
|
||||
import {
|
||||
getSharedGeometry,
|
||||
getSharedMaterial,
|
||||
objectTint,
|
||||
tintKey,
|
||||
tintedColor,
|
||||
} from './sharedResources';
|
||||
|
||||
const TOKEN_SIZE = 1.8;
|
||||
|
||||
/** How far (in trace pixels) the token silhouette is inset from the artwork. */
|
||||
const TRACE_INSET = 2;
|
||||
|
||||
/**
|
||||
* The token mesh for an object, exported so the full-setup view can compose it
|
||||
* into a shared scene.
|
||||
*/
|
||||
export function TokenObjectMesh({ object }: { object: TTSObject }) {
|
||||
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
|
||||
const thickness = object.CustomImage?.CustomToken?.Thickness ?? 0.1;
|
||||
|
||||
return <TokenMesh url={url} thickness={thickness} 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 TokenMesh({
|
||||
url,
|
||||
thickness,
|
||||
tint,
|
||||
}: {
|
||||
url?: string;
|
||||
thickness: number;
|
||||
tint: THREE.Color;
|
||||
}) {
|
||||
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
|
||||
|
||||
// Trace the image's alpha channel into a shape. Suspends until the trace
|
||||
// resolves so the surrounding Suspense boundary (and `Bounds`) only mounts
|
||||
// once the token geometry is present. Falls back to a circle when there's no
|
||||
// image or the trace fails.
|
||||
const trace = useTrace(url);
|
||||
|
||||
const { front, back, walls } = useMemo(() => {
|
||||
// The traced shape and its UV framing share the same transform, so the
|
||||
// full image rectangle maps to the same bounds in mesh coordinates.
|
||||
const scale = TOKEN_SIZE / Math.max(trace?.width ?? 0, trace?.height ?? 0);
|
||||
const shape = trace ? traceToShape(trace, scale) : circleShape(TOKEN_SIZE / 2);
|
||||
const uvBounds = trace ? traceToUvBounds(trace, scale) : undefined;
|
||||
const parts = extrudeShapeParts(shape, { height: thickness, uvBounds });
|
||||
// Shared across tokens with the same source image (the trace is cached per
|
||||
// URL, so the silhouette is deterministic) so the full-setup view reuses
|
||||
// geometry.
|
||||
const key = `token:${url ?? 'none'}:${thickness}`;
|
||||
return {
|
||||
front: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
|
||||
back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
|
||||
walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
|
||||
};
|
||||
}, [trace, thickness, url]);
|
||||
|
||||
// A token is solid: front, back, and walls all carry the texture (projected
|
||||
// UV), unlike tiles/cards where only the faces are textured. The tint is
|
||||
// baked into the color and cache key so tinted variants don't collide.
|
||||
const material = getSharedMaterial(`token:${url ?? 'none'}:${tintKey(tint)}`, {
|
||||
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
||||
map: texture ?? undefined,
|
||||
roughness: 0.8,
|
||||
});
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh geometry={front} material={material} />
|
||||
<mesh geometry={back} material={material} />
|
||||
<mesh geometry={walls} material={material} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
interface TraceData {
|
||||
shape: { outline: number[][]; holes?: number[][][] };
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// Cache traces by URL so Suspense doesn't re-issue the request on every render
|
||||
// while the boundary is held open. A URL maps to either a pending promise (while
|
||||
// loading) or the resolved value (once loaded).
|
||||
const traceCache = new Map<string, TraceData | null | Promise<TraceData | null>>();
|
||||
|
||||
/**
|
||||
* Suspend on the alpha trace for `url`, resolving to the traced shape (or null
|
||||
* when there's no URL / the trace fails). Throws the cached promise only while
|
||||
* it's pending; once resolved, the value is returned directly so the retry
|
||||
* render completes instead of suspending forever.
|
||||
*/
|
||||
function useTrace(url: string | undefined): TraceData | null {
|
||||
if (!url) return null;
|
||||
const cached = traceCache.get(url);
|
||||
if (cached === undefined) {
|
||||
const promise = traceImage(url, 'alpha', -TRACE_INSET).then((result) => {
|
||||
const value: TraceData | null = result.shape
|
||||
? {
|
||||
shape: result.shape,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
}
|
||||
: null;
|
||||
// Replace the pending promise with the resolved value so later renders
|
||||
// return it instead of re-suspending on a settled promise.
|
||||
traceCache.set(url, value);
|
||||
return value;
|
||||
});
|
||||
traceCache.set(url, promise);
|
||||
throw promise;
|
||||
}
|
||||
if (cached instanceof Promise) throw cached;
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
Reference in New Issue
Block a user