Files
tts-workshop/packages/tabletop/src/part.ts
T
hypercross 15c45e7106 refactor: share trace, error boundary, and proxy http helpers
Lift the trace-to-shape geometry into @tts/mesh (traceToShape/
traceToUvBounds), parameterized by scale so the web token viewer and
@tts/tabletop share one implementation. Move the CORS proxy HTTP helpers
(assetUrl, resolveAssetUrl, traceImage) into a new @tts/http package, and
make @tts/tabletop's ErrorBoundary the single source used by the web app.

This removes the duplicated ErrorBoundary, assetUrl, tabletopHttp, and
trace-to-shape code from apps/web and packages/tabletop.
2026-08-09 22:17:24 +08:00

91 lines
3.2 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,
traceToShape as meshTraceToShape,
traceToUvBounds as meshTraceToUvBounds,
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 { width: tw, height: th } = trace;
return meshTraceToShape(trace, Math.min(width / tw, height / th));
}
/**
* 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 } {
return meshTraceToUvBounds(trace, Math.min(width / trace.width, height / trace.height));
}