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;
|
|
|
|
|
|
}
|