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 -1
View File
@@ -22,8 +22,8 @@
"@react-three/fiber": "^9.7.0",
"@react-three/postprocessing": "^3.0.4",
"@tts/bgm": "workspace:*",
"@tts/http": "workspace:*",
"@tts/mesh": "workspace:*",
"bson": "^7.3.1",
"react": "^19.2.8",
"three": "^0.185.1",
"zustand": "^5.0.14"
-23
View File
@@ -1,23 +0,0 @@
import { describe, expect, it } from 'vitest';
import { resolveAssetUrl } from './http.js';
describe('resolveAssetUrl', () => {
it('passes absolute urls through unchanged', () => {
expect(resolveAssetUrl('https://example.com/a.png', 'harbor/parts/')).toBe(
'https://example.com/a.png',
);
expect(resolveAssetUrl('data:image/png;base64,AAAA', 'harbor/parts/')).toBe(
'data:image/png;base64,AAAA',
);
});
it('resolves a relative path against baseUrl', () => {
expect(resolveAssetUrl('./assets/tokens.png', 'harbor/parts/')).toBe(
'harbor/parts/assets/tokens.png',
);
});
it('returns the relative path as-is without a baseUrl', () => {
expect(resolveAssetUrl('./assets/tokens.png')).toBe('./assets/tokens.png');
});
});
+5 -51
View File
@@ -1,53 +1,7 @@
/**
* HTTP helpers for loading part assets. The only coupling to the host app's
* HTTP surface: the asset proxy (`/asset`) and the trace endpoint (`/trace`).
* Kept as plain functions so they're testable without react-three.
* HTTP helpers for loading part assets. Re-exported from `@tts/http`, the
* shared package for the host app's proxy (`/asset` and `/trace`) handlers.
* Kept as a thin re-export so the tabletop's public surface is unchanged.
*/
import { deserialize } from 'bson';
/**
* Resolve a part's asset URL. Absolute URLs (http/https, data:, blob:) pass
* through unchanged; relative paths are resolved against `baseUrl` — the
* directory of the part's source file (the json/yaml/markdown that defined
* it). Without a `baseUrl`, a relative path is returned as-is.
*/
export function resolveAssetUrl(url: string, baseUrl?: string): string {
if (/^(https?:|data:|blob:)/i.test(url)) return url;
if (!baseUrl) return url;
// `baseUrl` is a path relative to the games root (no scheme), so join rather
// than resolve as a URL. A fake origin lets `new URL` normalize `./` and
// `../`; the origin is stripped from the result.
const resolved = new URL(url, `http://localhost/${baseUrl}`).pathname;
return resolved.replace(/^\/+/, '');
}
/** Route an external asset URL through the proxy so three.js can load it. */
export function assetUrl(url: string): string {
return `/asset?url=${encodeURIComponent(url)}`;
}
export interface TraceResult {
width: number;
height: number;
shape?: { outline: number[][]; holes?: number[][][] };
}
/**
* 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.
*/
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(`/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;
}
export { resolveAssetUrl, assetUrl, traceImage } from '@tts/http';
export type { TraceResult } from '@tts/http';
+10 -31
View File
@@ -4,7 +4,13 @@
* app's `cardResolution.ts`).
*/
import type { Part, Crop } from '@tts/bgm';
import { rectShape, roundedRectShape, type Shape } from '@tts/mesh';
import {
rectShape,
roundedRectShape,
traceToShape as meshTraceToShape,
traceToUvBounds as meshTraceToUvBounds,
type Shape,
} from '@tts/mesh';
/** Scale from mm (the format's `size` unit) to world units. */
export const MM_TO_WORLD = 1 / 30;
@@ -67,15 +73,8 @@ export function traceToShape(
width: number,
height: number,
): Shape {
const { shape, width: tw, height: th } = trace;
const scale = Math.min(width / tw, height / th);
const ox = (tw * scale) / 2;
const oy = (th * scale) / 2;
const transform = (pts: number[][]) => pts.map(([x, y]) => [x! * scale - ox, (th - y!) * scale - oy]);
return {
outline: normalizeWinding(transform(shape.outline), true),
holes: shape.holes?.map((h) => normalizeWinding(transform(h), false)),
};
const { width: tw, height: th } = trace;
return meshTraceToShape(trace, Math.min(width / tw, height / th));
}
/**
@@ -88,25 +87,5 @@ export function traceToUvBounds(
width: number,
height: number,
): { minX: number; minY: number; maxX: number; maxY: number } {
const scale = Math.min(width / trace.width, height / 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 CCW (outline). */
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;
return meshTraceToUvBounds(trace, Math.min(width / trace.width, height / trace.height));
}