refactor: share trace, error boundary, and proxy http helpers
Lift the trace-to-shape geometry into @tts/mesh (traceToShape/ traceToUvBounds), parameterized by scale so the web token viewer and @tts/tabletop share one implementation. Move the CORS proxy HTTP helpers (assetUrl, resolveAssetUrl, traceImage) into a new @tts/http package, and make @tts/tabletop's ErrorBoundary the single source used by the web app. This removes the duplicated ErrorBoundary, assetUrl, tabletopHttp, and trace-to-shape code from apps/web and packages/tabletop.
This commit is contained in:
@@ -1,54 +0,0 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
/** Optional fallback rendered in place of `children` when an error is caught. */
|
||||
fallback?: (error: Error) => ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Catches render errors in its subtree and renders a fallback instead of
|
||||
* letting them crash the whole page. Used to isolate failures in object
|
||||
* viewers (e.g. WebGL context loss, model/texture load errors).
|
||||
*/
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
override state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// Surface the error for debugging without breaking the UI.
|
||||
console.error('Viewer error:', error, info.componentStack);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.state.error) {
|
||||
return this.props.fallback
|
||||
? this.props.fallback(this.state.error)
|
||||
: <DefaultFallback error={this.state.error} />;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function DefaultFallback({ error }: { error: Error }) {
|
||||
return (
|
||||
<div className="flex h-80 flex-col items-center justify-center gap-2 overflow-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4 text-center">
|
||||
<p className="text-sm font-medium text-zinc-200">Couldn't render this object</p>
|
||||
<p className="max-w-sm wrap-break-word font-mono text-xs text-zinc-400">
|
||||
{error.message}
|
||||
</p>
|
||||
{error.stack && (
|
||||
<pre className="mt-2 max-h-40 w-full overflow-auto whitespace-pre-wrap text-left font-mono text-[10px] leading-relaxed text-zinc-600">
|
||||
{error.stack}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type ExtrudedGeometry,
|
||||
} from '@tts/mesh';
|
||||
import Scene from './Scene';
|
||||
import { assetUrl } from './assetUrl';
|
||||
import { assetUrl } from '@tts/http';
|
||||
import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution';
|
||||
import { flipTexture } from './flipTexture';
|
||||
import { getSharedGeometry, objectTint, tintedColor } from './sharedResources';
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { TTSObject } from '@tts/shared';
|
||||
import * as THREE from 'three';
|
||||
import type { Object3D } from 'three';
|
||||
import Scene from './Scene';
|
||||
import { assetUrl } from './assetUrl';
|
||||
import { assetUrl } from '@tts/http';
|
||||
import { FlexibleModelLoader } from './flexibleModelLoader';
|
||||
import { objectTint, tintedColor } from './sharedResources';
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type ExtrudedGeometry,
|
||||
} from '@tts/mesh';
|
||||
import Scene from './Scene';
|
||||
import { assetUrl } from './assetUrl';
|
||||
import { assetUrl } from '@tts/http';
|
||||
import { flipTexture } from './flipTexture';
|
||||
import {
|
||||
getSharedGeometry,
|
||||
|
||||
@@ -3,14 +3,15 @@ import { useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import type { TTSObject } from '@tts/shared';
|
||||
import {
|
||||
circleShape,
|
||||
extrudeShapeParts,
|
||||
traceToShape,
|
||||
traceToUvBounds,
|
||||
type ExtrudedGeometry,
|
||||
type Shape,
|
||||
type UVBounds,
|
||||
} from '@tts/mesh';
|
||||
import { traceImage } from '../../api';
|
||||
import { traceImage } from '@tts/http';
|
||||
import Scene from './Scene';
|
||||
import { assetUrl } from './assetUrl';
|
||||
import { assetUrl } from '@tts/http';
|
||||
import {
|
||||
getSharedGeometry,
|
||||
getSharedMaterial,
|
||||
@@ -71,8 +72,9 @@ export function TokenMesh({
|
||||
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 shape = trace ? toMeshShape(trace) : circleShape();
|
||||
const uvBounds = trace ? toUvBounds(trace) : undefined;
|
||||
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
|
||||
@@ -154,71 +156,3 @@ function toGeometry(extruded: ExtrudedGeometry) {
|
||||
geo.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||
return geo;
|
||||
}
|
||||
|
||||
/** A circle fallback when there's no image to trace. */
|
||||
function circleShape(): Shape {
|
||||
const pts: number[][] = [];
|
||||
for (let i = 0; i < 48; i++) {
|
||||
const a = (i / 48) * Math.PI * 2;
|
||||
pts.push([Math.cos(a) * (TOKEN_SIZE / 2), Math.sin(a) * (TOKEN_SIZE / 2)]);
|
||||
}
|
||||
return { outline: pts };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a traced shape (image pixel coords, origin top-left, y-down) to a
|
||||
* mesh `Shape` (y-up, centered at the origin). Flips the y-axis, scales to
|
||||
* `TOKEN_SIZE`, centers the result, and normalizes winding so the outline is
|
||||
* counter-clockwise and holes are clockwise (as `@tts/mesh` expects).
|
||||
*/
|
||||
function toMeshShape(trace: {
|
||||
shape: { outline: number[][]; holes?: number[][][] };
|
||||
width: number;
|
||||
height: number;
|
||||
}): Shape {
|
||||
const { shape, width, height } = trace;
|
||||
const scale = TOKEN_SIZE / Math.max(width, height);
|
||||
const ox = (width * scale) / 2;
|
||||
const oy = (height * scale) / 2;
|
||||
const transform = (pts: number[][]) =>
|
||||
pts.map(([x, y]) => [x! * scale - ox, (height - y!) * scale - oy]);
|
||||
return {
|
||||
outline: normalizeWinding(transform(shape.outline), true),
|
||||
holes: shape.holes?.map((h) => normalizeWinding(transform(h), false)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The full image rectangle, in mesh coordinates, used as the UV framing so the
|
||||
* texture aligns with the traced silhouette (which may be smaller than the
|
||||
* image when there is transparent padding).
|
||||
*/
|
||||
function toUvBounds(trace: {
|
||||
width: number;
|
||||
height: number;
|
||||
}): UVBounds {
|
||||
const scale = TOKEN_SIZE / Math.max(trace.width, trace.height);
|
||||
const ox = (trace.width * scale) / 2;
|
||||
const oy = (trace.height * scale) / 2;
|
||||
return { minX: -ox, minY: -oy, maxX: ox, maxY: oy };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a ring has the requested winding. `ccw` true yields a
|
||||
* counter-clockwise ring (outline); false yields clockwise (hole).
|
||||
*/
|
||||
function normalizeWinding(pts: number[][], ccw: boolean): number[][] {
|
||||
const isCcw = signedArea(pts) > 0;
|
||||
return isCcw === ccw ? pts : [...pts].reverse();
|
||||
}
|
||||
|
||||
/** Signed area of a polygon; positive means counter-clockwise. */
|
||||
function signedArea(points: number[][]): number {
|
||||
let area = 0;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const [x1, y1] = points[i]!;
|
||||
const [x2, y2] = points[(i + 1) % points.length]!;
|
||||
area += x1! * y2! - x2! * y1!;
|
||||
}
|
||||
return area / 2;
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* Route an external asset URL through the proxy so it can be loaded by
|
||||
* three.js loaders (TextureLoader, GLTFLoader, etc.) despite the upstream host
|
||||
* omitting CORS headers.
|
||||
*/
|
||||
export function assetUrl(url: string): string {
|
||||
return `/asset?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user