/** * Pure helpers for rendering a bgm `Part` as a mesh. Kept free of react-three * so they can be unit-tested in a plain node environment (mirroring the web * app's `cardResolution.ts`). */ import type { Part, Crop } from '@tts/bgm'; import { rectShape, roundedRectShape, type Shape } from '@tts/mesh'; /** Scale from mm (the format's `size` unit) to world units. */ export const MM_TO_WORLD = 1 / 30; /** Default part size `[w, h, d]` in mm when a part has no `size`. */ const DEFAULT_SIZE: [number, number, number] = [60, 60, 3]; /** A part's dimensions in world units, derived from its `size` (mm). */ export function partDimensions(part: Part): { width: number; height: number; depth: number } { const [w, h, d] = part.size ?? DEFAULT_SIZE; return { width: w * MM_TO_WORLD, height: h * MM_TO_WORLD, depth: d * MM_TO_WORLD, }; } /** * UV repeat/offset that selects a single sprite from a sheet, given a crop * `[col, row, cols, rows]` that divides the image into a `cols` x `rows` grid * and picks the cell at `[col, row]`. Row 0 is the top of the image (v=1), so * the offset counts down from 1. Without a crop, the whole image is shown. */ export function spriteUvFromCrop( crop: Crop | undefined, ): { repeatX: number; repeatY: number; offsetX: number; offsetY: number } { if (!crop) return { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 }; const [col, row, cols, rows] = crop; return { repeatX: 1 / cols, repeatY: 1 / rows, offsetX: col / cols, offsetY: 1 - (row + 1) / rows, }; } /** * The fallback footprint for a part without a `shape` sprite: a rounded rect * when `fillet` is set, otherwise a plain rect, sized to the part's world * dimensions. The fillet (mm) is converted to world units and clamped to half * the smaller dimension. */ export function fallbackShape(part: Part, width: number, height: number): Shape { const fillet = (part.fillet ?? 0) * MM_TO_WORLD; if (fillet > 0) { return roundedRectShape(width, height, Math.min(fillet, Math.min(width, height) / 2)); } return rectShape(width, height); } /** * Convert a traced shape (image pixel coords, origin top-left, y-down) to a * mesh `Shape` (y-up, centered at the origin), scaled to fit the part's world * `width` x `height` box. Flips the y-axis, centers the result, and normalizes * winding so the outline is counter-clockwise and holes are clockwise (as * `@tts/mesh` expects). */ export function traceToShape( trace: { shape: { outline: number[][]; holes?: number[][][] }; width: number; height: number }, 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)), }; } /** * 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 }, 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; }