/** * 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; }