ttrpg-tools/src/plotcutter/layout.ts

236 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { contourToSvgPath } from './contour';
import { getCardShapePoints, calculateCenter } from './contour';
import type { CardPath, SinglePageLayout, LayoutOptions, CardPosition } from './types';
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;
/**
* 生成空走路径(抬刀移动路径)
* @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
};
}
/**
* 计算单页满排时的排版布局和刀路
*
* 此函数只关心单页排满的情况,不考虑实际牌组的张数。
* 返回的布局信息可以用于:
* - 预览单页最大容量时的刀路
* - 计算排版参数
* - 生成 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;
}