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.
67 lines
2.5 KiB
TypeScript
67 lines
2.5 KiB
TypeScript
/**
|
|
* 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;
|
|
} |