feat(tabletop): inject proxy handlers from web app

This commit is contained in:
2026-08-09 21:46:36 +08:00
parent eefce5487d
commit 521519ce3d
15 changed files with 670 additions and 0 deletions
+112
View File
@@ -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;
}