feat(tabletop): inject proxy handlers from web app
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user