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:
2026-08-09 22:17:24 +08:00
parent b4cdcdeb42
commit 15c45e7106
26 changed files with 282 additions and 273 deletions
+1
View File
@@ -22,6 +22,7 @@
"@tts/mesh": "workspace:*",
"@tts/shared": "workspace:*",
"@tts/bgm": "workspace:*",
"@tts/http": "workspace:*",
"@tts/tabletop": "workspace:*",
"bson": "^7.3.1",
"react": "^19.2.8",
+5 -22
View File
@@ -1,5 +1,7 @@
import { deserialize } from 'bson';
import type { SearchResult, TraceResult, TTSMod } from '@tts/shared';
import type { SearchResult, TTSMod } from '@tts/shared';
import { traceImage } from '@tts/http';
export { traceImage };
const BASE = '';
@@ -19,27 +21,8 @@ export function searchWorkshop(q: string, page = 1): Promise<SearchResult> {
}
/**
* Trace an image into a vector shape, BSON-deserializing the proxy response.
* `mode` controls how the region is derived (`alpha`, `bw`, `color`); a
* non-zero `offset` insets (negative) or outsets (positive) the shape in
* pixels.
* Fetch a full parsed TTS save.
*/
export async function traceImage(
url: string,
mode: 'alpha' | 'bw' | 'color' = 'alpha',
offset?: number,
): Promise<TraceResult> {
const params = new URLSearchParams({ url, mode });
if (offset !== undefined) params.set('offset', String(offset));
const res = await fetch(`${BASE}/trace?${params.toString()}`);
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(body?.error ?? `Trace failed (${res.status})`);
}
return deserialize(new Uint8Array(await res.arrayBuffer())) as TraceResult;
}
/** Fetch a full parsed TTS save. */
export function fetchMod(id: string, fileUrl?: string): Promise<TTSMod> {
const params = new URLSearchParams();
if (fileUrl) params.set('fileUrl', fileUrl);
-54
View File
@@ -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)}`;
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { useModStore } from '../stores/modStore';
import { useSearchStore } from '../stores/searchStore';
import { modFileUrl } from '../api';
import ObjectTree from '../components/ObjectTree';
import ErrorBoundary from '../components/ErrorBoundary';
import { ErrorBoundary } from '@tts/tabletop';
import { resolveViewer } from '../components/viewers';
import { iconsForObject } from '../components/objectIcons';
// Register the 3D viewers (side effect) so `resolveViewer` can find them.
+6 -9
View File
@@ -1,10 +1,9 @@
import { useParams } from 'react-router-dom';
import { PartView, ErrorBoundary, TabletopProvider } from '@tts/tabletop';
import { PartView, ErrorBoundary } from '@tts/tabletop';
import Breadcrumbs from '../components/Breadcrumbs';
import PackageMissing from '../components/PackageMissing';
import Scene from '../components/viewers/Scene';
import { findPackage } from './bgm';
import { tabletopHttp } from '../tabletopHttp';
/** Detail view for a single part within a package. */
export default function PartPage() {
@@ -39,14 +38,12 @@ export default function PartPage() {
</p>
</div>
{/* The boundary wraps the Canvas (Scene), not PartView, so its HTML
fallback renders outside the r3f namespace. The provider wires the
library's proxy calls to the web app's proxy. */}
fallback renders outside the r3f namespace. The library's proxy
calls default to the host's `/asset` and `/trace` paths. */}
<ErrorBoundary>
<TabletopProvider http={tabletopHttp}>
<Scene>
<PartView part={found} baseUrl={found.baseUrl} />
</Scene>
</TabletopProvider>
<Scene>
<PartView part={found} baseUrl={found.baseUrl} />
</Scene>
</ErrorBoundary>
<pre className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-xs text-zinc-400">
{JSON.stringify(found, null, 2)}
-13
View File
@@ -1,13 +0,0 @@
import type { TabletopHttp } from '@tts/tabletop';
import { traceImage as apiTraceImage } from './api';
import { assetUrl as viewerAssetUrl } from './components/viewers/assetUrl';
/**
* The tabletop library's HTTP handlers, wired to the web app's proxy. The
* library defaults to the conventional `/asset` and `/trace` paths, but the web
* app provides its own so the coupling is explicit and testable.
*/
export const tabletopHttp: TabletopHttp = {
assetUrl: viewerAssetUrl,
traceImage: apiTraceImage,
};