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:
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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)}`;
|
|
||||||
}
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { resolveAssetUrl, assetUrl } from './asset.js';
|
||||||
|
export { traceImage } from './trace.js';
|
||||||
|
export type { TraceResult } from '@tts/shared';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"lib": ["ES2022", "DOM"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
Generated
+22
-3
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user