112 lines
4.1 KiB
TypeScript
112 lines
4.1 KiB
TypeScript
/**
|
|
* 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;
|
|
} |