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:
@@ -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 { resolveAssetUrl } from './http.js';
|
||||
import { resolveAssetUrl } from './asset.js';
|
||||
|
||||
describe('resolveAssetUrl', () => {
|
||||
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 './tessellate.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/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"
|
||||
|
||||
@@ -1,53 +1,7 @@
|
||||
/**
|
||||
* HTTP helpers for loading part assets. The only coupling to the host app's
|
||||
* HTTP surface: the asset proxy (`/asset`) and the trace endpoint (`/trace`).
|
||||
* Kept as plain functions so they're testable without react-three.
|
||||
* HTTP helpers for loading part assets. Re-exported from `@tts/http`, the
|
||||
* shared package for the host app's proxy (`/asset` and `/trace`) handlers.
|
||||
* Kept as a thin re-export so the tabletop's public surface is unchanged.
|
||||
*/
|
||||
import { deserialize } from 'bson';
|
||||
|
||||
/**
|
||||
* Resolve a part's asset URL. Absolute URLs (http/https, data:, blob:) pass
|
||||
* through unchanged; relative paths are resolved against `baseUrl` — the
|
||||
* directory of the part's source file (the json/yaml/markdown that defined
|
||||
* it). Without a `baseUrl`, a relative path is returned as-is.
|
||||
*/
|
||||
export function resolveAssetUrl(url: string, baseUrl?: string): string {
|
||||
if (/^(https?:|data:|blob:)/i.test(url)) return url;
|
||||
if (!baseUrl) return url;
|
||||
// `baseUrl` is a path relative to the games root (no scheme), so join rather
|
||||
// than resolve as a URL. A fake origin lets `new URL` normalize `./` and
|
||||
// `../`; the origin is stripped from the result.
|
||||
const resolved = new URL(url, `http://localhost/${baseUrl}`).pathname;
|
||||
return resolved.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
/** Route an external asset URL through the proxy so three.js can load it. */
|
||||
export function assetUrl(url: string): string {
|
||||
return `/asset?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
|
||||
export interface TraceResult {
|
||||
width: number;
|
||||
height: number;
|
||||
shape?: { outline: number[][]; holes?: number[][][] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace an image into a vector shape, BSON-deserializing the proxy response.
|
||||
* `mode` controls how the region is derived (`alpha`, `bw`, `color`); a
|
||||
* non-zero `offset` insets (negative) or outsets (positive) the shape.
|
||||
*/
|
||||
export async function traceImage(
|
||||
url: string,
|
||||
mode: 'alpha' | 'bw' | 'color' = 'alpha',
|
||||
offset?: number,
|
||||
): Promise<TraceResult> {
|
||||
const params = new URLSearchParams({ url, mode });
|
||||
if (offset !== undefined) params.set('offset', String(offset));
|
||||
const res = await fetch(`/trace?${params.toString()}`);
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new Error(body?.error ?? `Trace failed (${res.status})`);
|
||||
}
|
||||
return deserialize(new Uint8Array(await res.arrayBuffer())) as TraceResult;
|
||||
}
|
||||
export { resolveAssetUrl, assetUrl, traceImage } from '@tts/http';
|
||||
export type { TraceResult } from '@tts/http';
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user