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.
91 lines
3.2 KiB
TypeScript
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));
|
|
} |