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/mesh": "workspace:*",
"@tts/shared": "workspace:*", "@tts/shared": "workspace:*",
"@tts/bgm": "workspace:*", "@tts/bgm": "workspace:*",
"@tts/http": "workspace:*",
"@tts/tabletop": "workspace:*", "@tts/tabletop": "workspace:*",
"bson": "^7.3.1", "bson": "^7.3.1",
"react": "^19.2.8", "react": "^19.2.8",
+5 -22
View File
@@ -1,5 +1,7 @@
import { deserialize } from 'bson'; import type { SearchResult, TTSMod } from '@tts/shared';
import type { SearchResult, TraceResult, TTSMod } from '@tts/shared'; import { traceImage } from '@tts/http';
export { traceImage };
const BASE = ''; 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. * Fetch a full parsed TTS save.
* `mode` controls how the region is derived (`alpha`, `bw`, `color`); a
* non-zero `offset` insets (negative) or outsets (positive) the shape in
* pixels.
*/ */
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> { export function fetchMod(id: string, fileUrl?: string): Promise<TTSMod> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (fileUrl) params.set('fileUrl', fileUrl); 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, type ExtrudedGeometry,
} from '@tts/mesh'; } from '@tts/mesh';
import Scene from './Scene'; import Scene from './Scene';
import { assetUrl } from './assetUrl'; import { assetUrl } from '@tts/http';
import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution'; import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution';
import { flipTexture } from './flipTexture'; import { flipTexture } from './flipTexture';
import { getSharedGeometry, objectTint, tintedColor } from './sharedResources'; import { getSharedGeometry, objectTint, tintedColor } from './sharedResources';
@@ -5,7 +5,7 @@ import type { TTSObject } from '@tts/shared';
import * as THREE from 'three'; import * as THREE from 'three';
import type { Object3D } from 'three'; import type { Object3D } from 'three';
import Scene from './Scene'; import Scene from './Scene';
import { assetUrl } from './assetUrl'; import { assetUrl } from '@tts/http';
import { FlexibleModelLoader } from './flexibleModelLoader'; import { FlexibleModelLoader } from './flexibleModelLoader';
import { objectTint, tintedColor } from './sharedResources'; import { objectTint, tintedColor } from './sharedResources';
@@ -12,7 +12,7 @@ import {
type ExtrudedGeometry, type ExtrudedGeometry,
} from '@tts/mesh'; } from '@tts/mesh';
import Scene from './Scene'; import Scene from './Scene';
import { assetUrl } from './assetUrl'; import { assetUrl } from '@tts/http';
import { flipTexture } from './flipTexture'; import { flipTexture } from './flipTexture';
import { import {
getSharedGeometry, getSharedGeometry,
@@ -3,14 +3,15 @@ import { useMemo } from 'react';
import * as THREE from 'three'; import * as THREE from 'three';
import type { TTSObject } from '@tts/shared'; import type { TTSObject } from '@tts/shared';
import { import {
circleShape,
extrudeShapeParts, extrudeShapeParts,
traceToShape,
traceToUvBounds,
type ExtrudedGeometry, type ExtrudedGeometry,
type Shape,
type UVBounds,
} from '@tts/mesh'; } from '@tts/mesh';
import { traceImage } from '../../api'; import { traceImage } from '@tts/http';
import Scene from './Scene'; import Scene from './Scene';
import { assetUrl } from './assetUrl'; import { assetUrl } from '@tts/http';
import { import {
getSharedGeometry, getSharedGeometry,
getSharedMaterial, getSharedMaterial,
@@ -71,8 +72,9 @@ export function TokenMesh({
const { front, back, walls } = useMemo(() => { const { front, back, walls } = useMemo(() => {
// The traced shape and its UV framing share the same transform, so the // The traced shape and its UV framing share the same transform, so the
// full image rectangle maps to the same bounds in mesh coordinates. // full image rectangle maps to the same bounds in mesh coordinates.
const shape = trace ? toMeshShape(trace) : circleShape(); const scale = TOKEN_SIZE / Math.max(trace?.width ?? 0, trace?.height ?? 0);
const uvBounds = trace ? toUvBounds(trace) : undefined; const shape = trace ? traceToShape(trace, scale) : circleShape(TOKEN_SIZE / 2);
const uvBounds = trace ? traceToUvBounds(trace, scale) : undefined;
const parts = extrudeShapeParts(shape, { height: thickness, uvBounds }); const parts = extrudeShapeParts(shape, { height: thickness, uvBounds });
// Shared across tokens with the same source image (the trace is cached per // 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 // 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)); geo.setIndex(new THREE.BufferAttribute(indices, 1));
return geo; 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 { useSearchStore } from '../stores/searchStore';
import { modFileUrl } from '../api'; import { modFileUrl } from '../api';
import ObjectTree from '../components/ObjectTree'; import ObjectTree from '../components/ObjectTree';
import ErrorBoundary from '../components/ErrorBoundary'; import { ErrorBoundary } from '@tts/tabletop';
import { resolveViewer } from '../components/viewers'; import { resolveViewer } from '../components/viewers';
import { iconsForObject } from '../components/objectIcons'; import { iconsForObject } from '../components/objectIcons';
// Register the 3D viewers (side effect) so `resolveViewer` can find them. // 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 { useParams } from 'react-router-dom';
import { PartView, ErrorBoundary, TabletopProvider } from '@tts/tabletop'; import { PartView, ErrorBoundary } from '@tts/tabletop';
import Breadcrumbs from '../components/Breadcrumbs'; import Breadcrumbs from '../components/Breadcrumbs';
import PackageMissing from '../components/PackageMissing'; import PackageMissing from '../components/PackageMissing';
import Scene from '../components/viewers/Scene'; import Scene from '../components/viewers/Scene';
import { findPackage } from './bgm'; import { findPackage } from './bgm';
import { tabletopHttp } from '../tabletopHttp';
/** Detail view for a single part within a package. */ /** Detail view for a single part within a package. */
export default function PartPage() { export default function PartPage() {
@@ -39,14 +38,12 @@ export default function PartPage() {
</p> </p>
</div> </div>
{/* The boundary wraps the Canvas (Scene), not PartView, so its HTML {/* The boundary wraps the Canvas (Scene), not PartView, so its HTML
fallback renders outside the r3f namespace. The provider wires the fallback renders outside the r3f namespace. The library's proxy
library's proxy calls to the web app's proxy. */} calls default to the host's `/asset` and `/trace` paths. */}
<ErrorBoundary> <ErrorBoundary>
<TabletopProvider http={tabletopHttp}> <Scene>
<Scene> <PartView part={found} baseUrl={found.baseUrl} />
<PartView part={found} baseUrl={found.baseUrl} /> </Scene>
</Scene>
</TabletopProvider>
</ErrorBoundary> </ErrorBoundary>
<pre className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-xs text-zinc-400"> <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)} {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,
};
+5 -1
View File
@@ -30,7 +30,11 @@ A real 52-card deck: a single `card` part expanded by `$variants` into 52 cards,
### `packages/tabletop` — rendering library (new) ### `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) ### `apps/proxy` — local game assets (new)
+28
View File
@@ -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"
}
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { resolveAssetUrl } from './http.js'; import { resolveAssetUrl } from './asset.js';
describe('resolveAssetUrl', () => { describe('resolveAssetUrl', () => {
it('passes absolute urls through unchanged', () => { it('passes absolute urls through unchanged', () => {
+26
View File
@@ -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)}`;
}
+3
View File
@@ -0,0 +1,3 @@
export { resolveAssetUrl, assetUrl } from './asset.js';
export { traceImage } from './trace.js';
export type { TraceResult } from '@tts/shared';
+27
View File
@@ -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<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;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"lib": ["ES2022", "DOM"]
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
});
+2 -1
View File
@@ -2,4 +2,5 @@ export * from './types.js';
export * from './shapes.js'; export * from './shapes.js';
export * from './tessellate.js'; export * from './tessellate.js';
export * from './walls.js'; export * from './walls.js';
export * from './extrude.js'; export * from './extrude.js';
export * from './trace.js';
+45
View File
@@ -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 });
});
});
+67
View File
@@ -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;
}
+1 -1
View File
@@ -22,8 +22,8 @@
"@react-three/fiber": "^9.7.0", "@react-three/fiber": "^9.7.0",
"@react-three/postprocessing": "^3.0.4", "@react-three/postprocessing": "^3.0.4",
"@tts/bgm": "workspace:*", "@tts/bgm": "workspace:*",
"@tts/http": "workspace:*",
"@tts/mesh": "workspace:*", "@tts/mesh": "workspace:*",
"bson": "^7.3.1",
"react": "^19.2.8", "react": "^19.2.8",
"three": "^0.185.1", "three": "^0.185.1",
"zustand": "^5.0.14" "zustand": "^5.0.14"
+5 -51
View File
@@ -1,53 +1,7 @@
/** /**
* HTTP helpers for loading part assets. The only coupling to the host app's * HTTP helpers for loading part assets. Re-exported from `@tts/http`, the
* HTTP surface: the asset proxy (`/asset`) and the trace endpoint (`/trace`). * shared package for the host app's proxy (`/asset` and `/trace`) handlers.
* Kept as plain functions so they're testable without react-three. * Kept as a thin re-export so the tabletop's public surface is unchanged.
*/ */
import { deserialize } from 'bson'; export { resolveAssetUrl, assetUrl, traceImage } from '@tts/http';
export type { TraceResult } from '@tts/http';
/**
* 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;
}
+10 -31
View File
@@ -4,7 +4,13 @@
* app's `cardResolution.ts`). * app's `cardResolution.ts`).
*/ */
import type { Part, Crop } from '@tts/bgm'; 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. */ /** Scale from mm (the format's `size` unit) to world units. */
export const MM_TO_WORLD = 1 / 30; export const MM_TO_WORLD = 1 / 30;
@@ -67,15 +73,8 @@ export function traceToShape(
width: number, width: number,
height: number, height: number,
): Shape { ): Shape {
const { shape, width: tw, height: th } = trace; const { width: tw, height: th } = trace;
const scale = Math.min(width / tw, height / th); return meshTraceToShape(trace, 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)),
};
} }
/** /**
@@ -88,25 +87,5 @@ export function traceToUvBounds(
width: number, width: number,
height: number, height: number,
): { minX: number; minY: number; maxX: number; maxY: number } { ): { minX: number; minY: number; maxX: number; maxY: number } {
const scale = Math.min(width / trace.width, height / trace.height); return meshTraceToUvBounds(trace, 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;
} }
+22 -3
View File
@@ -87,6 +87,9 @@ importers:
'@tts/extract': '@tts/extract':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/extract version: link:../../packages/extract
'@tts/http':
specifier: workspace:*
version: link:../../packages/http
'@tts/mesh': '@tts/mesh':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/mesh version: link:../../packages/mesh
@@ -190,6 +193,22 @@ importers:
specifier: ^5.7.2 specifier: ^5.7.2
version: 5.9.3 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: packages/mesh:
dependencies: dependencies:
earcut: earcut:
@@ -233,12 +252,12 @@ importers:
'@tts/bgm': '@tts/bgm':
specifier: workspace:* specifier: workspace:*
version: link:../bgm version: link:../bgm
'@tts/http':
specifier: workspace:*
version: link:../http
'@tts/mesh': '@tts/mesh':
specifier: workspace:* specifier: workspace:*
version: link:../mesh version: link:../mesh
bson:
specifier: ^7.3.1
version: 7.3.1
react: react:
specifier: ^19.2.8 specifier: ^19.2.8
version: 19.2.8 version: 19.2.8