diff --git a/apps/web/package.json b/apps/web/package.json index 5ae94e1..2706a67 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/http": "workspace:*", "@tts/tabletop": "workspace:*", "bson": "^7.3.1", "react": "^19.2.8", diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 8fe2944..e13e4d8 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -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 { } /** - * 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 { - 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 { const params = new URLSearchParams(); if (fileUrl) params.set('fileUrl', fileUrl); diff --git a/apps/web/src/components/ErrorBoundary.tsx b/apps/web/src/components/ErrorBoundary.tsx deleted file mode 100644 index 12a813a..0000000 --- a/apps/web/src/components/ErrorBoundary.tsx +++ /dev/null @@ -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 { - 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) - : ; - } - return this.props.children; - } -} - -function DefaultFallback({ error }: { error: Error }) { - return ( -
-

Couldn't render this object

-

- {error.message} -

- {error.stack && ( -
-          {error.stack}
-        
- )} -
- ); -} \ No newline at end of file diff --git a/apps/web/src/components/viewers/CardViewer.tsx b/apps/web/src/components/viewers/CardViewer.tsx index 4158e46..e81a6c4 100644 --- a/apps/web/src/components/viewers/CardViewer.tsx +++ b/apps/web/src/components/viewers/CardViewer.tsx @@ -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'; diff --git a/apps/web/src/components/viewers/CustomModelViewer.tsx b/apps/web/src/components/viewers/CustomModelViewer.tsx index dd653f3..667d34c 100644 --- a/apps/web/src/components/viewers/CustomModelViewer.tsx +++ b/apps/web/src/components/viewers/CustomModelViewer.tsx @@ -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'; diff --git a/apps/web/src/components/viewers/TileViewer.tsx b/apps/web/src/components/viewers/TileViewer.tsx index 704dd2f..7981395 100644 --- a/apps/web/src/components/viewers/TileViewer.tsx +++ b/apps/web/src/components/viewers/TileViewer.tsx @@ -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, diff --git a/apps/web/src/components/viewers/TokenViewer.tsx b/apps/web/src/components/viewers/TokenViewer.tsx index 1e0ddb6..ef85dcf 100644 --- a/apps/web/src/components/viewers/TokenViewer.tsx +++ b/apps/web/src/components/viewers/TokenViewer.tsx @@ -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; -} diff --git a/apps/web/src/components/viewers/assetUrl.ts b/apps/web/src/components/viewers/assetUrl.ts deleted file mode 100644 index b29a988..0000000 --- a/apps/web/src/components/viewers/assetUrl.ts +++ /dev/null @@ -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)}`; -} \ No newline at end of file diff --git a/apps/web/src/pages/ModPage.tsx b/apps/web/src/pages/ModPage.tsx index b83b4c1..a6b2e9b 100644 --- a/apps/web/src/pages/ModPage.tsx +++ b/apps/web/src/pages/ModPage.tsx @@ -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. diff --git a/apps/web/src/pages/PartPage.tsx b/apps/web/src/pages/PartPage.tsx index f2d5014..f64ea05 100644 --- a/apps/web/src/pages/PartPage.tsx +++ b/apps/web/src/pages/PartPage.tsx @@ -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() {

{/* 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. */} - - - - - + + +
             {JSON.stringify(found, null, 2)}
diff --git a/apps/web/src/tabletopHttp.ts b/apps/web/src/tabletopHttp.ts
deleted file mode 100644
index ebef2de..0000000
--- a/apps/web/src/tabletopHttp.ts
+++ /dev/null
@@ -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,
-};
\ No newline at end of file
diff --git a/docs/bgm-loader-status.md b/docs/bgm-loader-status.md
index 89709e0..dd2b86c 100644
--- a/docs/bgm-loader-status.md
+++ b/docs/bgm-loader-status.md
@@ -30,7 +30,11 @@ A real 52-card deck: a single `card` part expanded by `$variants` into 52 cards,
 
 ### `packages/tabletop` — rendering library (new)
 
-A standalone r3f library that renders bgm parts. `PartView`/`PartMesh` build a mesh from a `Part` definition via `@tts/mesh` (size/fillet, face/back sprite UVs, traced `shape` or rect fallback). Proxy calls (`/asset`, `/trace`) are injectable via `TabletopProvider`, so the host app supplies its own handlers. Plan: [`bgm-tabletop-plan.md`](./bgm-tabletop-plan.md).
+A standalone r3f library that renders bgm parts. `PartView`/`PartMesh` build a mesh from a `Part` definition via `@tts/mesh` (size/fillet, face/back sprite UVs, traced `shape` or rect fallback). Proxy calls (`/asset`, `/trace`) default to `@tts/http` handlers and are overridable via `TabletopProvider`. Plan: [`bgm-tabletop-plan.md`](./bgm-tabletop-plan.md).
+
+### `packages/http` — shared proxy HTTP (new)
+
+CORS-safe proxy HTTP helpers shared by the web app and `@tts/tabletop`: `assetUrl`/`resolveAssetUrl` (route external assets through `/asset`) and `traceImage` (image → vector shape via `/trace`, BSON-deserialized). The trace-to-shape geometry conversion lives in `@tts/mesh` (`traceToShape`/`traceToUvBounds`), and `ErrorBoundary` is shared via `@tts/tabletop`.
 
 ### `apps/proxy` — local game assets (new)
 
diff --git a/packages/http/package.json b/packages/http/package.json
new file mode 100644
index 0000000..0ae6d3c
--- /dev/null
+++ b/packages/http/package.json
@@ -0,0 +1,28 @@
+{
+  "name": "@tts/http",
+  "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": {
+    "@tts/shared": "workspace:*",
+    "bson": "^7.3.1"
+  },
+  "devDependencies": {
+    "typescript": "^5.7.2",
+    "vitest": "^4.1.10"
+  }
+}
\ No newline at end of file
diff --git a/packages/tabletop/src/http.test.ts b/packages/http/src/asset.test.ts
similarity index 94%
rename from packages/tabletop/src/http.test.ts
rename to packages/http/src/asset.test.ts
index 115d2c8..ab3eb80 100644
--- a/packages/tabletop/src/http.test.ts
+++ b/packages/http/src/asset.test.ts
@@ -1,5 +1,5 @@
 import { describe, expect, it } from 'vitest';
-import { resolveAssetUrl } from './http.js';
+import { resolveAssetUrl } from './asset.js';
 
 describe('resolveAssetUrl', () => {
   it('passes absolute urls through unchanged', () => {
diff --git a/packages/http/src/asset.ts b/packages/http/src/asset.ts
new file mode 100644
index 0000000..1d76338
--- /dev/null
+++ b/packages/http/src/asset.ts
@@ -0,0 +1,26 @@
+/**
+ * Asset URL helpers for the host app's CORS-safe asset proxy (`/asset`).
+ * Shared by the web viewers and `@tts/tabletop`, which both route external
+ * assets (textures, models) through the proxy so three.js can load them.
+ */
+
+/**
+ * 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)}`;
+}
\ No newline at end of file
diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts
new file mode 100644
index 0000000..1dd1834
--- /dev/null
+++ b/packages/http/src/index.ts
@@ -0,0 +1,3 @@
+export { resolveAssetUrl, assetUrl } from './asset.js';
+export { traceImage } from './trace.js';
+export type { TraceResult } from '@tts/shared';
\ No newline at end of file
diff --git a/packages/http/src/trace.ts b/packages/http/src/trace.ts
new file mode 100644
index 0000000..71e7ccd
--- /dev/null
+++ b/packages/http/src/trace.ts
@@ -0,0 +1,27 @@
+/**
+ * Trace an image into a vector shape via the host app's proxy (`/trace`).
+ * Shared by the web token viewer and `@tts/tabletop`, which both trace an
+ * image's alpha channel into a silhouette for extrusion.
+ */
+import { deserialize } from 'bson';
+import type { TraceResult } from '@tts/shared';
+
+/**
+ * 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/http/tsconfig.json b/packages/http/tsconfig.json
new file mode 100644
index 0000000..7830064
--- /dev/null
+++ b/packages/http/tsconfig.json
@@ -0,0 +1,9 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "outDir": "./dist",
+    "rootDir": "./src",
+    "lib": ["ES2022", "DOM"]
+  },
+  "include": ["src"]
+}
\ No newline at end of file
diff --git a/packages/http/vitest.config.ts b/packages/http/vitest.config.ts
new file mode 100644
index 0000000..2479fb2
--- /dev/null
+++ b/packages/http/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  test: {
+    include: ['src/**/*.test.ts'],
+  },
+});
\ No newline at end of file
diff --git a/packages/mesh/src/index.ts b/packages/mesh/src/index.ts
index e887053..634ed80 100644
--- a/packages/mesh/src/index.ts
+++ b/packages/mesh/src/index.ts
@@ -2,4 +2,5 @@ export * from './types.js';
 export * from './shapes.js';
 export * from './tessellate.js';
 export * from './walls.js';
