diff --git a/src/components/md-deck/PltPreview.tsx b/src/components/md-deck/PltPreview.tsx
index b178644..dc53fc3 100644
--- a/src/components/md-deck/PltPreview.tsx
+++ b/src/components/md-deck/PltPreview.tsx
@@ -1,6 +1,13 @@
import { createSignal, For, Show, createMemo } from 'solid-js';
import type { PageData } from './hooks/usePDFExport';
-import type { CardShape } from './types';
+import type { CardPath } from '../../plotcutter';
+import type { CardShape } from '../../plotcutter';
+import {
+ getCardShapePoints,
+ calculateCenter,
+ contourToSvgPath
+} from '../../plotcutter';
+import { generateTravelPaths, travelPathsToSvg } from '../../plotcutter';
import { pts2plotter } from '../../plotcutter';
export interface PltPreviewProps {
@@ -13,263 +20,40 @@ export interface PltPreviewProps {
onClose: () => void;
}
-export interface CardPath {
- pageIndex: number;
- cardIndex: number;
- points: [number, number][];
- centerX: number;
- centerY: number;
- pathD: string;
- startPoint: [number, number];
- endPoint: [number, number];
-}
-
/**
- * 生成带圆角的矩形路径点
- * @param width 矩形宽度
- * @param height 矩形高度
- * @param cornerRadius 圆角半径(mm)
- * @param segmentsPerCorner 每个圆角的分段数
+ * 生成卡片切割路径
*/
-function getRoundedRectPoints(
- width: number,
- height: number,
- cornerRadius: number,
- segmentsPerCorner: number = 4
-): [number, number][] {
- const points: [number, number][] = [];
- const r = Math.min(cornerRadius, width / 2, height / 2);
-
- if (r <= 0) {
- // 无圆角,返回普通矩形
- points.push([0, 0]);
- points.push([width, 0]);
- points.push([width, height]);
- points.push([0, height]);
- return points;
- }
-
- // 左上角圆角(从顶部开始,顺时针)
- for (let i = 0; i < segmentsPerCorner; i++) {
- const angle = (Math.PI / 2) * (i / segmentsPerCorner);
- points.push([
- r + r * Math.cos(angle - Math.PI / 2),
- r + r * Math.sin(angle - Math.PI / 2)
- ]);
- }
-
- // 右上角圆角
- for (let i = 0; i < segmentsPerCorner; i++) {
- const angle = (Math.PI / 2) * (i / segmentsPerCorner);
- points.push([
- width - r + r * Math.cos(angle),
- r + r * Math.sin(angle)
- ]);
- }
-
- // 右下角圆角
- for (let i = 0; i < segmentsPerCorner; i++) {
- const angle = (Math.PI / 2) * (i / segmentsPerCorner) + Math.PI / 2;
- points.push([
- width - r + r * Math.cos(angle),
- height - r + r * Math.sin(angle)
- ]);
- }
-
- // 左下角圆角
- for (let i = 0; i < segmentsPerCorner; i++) {
- const angle = (Math.PI / 2) * (i / segmentsPerCorner) + Math.PI;
- points.push([
- r + r * Math.cos(angle),
- height - r + r * Math.sin(angle)
- ]);
- }
-
- return points;
-}
-
-/**
- * 根据形状生成卡片轮廓点(单位:mm,相对于卡片左下角)
- */
-function getCardShapePoints(
+function generateCardPaths(
+ pages: PageData[],
+ cardWidth: number,
+ cardHeight: number,
shape: CardShape,
- width: number,
- height: number,
- cornerRadius: number = 0
-): [number, number][] {
- if (shape === 'rectangle' && cornerRadius > 0) {
- return getRoundedRectPoints(width, height, cornerRadius);
- }
-
- const points: [number, number][] = [];
-
- switch (shape) {
- case 'circle': {
- const radius = Math.min(width, height) / 2;
- const centerX = width / 2;
- const centerY = height / 2;
- for (let i = 0; i < 36; i++) {
- const angle = (i / 36) * Math.PI * 2;
- points.push([
- centerX + radius * Math.cos(angle),
- centerY + radius * Math.sin(angle)
- ]);
- }
- break;
- }
- case 'triangle': {
- points.push([width / 2, 0]);
- points.push([0, height]);
- points.push([width, height]);
- break;
- }
- case 'hexagon': {
- const halfW = width / 2;
- const quarterH = height / 4;
- points.push([halfW, 0]);
- points.push([width, quarterH]);
- points.push([width, height - quarterH]);
- points.push([halfW, height]);
- points.push([0, height - quarterH]);
- points.push([0, quarterH]);
- break;
- }
- case 'rectangle':
- default: {
- points.push([0, 0]);
- points.push([width, 0]);
- points.push([width, height]);
- points.push([0, height]);
- break;
- }
- }
-
- return points;
-}
-
-/**
- * 计算多边形的中心点
- */
-function calculateCenter(points: [number, number][]): { x: number; y: number } {
- let sumX = 0;
- let sumY = 0;
- for (const [x, y] of points) {
- sumX += x;
- sumY += y;
- }
- return {
- x: sumX / points.length,
- y: sumY / points.length
- };
-}
-
-/**
- * 根据进度计算小球在路径上的位置
- */
-function getPointOnPath(points: [number, number][], progress: number): [number, number] {
- if (points.length === 0) return [0, 0];
- if (points.length === 1) return points[0];
-
- const totalSegments = points.length;
- const scaledProgress = progress * totalSegments;
- const segmentIndex = Math.floor(scaledProgress);
- const segmentProgress = scaledProgress - segmentIndex;
-
- const currentIndex = Math.min(segmentIndex, points.length - 1);
- const nextIndex = (currentIndex + 1) % points.length;
-
- const [x1, y1] = points[currentIndex];
- const [x2, y2] = points[nextIndex];
-
- return [
- x1 + (x2 - x1) * segmentProgress,
- y1 + (y2 - y1) * segmentProgress
- ];
-}
-
-/**
- * 将路径点转换为 SVG path 命令
- */
-function pointsToSvgPath(points: [number, number][], closed = true): string {
- if (points.length === 0) return '';
-
- const [startX, startY] = points[0];
- let d = `M ${startX} ${startY}`;
-
- for (let i = 1; i < points.length; i++) {
- const [x, y] = points[i];
- d += ` L ${x} ${y}`;
- }
-
- if (closed) {
- d += ' Z';
- }
-
- return d;
-}
-
-/**
- * 生成空走路径(抬刀移动路径)
- */
-function generateTravelPaths(
- cardPaths: CardPath[],
+ bleed: number,
+ cornerRadius: number,
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;
-}
-
-/**
- * PLT 预览组件 - 显示切割路径预览
- */
-export function PltPreview(props: PltPreviewProps) {
- const a4Width = 297; // 横向 A4
- const a4Height = 210;
-
- // 使用传入的圆角值,但也允许用户修改
- const [cornerRadius, setCornerRadius] = createSignal(props.cornerRadius);
-
- // 收集所有卡片路径
+): CardPath[] {
const cardPaths: CardPath[] = [];
let pathIndex = 0;
// 计算切割尺寸(排版尺寸减去出血)
- const cutWidth = props.cardWidth - props.bleed * 2;
- const cutHeight = props.cardHeight - props.bleed * 2;
+ const cutWidth = cardWidth - bleed * 2;
+ const cutHeight = cardHeight - bleed * 2;
- for (const page of props.pages) {
+ for (const page of pages) {
for (const card of page.cards) {
if (card.side !== 'front') continue;
- const shapePoints = getCardShapePoints(props.shape, cutWidth, cutHeight, cornerRadius());
+ // 生成形状轮廓点(相对于卡片左下角)
+ const shapePoints = getCardShapePoints(shape, cutWidth, cutHeight, cornerRadius);
+
+ // 平移到页面坐标并翻转 Y 轴
const pagePoints = shapePoints.map(([x, y]) => [
- card.x + props.bleed + x,
- a4Height - (card.y + props.bleed + y)
+ card.x + bleed + x,
+ a4Height - (card.y + bleed + y)
] as [number, number]);
const center = calculateCenter(pagePoints);
- const pathD = pointsToSvgPath(pagePoints);
+ const pathD = contourToSvgPath(pagePoints);
// 起点和终点(对于闭合路径是同一点)
const startPoint = pagePoints[0];
@@ -288,21 +72,51 @@ export function PltPreview(props: PltPreviewProps) {
}
}
+ return cardPaths;
+}
+
+/**
+ * PLT 预览组件 - 显示切割路径预览
+ */
+export function PltPreview(props: PltPreviewProps) {
+ const a4Width = 297; // 横向 A4
+ const a4Height = 210;
+
+ // 使用传入的圆角值,但也允许用户修改
+ const [cornerRadius, setCornerRadius] = createSignal(props.cornerRadius);
+
+ // 生成所有卡片路径
+ const cardPaths = createMemo(() =>
+ generateCardPaths(
+ props.pages,
+ props.cardWidth,
+ props.cardHeight,
+ props.shape,
+ props.bleed,
+ cornerRadius(),
+ a4Height
+ )
+ );
+
// 生成空走路径
- const travelPaths = generateTravelPaths(cardPaths, a4Height);
- const travelPathD = travelPaths.map(path => pointsToSvgPath(path, false)).join(' ');
+ const travelPathD = createMemo(() => {
+ const travelPaths = generateTravelPaths(cardPaths(), a4Height);
+ return travelPathsToSvg(travelPaths);
+ });
// 生成 HPGL 代码用于下载
- const allPaths = cardPaths.map(p => p.points);
- const plotterCode = allPaths.length > 0 ? pts2plotter(allPaths, a4Width, a4Height, 1) : '';
+ const plotterCode = createMemo(() => {
+ const allPaths = cardPaths().map(p => p.points);
+ return allPaths.length > 0 ? pts2plotter(allPaths, a4Width, a4Height, 1) : '';
+ });
const handleDownload = () => {
- if (!plotterCode) {
+ if (!plotterCode()) {
alert('没有可导出的卡片');
return;
}
- const blob = new Blob([plotterCode], { type: 'application/vnd.hp-HPGL' });
+ const blob = new Blob([plotterCode()], { type: 'application/vnd.hp-HPGL' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
@@ -340,7 +154,7 @@ export function PltPreview(props: PltPreviewProps) {
@@ -358,7 +172,7 @@ export function PltPreview(props: PltPreviewProps) {
{(page) => {
- const pageCardPaths = cardPaths.filter(p => p.pageIndex === page.pageIndex);
+ const pageCardPaths = cardPaths().filter(p => p.pageIndex === page.pageIndex);
return (
{/* 空走路径(虚线) */}
-
+
void;
}
-/**
- * 生成带圆角的矩形路径点
- */
-function getRoundedRectPoints(
- width: number,
- height: number,
- cornerRadius: number,
- segmentsPerCorner: number = 4
-): [number, number][] {
- const points: [number, number][] = [];
- const r = Math.min(cornerRadius, width / 2, height / 2);
-
- if (r <= 0) {
- points.push([0, 0]);
- points.push([width, 0]);
- points.push([width, height]);
- points.push([0, height]);
- return points;
- }
-
- // 左上角圆角(从顶部开始,顺时针)
- for (let i = 0; i < segmentsPerCorner; i++) {
- const angle = (Math.PI / 2) * (i / segmentsPerCorner) - Math.PI / 2;
- points.push([
- r + r * Math.cos(angle),
- r + r * Math.sin(angle)
- ]);
- }
-
- // 右上角圆角
- for (let i = 0; i < segmentsPerCorner; i++) {
- const angle = (Math.PI / 2) * (i / segmentsPerCorner);
- points.push([
- width - r + r * Math.cos(angle),
- r + r * Math.sin(angle)
- ]);
- }
-
- // 右下角圆角
- for (let i = 0; i < segmentsPerCorner; i++) {
- const angle = (Math.PI / 2) * (i / segmentsPerCorner) + Math.PI / 2;
- points.push([
- width - r + r * Math.cos(angle),
- height - r + r * Math.sin(angle)
- ]);
- }
-
- // 左下角圆角
- for (let i = 0; i < segmentsPerCorner; i++) {
- const angle = (Math.PI / 2) * (i / segmentsPerCorner) + Math.PI;
- points.push([
- r + r * Math.cos(angle),
- height - r + r * Math.sin(angle)
- ]);
- }
-
- return points;
-}
-
-/**
- * 根据形状生成卡片轮廓点(单位:mm,相对于卡片左下角)
- */
-function getCardShapePoints(
- shape: CardShape,
- width: number,
- height: number,
- cornerRadius: number = 0
-): [number, number][] {
- if (shape === 'rectangle' && cornerRadius > 0) {
- return getRoundedRectPoints(width, height, cornerRadius);
- }
-
- const points: [number, number][] = [];
-
- switch (shape) {
- case 'circle': {
- const radius = Math.min(width, height) / 2;
- const centerX = width / 2;
- const centerY = height / 2;
- for (let i = 0; i < 36; i++) {
- const angle = (i / 36) * Math.PI * 2;
- points.push([
- centerX + radius * Math.cos(angle),
- centerY + radius * Math.sin(angle)
- ]);
- }
- break;
- }
- case 'triangle': {
- points.push([width / 2, 0]);
- points.push([0, height]);
- points.push([width, height]);
- break;
- }
- case 'hexagon': {
- const halfW = width / 2;
- const quarterH = height / 4;
- points.push([halfW, 0]);
- points.push([width, quarterH]);
- points.push([width, height - quarterH]);
- points.push([halfW, height]);
- points.push([0, height - quarterH]);
- points.push([0, quarterH]);
- break;
- }
- case 'rectangle':
- default: {
- points.push([0, 0]);
- points.push([width, 0]);
- points.push([width, height]);
- points.push([0, height]);
- break;
- }
- }
-
- return points;
-}
-
-/**
- * 计算多边形的中心点
- */
-function calculateCenter(points: [number, number][]): { x: number; y: number } {
- let sumX = 0;
- let sumY = 0;
- for (const [x, y] of points) {
- sumX += x;
- sumY += y;
- }
- return {
- x: sumX / points.length,
- y: sumY / points.length
- };
-}
-
/**
* 生成空走路径(抬刀移动路径)
* 从左上角出发,连接所有卡片的起点/终点,最后返回左上角
@@ -168,27 +38,27 @@ function generateTravelPaths(
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;
}
diff --git a/src/components/md-deck/types.ts b/src/components/md-deck/types.ts
index 42c1a87..34d50a9 100644
--- a/src/components/md-deck/types.ts
+++ b/src/components/md-deck/types.ts
@@ -4,7 +4,7 @@ export interface CardData {
export type CardSide = 'front' | 'back';
-export type CardShape = 'rectangle' | 'circle' | 'triangle' | 'hexagon';
+export type { CardShape } from '../../plotcutter/contour';
export interface Layer {
prop: string;
diff --git a/src/plotcutter/contour.ts b/src/plotcutter/contour.ts
new file mode 100644
index 0000000..2e6490e
--- /dev/null
+++ b/src/plotcutter/contour.ts
@@ -0,0 +1,245 @@
+export type CardShape = 'rectangle' | 'circle' | 'triangle' | 'hexagon';
+
+export interface ContourPoint {
+ x: number;
+ y: number;
+}
+
+export interface ContourBounds {
+ minX: number;
+ minY: number;
+ maxX: number;
+ maxY: number;
+}
+
+/**
+ * 生成带圆角的矩形轮廓点
+ * @param width 矩形宽度
+ * @param height 矩形高度
+ * @param cornerRadius 圆角半径(mm)
+ * @param segmentsPerCorner 每个圆角的分段数
+ */
+export function getRoundedRectPoints(
+ width: number,
+ height: number,
+ cornerRadius: number,
+ segmentsPerCorner: number = 4
+): [number, number][] {
+ const points: [number, number][] = [];
+ const r = Math.min(cornerRadius, width / 2, height / 2);
+
+ if (r <= 0) {
+ // 无圆角,返回普通矩形
+ points.push([0, 0]);
+ points.push([width, 0]);
+ points.push([width, height]);
+ points.push([0, height]);
+ return points;
+ }
+
+ // 左上角圆角(从顶部开始,顺时针)
+ for (let i = 0; i < segmentsPerCorner; i++) {
+ const angle = (Math.PI / 2) * (i / segmentsPerCorner);
+ points.push([
+ r + r * Math.cos(angle - Math.PI / 2),
+ r + r * Math.sin(angle - Math.PI / 2)
+ ]);
+ }
+
+ // 右上角圆角
+ for (let i = 0; i < segmentsPerCorner; i++) {
+ const angle = (Math.PI / 2) * (i / segmentsPerCorner);
+ points.push([
+ width - r + r * Math.cos(angle),
+ r + r * Math.sin(angle)
+ ]);
+ }
+
+ // 右下角圆角
+ for (let i = 0; i < segmentsPerCorner; i++) {
+ const angle = (Math.PI / 2) * (i / segmentsPerCorner) + Math.PI / 2;
+ points.push([
+ width - r + r * Math.cos(angle),
+ height - r + r * Math.sin(angle)
+ ]);
+ }
+
+ // 左下角圆角
+ for (let i = 0; i < segmentsPerCorner; i++) {
+ const angle = (Math.PI / 2) * (i / segmentsPerCorner) + Math.PI;
+ points.push([
+ r + r * Math.cos(angle),
+ height - r + r * Math.sin(angle)
+ ]);
+ }
+
+ return points;
+}
+
+/**
+ * 根据形状生成卡片轮廓点(单位:mm,相对于卡片左下角)
+ */
+export function getCardShapePoints(
+ shape: CardShape,
+ width: number,
+ height: number,
+ cornerRadius: number = 0
+): [number, number][] {
+ if (shape === 'rectangle' && cornerRadius > 0) {
+ return getRoundedRectPoints(width, height, cornerRadius);
+ }
+
+ const points: [number, number][] = [];
+
+ switch (shape) {
+ case 'circle': {
+ const radius = Math.min(width, height) / 2;
+ const centerX = width / 2;
+ const centerY = height / 2;
+ for (let i = 0; i < 36; i++) {
+ const angle = (i / 36) * Math.PI * 2;
+ points.push([
+ centerX + radius * Math.cos(angle),
+ centerY + radius * Math.sin(angle)
+ ]);
+ }
+ break;
+ }
+ case 'triangle': {
+ points.push([width / 2, 0]);
+ points.push([0, height]);
+ points.push([width, height]);
+ break;
+ }
+ case 'hexagon': {
+ const halfW = width / 2;
+ const quarterH = height / 4;
+ points.push([halfW, 0]);
+ points.push([width, quarterH]);
+ points.push([width, height - quarterH]);
+ points.push([halfW, height]);
+ points.push([0, height - quarterH]);
+ points.push([0, quarterH]);
+ break;
+ }
+ case 'rectangle':
+ default: {
+ points.push([0, 0]);
+ points.push([width, 0]);
+ points.push([width, height]);
+ points.push([0, height]);
+ break;
+ }
+ }
+
+ return points;
+}
+
+/**
+ * 计算多边形的中心点
+ */
+export function calculateCenter(points: [number, number][]): { x: number; y: number } {
+ let sumX = 0;
+ let sumY = 0;
+ for (const [x, y] of points) {
+ sumX += x;
+ sumY += y;
+ }
+ return {
+ x: sumX / points.length,
+ y: sumY / points.length
+ };
+}
+
+/**
+ * 计算轮廓的边界框
+ */
+export function calculateBounds(points: [number, number][]): ContourBounds {
+ if (points.length === 0) {
+ return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
+ }
+
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+
+ for (const [x, y] of 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 };
+}
+
+/**
+ * 根据进度计算点在路径上的位置
+ */
+export function getPointOnPath(points: [number, number][], progress: number): [number, number] {
+ if (points.length === 0) return [0, 0];
+ if (points.length === 1) return points[0];
+
+ const totalSegments = points.length;
+ const scaledProgress = progress * totalSegments;
+ const segmentIndex = Math.floor(scaledProgress);
+ const segmentProgress = scaledProgress - segmentIndex;
+
+ const currentIndex = Math.min(segmentIndex, points.length - 1);
+ const nextIndex = (currentIndex + 1) % points.length;
+
+ const [x1, y1] = points[currentIndex];
+ const [x2, y2] = points[nextIndex];
+
+ return [
+ x1 + (x2 - x1) * segmentProgress,
+ y1 + (y2 - y1) * segmentProgress
+ ];
+}
+
+/**
+ * 将轮廓点转换为 SVG path 命令
+ * @param points 轮廓点数组
+ * @param closed 是否闭合路径
+ */
+export function contourToSvgPath(points: [number, number][], closed = true): string {
+ if (points.length === 0) return '';
+
+ const [startX, startY] = points[0];
+ let d = `M ${startX} ${startY}`;
+
+ for (let i = 1; i < points.length; i++) {
+ const [x, y] = points[i];
+ d += ` L ${x} ${y}`;
+ }
+
+ if (closed) {
+ d += ' Z';
+ }
+
+ return d;
+}
+
+/**
+ * 平移轮廓点(添加偏移量)
+ */
+export function translateContour(
+ points: [number, number][],
+ offsetX: number,
+ offsetY: number
+): [number, number][] {
+ return points.map(([x, y]) => [x + offsetX, y + offsetY] as [number, number]);
+}
+
+/**
+ * 翻转轮廓(用于 SVG 坐标转换,Y 轴翻转)
+ * @param points 轮廓点
+ * @param height 画布高度
+ */
+export function flipContourY(
+ points: [number, number][],
+ height: number
+): [number, number][] {
+ return points.map(([x, y]) => [x, height - y] as [number, number]);
+}
diff --git a/src/plotcutter/index.ts b/src/plotcutter/index.ts
index 22bb6f1..aae761a 100644
--- a/src/plotcutter/index.ts
+++ b/src/plotcutter/index.ts
@@ -1,3 +1,5 @@
export * from "./bezier";
export * from "./vector";
-export * from "./plotter";
\ No newline at end of file
+export * from "./plotter";
+export * from "./contour";
+export * from "./layout";
\ No newline at end of file
diff --git a/src/plotcutter/layout.ts b/src/plotcutter/layout.ts
new file mode 100644
index 0000000..d156230
--- /dev/null
+++ b/src/plotcutter/layout.ts
@@ -0,0 +1,95 @@
+import { contourToSvgPath } from './contour';
+
+/**
+ * 卡片切割路径
+ */
+export interface CardPath {
+ pageIndex: number;
+ cardIndex: number;
+ points: [number, number][];
+ centerX: number;
+ centerY: number;
+ pathD: string;
+ startPoint: [number, number];
+ endPoint: [number, number];
+}
+
+/**
+ * 生成空走路径(抬刀移动路径)
+ * @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
+ };
+}