From 521519ce3d8dc59f7de63424638d28393feb431b Mon Sep 17 00:00:00 2001
From: hypercross
Date: Sun, 9 Aug 2026 21:46:36 +0800
Subject: [PATCH] feat(tabletop): inject proxy handlers from web app
---
apps/web/package.json | 1 +
apps/web/src/pages/PartPage.tsx | 13 ++
apps/web/src/tabletopHttp.ts | 13 ++
packages/tabletop/package.json | 37 ++++++
packages/tabletop/src/ErrorBoundary.tsx | 54 ++++++++
packages/tabletop/src/http.test.ts | 23 ++++
packages/tabletop/src/http.ts | 53 ++++++++
packages/tabletop/src/index.ts | 12 ++
packages/tabletop/src/part.test.ts | 81 ++++++++++++
packages/tabletop/src/part.ts | 112 +++++++++++++++++
packages/tabletop/src/partView.tsx | 160 ++++++++++++++++++++++++
packages/tabletop/src/provider.tsx | 47 +++++++
packages/tabletop/tsconfig.json | 11 ++
packages/tabletop/vitest.config.ts | 7 ++
pnpm-lock.yaml | 46 +++++++
15 files changed, 670 insertions(+)
create mode 100644 apps/web/src/tabletopHttp.ts
create mode 100644 packages/tabletop/package.json
create mode 100644 packages/tabletop/src/ErrorBoundary.tsx
create mode 100644 packages/tabletop/src/http.test.ts
create mode 100644 packages/tabletop/src/http.ts
create mode 100644 packages/tabletop/src/index.ts
create mode 100644 packages/tabletop/src/part.test.ts
create mode 100644 packages/tabletop/src/part.ts
create mode 100644 packages/tabletop/src/partView.tsx
create mode 100644 packages/tabletop/src/provider.tsx
create mode 100644 packages/tabletop/tsconfig.json
create mode 100644 packages/tabletop/vitest.config.ts
diff --git a/apps/web/package.json b/apps/web/package.json
index 2dac665..5ae94e1 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -22,6 +22,7 @@
"@tts/mesh": "workspace:*",
"@tts/shared": "workspace:*",
"@tts/bgm": "workspace:*",
+ "@tts/tabletop": "workspace:*",
"bson": "^7.3.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
diff --git a/apps/web/src/pages/PartPage.tsx b/apps/web/src/pages/PartPage.tsx
index e523748..f2d5014 100644
--- a/apps/web/src/pages/PartPage.tsx
+++ b/apps/web/src/pages/PartPage.tsx
@@ -1,7 +1,10 @@
import { useParams } from 'react-router-dom';
+import { PartView, ErrorBoundary, TabletopProvider } 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() {
@@ -35,6 +38,16 @@ export default function PartPage() {
{found.fillet ? ` · fillet ${found.fillet}mm` : ''}
+ {/* 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. */}
+
+
+
+
+
+
+
{JSON.stringify(found, null, 2)}
diff --git a/apps/web/src/tabletopHttp.ts b/apps/web/src/tabletopHttp.ts
new file mode 100644
index 0000000..ebef2de
--- /dev/null
+++ b/apps/web/src/tabletopHttp.ts
@@ -0,0 +1,13 @@
+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,
+};
\ No newline at end of file
diff --git a/packages/tabletop/package.json b/packages/tabletop/package.json
new file mode 100644
index 0000000..f5e4c57
--- /dev/null
+++ b/packages/tabletop/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "@tts/tabletop",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "typecheck": "tsc -p tsconfig.json --noEmit",
+ "test": "vitest run",
+ "lint": "echo \"no lint configured\""
+ },
+ "dependencies": {
+ "@react-three/drei": "^10.7.8",
+ "@react-three/fiber": "^9.7.0",
+ "@react-three/postprocessing": "^3.0.4",
+ "@tts/bgm": "workspace:*",
+ "@tts/mesh": "workspace:*",
+ "bson": "^7.3.1",
+ "react": "^19.2.8",
+ "three": "^0.185.1",
+ "zustand": "^5.0.14"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.18",
+ "@types/three": "^0.185.4",
+ "typescript": "^5.7.2",
+ "vitest": "^4.1.10"
+ }
+}
\ No newline at end of file
diff --git a/packages/tabletop/src/ErrorBoundary.tsx b/packages/tabletop/src/ErrorBoundary.tsx
new file mode 100644
index 0000000..c95daa0
--- /dev/null
+++ b/packages/tabletop/src/ErrorBoundary.tsx
@@ -0,0 +1,54 @@
+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 part viewers
+ * (e.g. WebGL context loss, texture/trace load errors).
+ */
+export default class ErrorBoundary extends Component {
+ 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('Part viewer error:', error, info.componentStack);
+ }
+
+ override render() {
+ if (this.state.error) {
+ return this.props.fallback
+ ? this.props.fallback(this.state.error)
+ : ;
+ }
+ return this.props.children;
+ }
+}
+
+function DefaultFallback({ error }: { error: Error }) {
+ return (
+
+
Couldn't render this part
+
+ {error.message}
+
+ {error.stack && (
+
+ {error.stack}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/packages/tabletop/src/http.test.ts b/packages/tabletop/src/http.test.ts
new file mode 100644
index 0000000..115d2c8
--- /dev/null
+++ b/packages/tabletop/src/http.test.ts
@@ -0,0 +1,23 @@
+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');
+ });
+});
\ No newline at end of file
diff --git a/packages/tabletop/src/http.ts b/packages/tabletop/src/http.ts
new file mode 100644
index 0000000..2b304ac
--- /dev/null
+++ b/packages/tabletop/src/http.ts
@@ -0,0 +1,53 @@
+/**
+ * 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.
+ */
+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 {
+ 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;
+}
\ No newline at end of file
diff --git a/packages/tabletop/src/index.ts b/packages/tabletop/src/index.ts
new file mode 100644
index 0000000..dee3a27
--- /dev/null
+++ b/packages/tabletop/src/index.ts
@@ -0,0 +1,12 @@
+export { PartView, PartMesh } from './partView.js';
+export { default as ErrorBoundary } from './ErrorBoundary.js';
+export { TabletopProvider, useTabletopHttp } from './provider.js';
+export type { TabletopHttp, AssetUrlFn, TraceImageFn } from './provider.js';
+export {
+ partDimensions,
+ spriteUvFromCrop,
+ fallbackShape,
+ traceToShape,
+ traceToUvBounds,
+} from './part.js';
+export { resolveAssetUrl, assetUrl, traceImage } from './http.js';
\ No newline at end of file
diff --git a/packages/tabletop/src/part.test.ts b/packages/tabletop/src/part.test.ts
new file mode 100644
index 0000000..79900c5
--- /dev/null
+++ b/packages/tabletop/src/part.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from 'vitest';
+import type { Part } from '@tts/bgm';
+import {
+ partDimensions,
+ spriteUvFromCrop,
+ fallbackShape,
+ traceToShape,
+ traceToUvBounds,
+} from './part.js';
+
+describe('partDimensions', () => {
+ it('converts mm size to world units', () => {
+ const part: Part = { type: 'card', id: 'as', size: [63, 88, 3] };
+ expect(partDimensions(part)).toEqual({ width: 63 / 30, height: 88 / 30, depth: 3 / 30 });
+ });
+
+ it('defaults when no size', () => {
+ const part: Part = { type: 'token', id: 'wood' };
+ expect(partDimensions(part)).toEqual({ width: 2, height: 2, depth: 0.1 });
+ });
+});
+
+describe('spriteUvFromCrop', () => {
+ it('returns full-image UVs without a crop', () => {
+ expect(spriteUvFromCrop(undefined)).toEqual({ repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 });
+ });
+
+ it('selects a sprite cell from a crop', () => {
+ // [col, row, cols, rows] — cell at col 1, row 2 of a 13x5 sheet.
+ expect(spriteUvFromCrop([1, 2, 13, 5])).toEqual({
+ repeatX: 1 / 13,
+ repeatY: 1 / 5,
+ offsetX: 1 / 13,
+ offsetY: 1 - 3 / 5,
+ });
+ });
+});
+
+describe('fallbackShape', () => {
+ it('returns a rect without a fillet', () => {
+ const part: Part = { type: 'tile', id: 'x', size: [40, 20, 3] };
+ const shape = fallbackShape(part, 40 / 30, 20 / 30);
+ expect(shape.outline).toHaveLength(4);
+ });
+
+ it('returns a rounded rect with a fillet', () => {
+ const part: Part = { type: 'card', id: 'as', size: [63, 88, 3], fillet: 2 };
+ const shape = fallbackShape(part, 63 / 30, 88 / 30);
+ // 4 corners x 4 segments.
+ expect(shape.outline.length).toBeGreaterThan(4);
+ });
+});
+
+describe('traceToShape', () => {
+ it('flips y and centers the traced shape', () => {
+ const trace = {
+ shape: { outline: [[0, 0], [10, 0], [10, 10], [0, 10]] },
+ width: 10,
+ height: 10,
+ };
+ const shape = traceToShape(trace, 2, 2);
+ // Centered at origin, scaled to fit the 2x2 box. The y-flip reverses
+ // winding, so the outline is normalized to CCW (order may differ).
+ expect(shape.outline).toHaveLength(4);
+ expect(shape.outline).toEqual(
+ expect.arrayContaining([
+ [-1, 1],
+ [1, 1],
+ [1, -1],
+ [-1, -1],
+ ]),
+ );
+ });
+});
+
+describe('traceToUvBounds', () => {
+ it('maps the full image rectangle to the part box', () => {
+ const bounds = traceToUvBounds({ width: 10, height: 20 }, 2, 4);
+ expect(bounds).toEqual({ minX: -1, minY: -2, maxX: 1, maxY: 2 });
+ });
+});
\ No newline at end of file
diff --git a/packages/tabletop/src/part.ts b/packages/tabletop/src/part.ts
new file mode 100644
index 0000000..a7b04fe
--- /dev/null
+++ b/packages/tabletop/src/part.ts
@@ -0,0 +1,112 @@
+/**
+ * Pure helpers for rendering a bgm `Part` as a mesh. Kept free of react-three
+ * so they can be unit-tested in a plain node environment (mirroring the web
+ * app's `cardResolution.ts`).
+ */
+import type { Part, Crop } from '@tts/bgm';
+import { rectShape, roundedRectShape, type Shape } from '@tts/mesh';
+
+/** Scale from mm (the format's `size` unit) to world units. */
+export const MM_TO_WORLD = 1 / 30;
+
+/** Default part size `[w, h, d]` in mm when a part has no `size`. */
+const DEFAULT_SIZE: [number, number, number] = [60, 60, 3];
+
+/** A part's dimensions in world units, derived from its `size` (mm). */
+export function partDimensions(part: Part): { width: number; height: number; depth: number } {
+ const [w, h, d] = part.size ?? DEFAULT_SIZE;
+ return {
+ width: w * MM_TO_WORLD,
+ height: h * MM_TO_WORLD,
+ depth: d * MM_TO_WORLD,
+ };
+}
+
+/**
+ * UV repeat/offset that selects a single sprite from a sheet, given a crop
+ * `[col, row, cols, rows]` that divides the image into a `cols` x `rows` grid
+ * and picks the cell at `[col, row]`. Row 0 is the top of the image (v=1), so
+ * the offset counts down from 1. Without a crop, the whole image is shown.
+ */
+export function spriteUvFromCrop(
+ crop: Crop | undefined,
+): { repeatX: number; repeatY: number; offsetX: number; offsetY: number } {
+ if (!crop) return { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 };
+ const [col, row, cols, rows] = crop;
+ return {
+ repeatX: 1 / cols,
+ repeatY: 1 / rows,
+ offsetX: col / cols,
+ offsetY: 1 - (row + 1) / rows,
+ };
+}
+
+/**
+ * The fallback footprint for a part without a `shape` sprite: a rounded rect
+ * when `fillet` is set, otherwise a plain rect, sized to the part's world
+ * dimensions. The fillet (mm) is converted to world units and clamped to half
+ * the smaller dimension.
+ */
+export function fallbackShape(part: Part, width: number, height: number): Shape {
+ const fillet = (part.fillet ?? 0) * MM_TO_WORLD;
+ if (fillet > 0) {
+ return roundedRectShape(width, height, Math.min(fillet, Math.min(width, height) / 2));
+ }
+ return rectShape(width, height);
+}
+
+/**
+ * Convert a traced shape (image pixel coords, origin top-left, y-down) to a
+ * mesh `Shape` (y-up, centered at the origin), scaled to fit the part's world
+ * `width` x `height` box. Flips the y-axis, centers the result, and normalizes
+ * winding so the outline is counter-clockwise and holes are clockwise (as
+ * `@tts/mesh` expects).
+ */
+export function traceToShape(
+ trace: { shape: { outline: number[][]; holes?: number[][][] }; width: number; height: number },
+ 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)),
+ };
+}
+
+/**
+ * The full image rectangle, in mesh coordinates, used as the UV framing so the
+ * texture aligns with a traced silhouette (which may be smaller than the image
+ * when there is transparent padding).
+ */
+export function traceToUvBounds(
+ trace: { width: number; height: number },
+ 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;
+}
\ No newline at end of file
diff --git a/packages/tabletop/src/partView.tsx b/packages/tabletop/src/partView.tsx
new file mode 100644
index 0000000..b2e34aa
--- /dev/null
+++ b/packages/tabletop/src/partView.tsx
@@ -0,0 +1,160 @@
+/**
+ * `PartView` — render a bgm `Part` definition as a 3D mesh.
+ *
+ * Builds the mesh from the part's definition using `@tts/mesh` geometry:
+ * - `size` → world dimensions; `fillet` → corner radius.
+ * - `face`/`faceCrop`/`back`/`backCrop` → textures with sprite UVs.
+ * - `shape` → traced silhouette (via the proxy `/trace`); otherwise a
+ * rect/rounded-rect fallback.
+ *
+ * A standalone component library: it reuses `@tts/mesh` geometry, not the web
+ * app's viewers. The trace endpoint and asset proxy are the only coupling to
+ * the host app's HTTP surface.
+ */
+import { useTexture } from '@react-three/drei';
+import { useMemo } from 'react';
+import * as THREE from 'three';
+import type { Part } from '@tts/bgm';
+import { extrudeShapeParts, type ExtrudedGeometry, type UVBounds } from '@tts/mesh';
+import {
+ fallbackShape,
+ partDimensions,
+ spriteUvFromCrop,
+ traceToShape,
+ traceToUvBounds,
+} from './part.js';
+import { resolveAssetUrl } from './http.js';
+import { useTabletopHttp, type TraceImageFn } from './provider.js';
+
+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.
+// A URL maps to either a pending promise (while loading) or the resolved value.
+const traceCache = new Map>();
+
+/**
+ * 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.
+ */
+export function useTrace(url: string | undefined, traceImage: TraceImageFn): TraceData | null {
+ if (!url) return null;
+ const cached = traceCache.get(url);
+ if (cached === undefined) {
+ const promise = traceImage(url, 'alpha', -2).then((result) => {
+ const value: TraceData | null = result.shape
+ ? { shape: result.shape, width: result.width, height: result.height }
+ : null;
+ traceCache.set(url, value);
+ return value;
+ });
+ traceCache.set(url, promise);
+ throw promise;
+ }
+ if (cached instanceof Promise) throw cached;
+ return cached;
+}
+
+/** A 1x1 transparent placeholder so `useTexture` always receives a URL. */
+const FALLBACK_URL =
+ 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
+
+/**
+ * Render a single part as a mesh. `PartView` is a canvas-internal mesh
+ * component — wrap it (or its containing `