-export * from './extrude.js';
\ No newline at end of file
+export * from './extrude.js';
+export * from './trace.js';
\ No newline at end of file
diff --git a/packages/mesh/src/trace.test.ts b/packages/mesh/src/trace.test.ts
new file mode 100644
index 0000000..e3926be
--- /dev/null
+++ b/packages/mesh/src/trace.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest';
+import { traceToShape, traceToUvBounds } from './trace.js';
+
+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, 0.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],
+      ]),
+    );
+  });
+
+  it('maps holes to clockwise winding', () => {
+    const trace = {
+      shape: {
+        outline: [[0, 0], [10, 0], [10, 10], [0, 10]],
+        holes: [[[2, 2], [2, 8], [8, 8], [8, 2]]],
+      },
+      width: 10,
+      height: 10,
+    };
+    const shape = traceToShape(trace, 0.2);
+    expect(shape.holes).toHaveLength(1);
+    expect(shape.holes![0]).toHaveLength(4);
+  });
+});
+
+describe('traceToUvBounds', () => {
+  it('maps the full image rectangle to the part box', () => {
+    const bounds = traceToUvBounds({ width: 10, height: 20 }, 0.2);
+    expect(bounds).toEqual({ minX: -1, minY: -2, maxX: 1, maxY: 2 });
+  });
+});
\ No newline at end of file
diff --git a/packages/mesh/src/trace.ts b/packages/mesh/src/trace.ts
new file mode 100644
index 0000000..24f8c4e
--- /dev/null
+++ b/packages/mesh/src/trace.ts
@@ -0,0 +1,67 @@
+/**
+ * Trace-to-shape helpers: convert a proxy `/trace` result (image pixel coords,
+ * origin top-left, y-down) into a mesh `Shape` (y-up, centered at the origin).
+ *
+ * Shared by the web token viewer and `@tts/tabletop`, which both trace an
+ * image's alpha channel into a silhouette and extrude it. The only difference
+ * between consumers is the scale factor, so the transform is parameterized by
+ * `scale` and the callers compute it (fit-to-box vs fit-to-max-dimension).
+ */
+import type { Shape } from './shapes.js';
+import type { UVBounds } from './types.js';
+
+/** A traced image region, in image pixel coords (origin top-left, y-down). */
+export interface TraceInput {
+  shape: { outline: number[][]; holes?: number[][][] };
+  width: number;
+  height: number;
+}
+
+/**
+ * Convert a traced shape to a mesh `Shape` scaled by `scale`. Flips the y-axis
+ * (image y-down → mesh y-up), centers the result at the origin, and normalizes
+ * winding so the outline is counter-clockwise and holes are clockwise (as
+ * `@tts/mesh` expects).
+ */
+export function traceToShape(trace: TraceInput, scale: number): Shape {
+  const { shape, width, height } = trace;
+  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 a traced silhouette (which may be smaller than the image
+ * when there is transparent padding).
+ */
+export function traceToUvBounds(
+  trace: { width: number; height: number },
+  scale: number,
+): UVBounds {
+  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/package.json b/packages/tabletop/package.json
index f5e4c57..a538e14 100644
--- a/packages/tabletop/package.json
+++ b/packages/tabletop/package.json
@@ -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"
diff --git a/packages/tabletop/src/http.ts b/packages/tabletop/src/http.ts
index 2b304ac..9d99f8b 100644
--- a/packages/tabletop/src/http.ts
+++ b/packages/tabletop/src/http.ts
@@ -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 {
-  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
+export { resolveAssetUrl, assetUrl, traceImage } from '@tts/http';
+export type { TraceResult } from '@tts/http';
\ No newline at end of file
diff --git a/packages/tabletop/src/part.ts b/packages/tabletop/src/part.ts
index a7b04fe..8d44b89 100644
--- a/packages/tabletop/src/part.ts
+++ b/packages/tabletop/src/part.ts
@@ -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));
 }
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 27bf14a..21eba27 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -87,6 +87,9 @@ importers:
       '@tts/extract':
         specifier: workspace:*
         version: link:../../packages/extract
+      '@tts/http':
+        specifier: workspace:*
+        version: link:../../packages/http
       '@tts/mesh':
         specifier: workspace:*
         version: link:../../packages/mesh
@@ -190,6 +193,22 @@ importers:
         specifier: ^5.7.2
         version: 5.9.3
 
+  packages/http:
+    dependencies:
+      '@tts/shared':
+        specifier: workspace:*
+        version: link:../shared
+      bson:
+        specifier: ^7.3.1
+        version: 7.3.1
+    devDependencies:
+      typescript:
+        specifier: ^5.7.2
+        version: 5.9.3
+      vitest:
+        specifier: ^4.1.10
+        version: 4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
+
   packages/mesh:
     dependencies:
       earcut:
@@ -233,12 +252,12 @@ importers:
       '@tts/bgm':
         specifier: workspace:*
         version: link:../bgm
+      '@tts/http':
+        specifier: workspace:*
+        version: link:../http
       '@tts/mesh':
         specifier: workspace:*
         version: link:../mesh
-      bson:
-        specifier: ^7.3.1
-        version: 7.3.1
       react:
         specifier: ^19.2.8
         version: 19.2.8