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
+2 -1
View File
@@ -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';
+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;
}