ttrpg-tools/src/plotcutter/layout.ts

236 lines
6.2 KiB
TypeScript
Raw Normal View History

2026-03-15 01:43:25 +08:00
import { contourToSvgPath } from './contour';
2026-03-15 09:29:18 +08:00
import { getCardShapePoints, calculateCenter } from './contour';
import type { CardPath, SinglePageLayout, LayoutOptions, CardPosition } from './types';
2026-03-15 01:43:25 +08:00
2026-03-15 09:29:18 +08:00
const A4_WIDTH_PORTRAIT = 210;
const A4_HEIGHT_PORTRAIT = 297;
const A4_WIDTH_LANDSCAPE = 297;
const A4_HEIGHT_LANDSCAPE = 210;
const DEFAULT_PRINT_MARGIN = 5;
2026-03-15 01:43:25 +08:00
/**
*
* @param cardPaths
* @param a4Height A4
*/
export function generateTravelPaths(
cardPaths: CardPath[],
a4Height: number
): [number, number][][] {
const travelPaths: [number, number][][] = [];
// 起点:左上角 (0, a4Height) - 注意 SVG 坐标 Y 向下plotter 坐标 Y 向上
const startPoint: [number, number] = [0, a4Height];
if (cardPaths.length === 0) {
return travelPaths;
}
// 从起点到第一张卡的起点
travelPaths.push([startPoint, cardPaths[0].startPoint]);
// 卡片之间的移动
for (let i = 0; i < cardPaths.length - 1; i++) {
const currentEnd = cardPaths[i].endPoint;
const nextStart = cardPaths[i + 1].startPoint;
travelPaths.push([currentEnd, nextStart]);
}
// 从最后一张卡返回起点
travelPaths.push([cardPaths[cardPaths.length - 1].endPoint, startPoint]);
return travelPaths;
}
/**
* SVG path
*/
export function travelPathsToSvg(travelPaths: [number, number][][]): string {
return travelPaths.map(path => contourToSvgPath(path, false)).join(' ');
}
/**
*
*/
export function calculateTotalBounds(cardPaths: CardPath[]): {
minX: number;
minY: number;
maxX: number;
maxY: number;
width: number;
height: number;
} {
if (cardPaths.length === 0) {
return { minX: 0, minY: 0, maxX: 0, maxY: 0, width: 0, height: 0 };
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const cardPath of cardPaths) {
for (const [x, y] of cardPath.points) {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
}
return {
minX,
minY,
maxX,
maxY,
width: maxX - minX,
height: maxY - minY
};
}
2026-03-15 09:29:18 +08:00
/**
*
*
*
*
* -
* -
* - PLT
*/
export function calculateSinglePageLayout(options: LayoutOptions): SinglePageLayout {
const {
cardWidth,
cardHeight,
shape,
bleed,
cornerRadius,
orientation,
printMargin = DEFAULT_PRINT_MARGIN
} = options;
// 确定 A4 尺寸
const a4Width = orientation === 'landscape' ? A4_WIDTH_LANDSCAPE : A4_WIDTH_PORTRAIT;
const a4Height = orientation === 'landscape' ? A4_HEIGHT_LANDSCAPE : A4_HEIGHT_PORTRAIT;
// 计算可用区域
const usableWidth = a4Width - printMargin * 2;
const usableHeight = a4Height - printMargin * 2;
// 计算每行/每页的卡片数
const cardsPerRow = Math.floor(usableWidth / cardWidth);
const rowsPerPage = Math.floor(usableHeight / cardHeight);
const cardsPerPage = cardsPerRow * rowsPerPage;
// 计算网格居中偏移
const maxGridWidth = cardsPerRow * cardWidth;
const maxGridHeight = rowsPerPage * cardHeight;
const offsetX = (a4Width - maxGridWidth) / 2;
const offsetY = (a4Height - maxGridHeight) / 2;
// 计算切割尺寸(排版尺寸减去出血)
const cutWidth = cardWidth - bleed * 2;
const cutHeight = cardHeight - bleed * 2;
// 生成卡片位置和刀路
const cardPositions: CardPosition[] = [];
const cardPaths: CardPath[] = [];
for (let i = 0; i < cardsPerPage; i++) {
const row = Math.floor(i / cardsPerRow);
const col = i % cardsPerRow;
// 卡片位置(左下角坐标)
const x = offsetX + col * cardWidth;
const y = offsetY + row * cardHeight;
cardPositions.push({ x, y, cardIndex: i });
// 生成形状轮廓点(相对于卡片左下角)
const shapePoints = getCardShapePoints(shape, cutWidth, cutHeight, cornerRadius);
// 平移到页面坐标并翻转 Y 轴SVG Y 向下plotter Y 向上)
const pagePoints = shapePoints.map(([px, py]) => [
x + bleed + px,
a4Height - (y + bleed + py)
] as [number, number]);
const center = calculateCenter(pagePoints);
const pathD = contourToSvgPath(pagePoints);
const startPoint = pagePoints[0];
const endPoint = pagePoints[pagePoints.length - 1];
cardPaths.push({
pageIndex: 0,
cardIndex: i,
points: pagePoints,
centerX: center.x,
centerY: center.y,
pathD,
startPoint,
endPoint
});
}
return {
cardPositions,
cardPaths,
a4Width,
a4Height,
cardsPerRow,
rowsPerPage,
cardsPerPage
};
}
/**
*
*/
export function generateCardPathsFromPositions(
positions: CardPosition[],
options: Omit<LayoutOptions, 'orientation' | 'printMargin'> & { a4Height: number }
): CardPath[] {
const {
cardWidth,
cardHeight,
shape,
bleed,
cornerRadius,
a4Height
} = options;
// 计算切割尺寸
const cutWidth = cardWidth - bleed * 2;
const cutHeight = cardHeight - bleed * 2;
const cardPaths: CardPath[] = [];
for (const pos of positions) {
// 生成形状轮廓点(相对于卡片左下角)
const shapePoints = getCardShapePoints(shape, cutWidth, cutHeight, cornerRadius);
// 平移到页面坐标并翻转 Y 轴
const pagePoints = shapePoints.map(([px, py]) => [
pos.x + bleed + px,
a4Height - (pos.y + bleed + py)
] as [number, number]);
const center = calculateCenter(pagePoints);
const pathD = contourToSvgPath(pagePoints);
const startPoint = pagePoints[0];
const endPoint = pagePoints[pagePoints.length - 1];
cardPaths.push({
pageIndex: 0,
cardIndex: pos.cardIndex,
points: pagePoints,
centerX: center.x,
centerY: center.y,
pathD,
startPoint,
endPoint
});
}
return cardPaths;
}