refactor: move code to plotcutter

This commit is contained in:
2026-03-15 09:29:18 +08:00
parent 025f5a46b0
commit 28794fd9f0
8 changed files with 737 additions and 314 deletions
+1 -13
View File
@@ -1,16 +1,4 @@
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;
}
import type { CardShape, ContourPoint, ContourBounds } from './types';
/**
* 生成带圆角的矩形轮廓点
+3 -1
View File
@@ -2,4 +2,6 @@ export * from "./bezier";
export * from "./vector";
export * from "./plotter";
export * from "./contour";
export * from "./layout";
export * from "./layout";
export * from "./types";
export * from "./parser";
+153 -13
View File
@@ -1,18 +1,12 @@
import { contourToSvgPath } from './contour';
import { getCardShapePoints, calculateCenter } from './contour';
import type { CardPath, SinglePageLayout, LayoutOptions, CardPosition } from './types';
/**
* 卡片切割路径
*/
export interface CardPath {
pageIndex: number;
cardIndex: number;
points: [number, number][];
centerX: number;
centerY: number;
pathD: string;
startPoint: [number, number];
endPoint: [number, number];
}
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;
/**
* 生成空走路径(抬刀移动路径)
@@ -93,3 +87,149 @@ export function calculateTotalBounds(cardPaths: CardPath[]): {
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;
}
+221
View File
@@ -0,0 +1,221 @@
import type { ParsedPlt } from './types';
/**
* HPGL 单位转换系数
*/
const HPGL_UNIT = 0.025; // 1 HPGL unit = 0.025mm
/**
* 解析 HPGL/PLT 代码,提取路径信息
*
* 支持的命令:
* - IN: 初始化
* - TB: 定义绘图区域
* - CT: 设置切线角度
* - SP: 选择画笔
* - PU: 抬笔移动
* - PD: 下笔绘制
* - D: 绘制到指定点(简写)
* - U: 抬笔移动到指定点(简写)
* - @: 结束命令
*/
export function parsePlt(pltCode: string): ParsedPlt {
const result: ParsedPlt = {
paths: [],
startPoint: undefined,
endPoint: undefined,
width: undefined,
height: undefined
};
// 解析 TB 命令获取页面尺寸
const tbMatch = pltCode.match(/TB(\d+),(\d+),(\d+)/);
if (tbMatch) {
const width = Number(tbMatch[2]) * HPGL_UNIT;
const height = Number(tbMatch[3]) * HPGL_UNIT;
result.width = width;
result.height = height;
}
// 提取所有下笔绘制的路径
// 解析逻辑:跟踪 PU/PD/U/D 命令,收集 PD/D 命令的点
const lines = pltCode.split('\n');
let currentPath: [number, number][] = [];
let isPenDown = false;
let currentPosition: [number, number] = [0, 0];
for (const line of lines) {
const trimmed = line.trim();
// 解析 PU 命令(抬笔移动)
const puMatch = trimmed.match(/PU\s*(\d+(?:\.\d+)?),(\d+(?:\.\d+)?)/i);
if (puMatch) {
if (currentPath.length > 0) {
result.paths.push([...currentPath]);
currentPath = [];
}
isPenDown = false;
currentPosition = [
Number(puMatch[1]) * HPGL_UNIT,
Number(puMatch[2]) * HPGL_UNIT
];
continue;
}
// 解析 PD 命令(下笔绘制)
const pdMatch = trimmed.match(/PD\s*(\d+(?:\.\d+)?),(\d+(?:\.\d+)?)/i);
if (pdMatch) {
const newPoint: [number, number] = [
Number(pdMatch[1]) * HPGL_UNIT,
Number(pdMatch[2]) * HPGL_UNIT
];
if (!isPenDown) {
// 新的路径起点
currentPath = [newPoint];
isPenDown = true;
} else {
currentPath.push(newPoint);
}
currentPosition = newPoint;
continue;
}
// 解析 U 命令(抬笔移动,简写)
const uMatch = trimmed.match(/U\s*(\d+(?:\.\d+)?),(\d+(?:\.\d+)?)/i);
if (uMatch) {
if (currentPath.length > 0) {
result.paths.push([...currentPath]);
currentPath = [];
}
isPenDown = false;
currentPosition = [
Number(uMatch[1]) * HPGL_UNIT,
Number(uMatch[2]) * HPGL_UNIT
];
continue;
}
// 解析 D 命令(下笔绘制,简写)
const dMatch = trimmed.match(/D\s*(\d+(?:\.\d+)?),(\d+(?:\.\d+)?)/i);
if (dMatch) {
const newPoint: [number, number] = [
Number(dMatch[1]) * HPGL_UNIT,
Number(dMatch[2]) * HPGL_UNIT
];
if (!isPenDown) {
// 新的路径起点
currentPath = [newPoint];
isPenDown = true;
} else {
currentPath.push(newPoint);
}
currentPosition = newPoint;
continue;
}
// 解析 PA 命令(绝对坐标移动)
const paMatch = trimmed.match(/PA\s*(\d+(?:\.\d+)?),(\d+(?:\.\d+)?)/i);
if (paMatch) {
const newPoint: [number, number] = [
Number(paMatch[1]) * HPGL_UNIT,
Number(paMatch[2]) * HPGL_UNIT
];
if (isPenDown) {
currentPath.push(newPoint);
} else {
if (currentPath.length > 0) {
result.paths.push([...currentPath]);
currentPath = [];
}
currentPosition = newPoint;
}
continue;
}
// 检查结束命令
if (trimmed.includes('@')) {
if (currentPath.length > 0) {
result.paths.push([...currentPath]);
currentPath = [];
}
break;
}
}
// 处理最后一条路径
if (currentPath.length > 0) {
result.paths.push([...currentPath]);
}
// 设置起点和终点
if (result.paths.length > 0) {
const firstPath = result.paths[0];
const lastPath = result.paths[result.paths.length - 1];
if (firstPath.length > 0) {
result.startPoint = firstPath[0];
}
if (lastPath.length > 0) {
result.endPoint = lastPath[lastPath.length - 1];
}
}
return result;
}
/**
* 从解析的 PLT 数据中提取切割路径(排除空走路径)
*
* 空走路径特征:
* - 路径长度短(通常是直线移动)
* - 连接两个切割路径的终点和起点
*
* @param parsedPlt 解析后的 PLT 数据
* @param minCutLength 最小切割路径长度阈值(mm),小于此值的路径被视为空走路径
*/
export function extractCutPaths(parsedPlt: ParsedPlt, minCutLength: number = 5): [number, number][][] {
const allPaths = parsedPlt.paths;
const cutPaths: [number, number][][] = [];
for (const path of allPaths) {
if (path.length < 2) continue;
// 计算路径长度
let pathLength = 0;
for (let i = 1; i < path.length; i++) {
const [x1, y1] = path[i - 1];
const [x2, y2] = path[i];
const dx = x2 - x1;
const dy = y2 - y1;
pathLength += Math.sqrt(dx * dx + dy * dy);
}
// 如果路径长度大于阈值,认为是切割路径
if (pathLength >= minCutLength) {
cutPaths.push(path);
}
}
return cutPaths;
}
/**
* 将解析的路径转换为 SVG path 命令
*/
export function parsedPltToSvg(paths: [number, number][]): string {
if (paths.length === 0) return '';
const [startX, startY] = paths[0];
let d = `M ${startX} ${startY}`;
for (let i = 1; i < paths.length; i++) {
const [x, y] = paths[i];
d += ` L ${x} ${y}`;
}
d += ' Z';
return d;
}
+101
View File
@@ -0,0 +1,101 @@
/**
* 卡片形状类型
*/
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;
}
/**
* 卡片切割路径
*/
export interface CardPath {
pageIndex: number;
cardIndex: number;
points: [number, number][];
centerX: number;
centerY: number;
pathD: string;
startPoint: [number, number];
endPoint: [number, number];
}
/**
* 单页排版结果
*/
export interface SinglePageLayout {
/** 单页满排时的卡片位置 */
cardPositions: CardPosition[];
/** 对应的刀路路径 */
cardPaths: CardPath[];
/** A4 纸宽度 (mm) */
a4Width: number;
/** A4 纸高度 (mm) */
a4Height: number;
/** 每行卡片数 */
cardsPerRow: number;
/** 每页行数 */
rowsPerPage: number;
/** 每页总卡片数 */
cardsPerPage: number;
}
/**
* 卡片位置
*/
export interface CardPosition {
x: number;
y: number;
cardIndex: number;
}
/**
* 排版选项
*/
export interface LayoutOptions {
/** 卡片宽度 (mm) */
cardWidth: number;
/** 卡片高度 (mm) */
cardHeight: number;
/** 卡片形状 */
shape: CardShape;
/** 出血 (mm) */
bleed: number;
/** 圆角半径 (mm) */
cornerRadius: number;
/** 打印方向 */
orientation: 'portrait' | 'landscape';
/** 打印边距 (mm) */
printMargin?: number;
}
/**
* PLT 解析结果
*/
export interface ParsedPlt {
/** 解析出的路径 */
paths: [number, number][][];
/** 起点坐标 (mm) */
startPoint?: [number, number];
/** 终点坐标 (mm) */
endPoint?: [number, number];
/** 页面宽度 (mm) */
width?: number;
/** 页面高度 (mm) */
height?: number;
}