From 28794fd9f0622c1c6faa31da40a0805e1a017e55 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sun, 15 Mar 2026 09:29:18 +0800 Subject: [PATCH] refactor: move code to plotcutter --- src/components/md-deck/PltPreview.tsx | 375 ++++++++++-------- src/components/md-deck/PrintPreview.tsx | 11 +- .../md-deck/hooks/usePlotterExport.ts | 159 +++----- src/plotcutter/contour.ts | 14 +- src/plotcutter/index.ts | 4 +- src/plotcutter/layout.ts | 166 +++++++- src/plotcutter/parser.ts | 221 +++++++++++ src/plotcutter/types.ts | 101 +++++ 8 files changed, 737 insertions(+), 314 deletions(-) create mode 100644 src/plotcutter/parser.ts create mode 100644 src/plotcutter/types.ts diff --git a/src/components/md-deck/PltPreview.tsx b/src/components/md-deck/PltPreview.tsx index dc53fc3..645c99b 100644 --- a/src/components/md-deck/PltPreview.tsx +++ b/src/components/md-deck/PltPreview.tsx @@ -1,5 +1,7 @@ import { createSignal, For, Show, createMemo } from 'solid-js'; -import type { PageData } from './hooks/usePDFExport'; +import { parsePlt, extractCutPaths, parsedPltToSvg } from '../../plotcutter/parser'; +import { generateTravelPaths, travelPathsToSvg } from '../../plotcutter/layout'; +import { pts2plotter } from '../../plotcutter/plotter'; import type { CardPath } from '../../plotcutter'; import type { CardShape } from '../../plotcutter'; import { @@ -7,116 +9,166 @@ import { calculateCenter, contourToSvgPath } from '../../plotcutter'; -import { generateTravelPaths, travelPathsToSvg } from '../../plotcutter'; -import { pts2plotter } from '../../plotcutter'; export interface PltPreviewProps { - pages: PageData[]; - cardWidth: number; - cardHeight: number; + /** PLT 文件内容 */ + pltCode: string; + /** 卡片形状(用于生成刀路) */ shape: CardShape; + /** 卡片宽度 (mm) */ + cardWidth: number; + /** 卡片高度 (mm) */ + cardHeight: number; + /** 出血 (mm) */ bleed: number; + /** 圆角半径 (mm) */ cornerRadius: number; + /** 打印方向 */ + orientation: 'portrait' | 'landscape'; + /** 关闭回调 */ onClose: () => void; } /** - * 生成卡片切割路径 + * 从 PLT 代码解析并生成卡片路径数据 */ -function generateCardPaths( - pages: PageData[], - cardWidth: number, - cardHeight: number, - shape: CardShape, - bleed: number, - cornerRadius: number, - a4Height: number -): CardPath[] { - const cardPaths: CardPath[] = []; - let pathIndex = 0; +function parsePltToCardPaths(pltCode: string, a4Height: number): { + cutPaths: [number, number][][]; + cardPaths: CardPath[]; +} { + const parsed = parsePlt(pltCode); + const cutPaths = extractCutPaths(parsed, 5); // 5mm 阈值 - // 计算切割尺寸(排版尺寸减去出血) - const cutWidth = cardWidth - bleed * 2; - const cutHeight = cardHeight - bleed * 2; + // 将解析的路径转换为 CardPath 格式用于显示 + const cardPaths: CardPath[] = cutPaths.map((points, index) => { + const center = calculateCenter(points); + const pathD = contourToSvgPath(points); + const startPoint = points[0]; + const endPoint = points[points.length - 1]; - for (const page of pages) { - for (const card of page.cards) { - if (card.side !== 'front') continue; + return { + pageIndex: 0, + cardIndex: index, + points, + centerX: center.x, + centerY: center.y, + pathD, + startPoint, + endPoint + }; + }); - // 生成形状轮廓点(相对于卡片左下角) - const shapePoints = getCardShapePoints(shape, cutWidth, cutHeight, cornerRadius); - - // 平移到页面坐标并翻转 Y 轴 - const pagePoints = shapePoints.map(([x, y]) => [ - card.x + bleed + x, - a4Height - (card.y + bleed + y) - ] as [number, number]); - - const center = calculateCenter(pagePoints); - const pathD = contourToSvgPath(pagePoints); - - // 起点和终点(对于闭合路径是同一点) - const startPoint = pagePoints[0]; - const endPoint = pagePoints[pagePoints.length - 1]; - - cardPaths.push({ - pageIndex: page.pageIndex, - cardIndex: pathIndex++, - points: pagePoints, - centerX: center.x, - centerY: center.y, - pathD, - startPoint, - endPoint - }); - } - } - - return cardPaths; + return { cutPaths, cardPaths }; } /** - * PLT 预览组件 - 显示切割路径预览 + * 生成单页满排时的 PLT 代码(用于预览对比) + */ +function generateSinglePagePlt( + shape: CardShape, + cardWidth: number, + cardHeight: number, + bleed: number, + cornerRadius: number, + orientation: 'portrait' | 'landscape' +): string { + const a4Width = orientation === 'landscape' ? 297 : 210; + const a4Height = orientation === 'landscape' ? 210 : 297; + const printMargin = 5; + + 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 allPaths: [number, number][][] = []; + + 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; + + const shapePoints = getCardShapePoints(shape, cutWidth, cutHeight, cornerRadius); + const pagePoints = shapePoints.map(([px, py]) => [ + x + bleed + px, + a4Height - (y + bleed + py) + ] as [number, number]); + + allPaths.push(pagePoints); + } + + if (allPaths.length === 0) return ''; + + const startPoint: [number, number] = [0, a4Height]; + const endPoint: [number, number] = [0, a4Height]; + return pts2plotter(allPaths, a4Width, a4Height, 1, startPoint, endPoint); +} + +/** + * PLT 预览组件 - 基于 PLT 文本解析显示切割路径预览 */ export function PltPreview(props: PltPreviewProps) { - const a4Width = 297; // 横向 A4 - const a4Height = 210; + const a4Width = props.orientation === 'landscape' ? 297 : 210; + const a4Height = props.orientation === 'landscape' ? 210 : 297; // 使用传入的圆角值,但也允许用户修改 const [cornerRadius, setCornerRadius] = createSignal(props.cornerRadius); - // 生成所有卡片路径 - const cardPaths = createMemo(() => - generateCardPaths( - props.pages, - props.cardWidth, - props.cardHeight, - props.shape, - props.bleed, - cornerRadius(), - a4Height - ) - ); + // 解析传入的 PLT 代码 + const parsedData = createMemo(() => { + if (!props.pltCode) { + return { cutPaths: [] as [number, number][][], cardPaths: [] as CardPath[] }; + } + return parsePltToCardPaths(props.pltCode, a4Height); + }); // 生成空走路径 const travelPathD = createMemo(() => { - const travelPaths = generateTravelPaths(cardPaths(), a4Height); + const cardPaths = parsedData().cardPaths; + if (cardPaths.length === 0) return ''; + const travelPaths = generateTravelPaths(cardPaths, a4Height); return travelPathsToSvg(travelPaths); }); - // 生成 HPGL 代码用于下载 - const plotterCode = createMemo(() => { - const allPaths = cardPaths().map(p => p.points); - return allPaths.length > 0 ? pts2plotter(allPaths, a4Width, a4Height, 1) : ''; + // 生成单页满排时的 PLT 代码(用于对比) + const singlePagePltCode = createMemo(() => { + return generateSinglePagePlt( + props.shape, + props.cardWidth, + props.cardHeight, + props.bleed, + cornerRadius(), + props.orientation + ); + }); + + // 生成当前 PLT 的 HPGL 代码(重新生成,确保圆角更新) + const currentPltCode = createMemo(() => { + const cardPaths = parsedData().cardPaths; + if (cardPaths.length === 0) return ''; + const allPaths = cardPaths.map(p => p.points); + const startPoint: [number, number] = [0, a4Height]; + const endPoint: [number, number] = [0, a4Height]; + return pts2plotter(allPaths, a4Width, a4Height, 1, startPoint, endPoint); }); const handleDownload = () => { - if (!plotterCode()) { + if (!currentPltCode()) { alert('没有可导出的卡片'); return; } - const blob = new Blob([plotterCode()], { type: 'application/vnd.hp-HPGL' }); + const blob = new Blob([currentPltCode()], { type: 'application/vnd.hp-HPGL' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; @@ -154,7 +206,7 @@ export function PltPreview(props: PltPreviewProps) { @@ -170,101 +222,82 @@ export function PltPreview(props: PltPreviewProps) { {/* 预览区域 */}
- - {(page) => { - const pageCardPaths = cardPaths().filter(p => p.pageIndex === page.pageIndex); - - return ( - - {/* A4 边框 */} - - - {/* 页面边框 */} - - - {/* 空走路径(虚线) */} - - - - - {/* 切割路径 */} - - {(path) => { - return ( - - {/* 切割路径 */} - - - {/* 动画小球 */} - - - - - - {/* 序号标签 */} - - - - {path.cardIndex + 1} - - - - ); - }} - - - ); + + xmlns="http://www.w3.org/2000/svg" + > + {/* A4 边框 */} + + + {/* 空走路径(虚线) */} + + + + + {/* 切割路径 */} + + {(path) => { + return ( + + {/* 切割路径 */} + + + {/* 动画小球 */} + + + + + + {/* 序号标签 */} + + + + {path.cardIndex + 1} + + + + ); + }} + +
{/* 图例说明 */} diff --git a/src/components/md-deck/PrintPreview.tsx b/src/components/md-deck/PrintPreview.tsx index 15ec6f0..896a031 100644 --- a/src/components/md-deck/PrintPreview.tsx +++ b/src/components/md-deck/PrintPreview.tsx @@ -25,6 +25,7 @@ export function PrintPreview(props: PrintPreviewProps) { const { generatePltData, downloadPltFile } = usePlotterExport(store); const [showPltPreview, setShowPltPreview] = createSignal(false); + const [pltCode, setPltCode] = createSignal(''); const frontVisibleLayers = () => store.state.frontLayerConfigs.filter((l) => l.visible); const backVisibleLayers = () => store.state.backLayerConfigs.filter((l) => l.visible); @@ -45,7 +46,13 @@ export function PrintPreview(props: PrintPreviewProps) { }; const handleOpenPltPreview = () => { - setShowPltPreview(true); + const data = generatePltData(); + if (data) { + setPltCode(data.pltCode); + setShowPltPreview(true); + } else { + alert('没有可预览的卡片'); + } }; const handleClosePltPreview = () => { @@ -53,7 +60,7 @@ export function PrintPreview(props: PrintPreviewProps) { }; return ( - }> + }>
PltExportData | null; - downloadPltFile: (plotterCode: string) => void; - exportToPlt: (pages: PageData[]) => void; + /** 生成单页满排时的 PLT 数据 */ + generatePltData: () => PltExportData | null; + /** 下载 PLT 文件 */ + downloadPltFile: (pltCode: string) => void; + /** 直接导出 PLT(打开下载) */ + exportToPlt: () => void; } /** - * 生成空走路径(抬刀移动路径) - * 从左上角出发,连接所有卡片的起点/终点,最后返回左上角 - */ -function generateTravelPaths( - cardPaths: CardPathData[], - 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 导出 hook - 生成 HPGL 格式文件并下载 + * PLT 导出 hook - 生成单页满排时的 HPGL 格式文件 + * + * 刀路只关心单页排满的情况,不考虑实际牌组的张数。 */ export function usePlotterExport(store: DeckStore): UsePlotterExportReturn { const bleed = () => store.state.bleed || 1; @@ -71,81 +32,51 @@ export function usePlotterExport(store: DeckStore): UsePlotterExportReturn { const cardWidth = () => store.state.dimensions?.cardWidth || 56; const cardHeight = () => store.state.dimensions?.cardHeight || 88; const shape = () => store.state.shape; - const a4Width = 297; // 横向 A4 - const a4Height = 210; + const orientation = () => store.state.printOrientation || 'landscape'; /** - * 生成 PLT 数据(不下载,用于预览) + * 生成单页满排时的 PLT 数据 */ - const generatePltData = (pages: PageData[]): PltExportData | null => { - const paths: CardPathData[] = []; - const currentBleed = bleed(); - const currentCornerRadius = cornerRadius(); + const generatePltData = (): PltExportData | null => { + const layout = calculateSinglePageLayout({ + cardWidth: cardWidth(), + cardHeight: cardHeight(), + shape: shape(), + bleed: bleed(), + cornerRadius: cornerRadius(), + orientation: orientation() + }); - // 计算切割尺寸(排版尺寸减去出血) - const cutWidth = cardWidth() - currentBleed * 2; - const cutHeight = cardHeight() - currentBleed * 2; - - for (const page of pages) { - for (const card of page.cards) { - if (card.side !== 'front') continue; - - // 获取卡片形状点(相对于卡片原点,使用切割尺寸) - const shapePoints = getCardShapePoints(shape(), cutWidth, cutHeight, currentCornerRadius); - - // 转换点到页面坐标: - // - X 轴:卡片位置 + 出血偏移 - // - Y 轴:翻转(SVG Y 向下,plotter Y 向上) - const pagePoints = shapePoints.map(([x, y]) => [ - card.x + currentBleed + x, - a4Height - (card.y + currentBleed + y) - ] as [number, number]); - - const center = calculateCenter(pagePoints); - const startPoint = pagePoints[0]; - const endPoint = pagePoints[pagePoints.length - 1]; - - paths.push({ - points: pagePoints, - centerX: center.x, - centerY: center.y, - startPoint, - endPoint - }); - } - } - - if (paths.length === 0) { + if (layout.cardPaths.length === 0) { return null; } // 生成空走路径 - const travelPaths = generateTravelPaths(paths, a4Height); - + const travelPaths = generateTravelPaths(layout.cardPaths, layout.a4Height); + // 生成 HPGL 代码(包含空走路径,从左上角出发并返回) - const allPaths = paths.map(p => p.points); - const startPoint: [number, number] = [0, a4Height]; // 左上角 - const endPoint: [number, number] = [0, a4Height]; // 返回左上角 - const plotterCode = pts2plotter(allPaths, a4Width, a4Height, 1, startPoint, endPoint); + const allPaths = layout.cardPaths.map(p => p.points); + const startPoint: [number, number] = [0, layout.a4Height]; + const endPoint: [number, number] = [0, layout.a4Height]; + const plotterCode = pts2plotter(allPaths, layout.a4Width, layout.a4Height, 1, startPoint, endPoint); return { - paths, - travelPaths, - plotterCode, - a4Width, - a4Height + pltCode: plotterCode, + a4Width: layout.a4Width, + a4Height: layout.a4Height, + cardsPerPage: layout.cardsPerPage }; }; /** * 下载 PLT 文件 */ - const downloadPltFile = (plotterCode: string) => { - const blob = new Blob([plotterCode], { type: 'application/vnd.hp-HPGL' }); + const downloadPltFile = (pltCode: string) => { + const blob = new Blob([pltCode], { type: 'application/vnd.hp-HPGL' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; - link.download = `deck-export-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.plt`; + link.download = `deck-plt-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.plt`; document.body.appendChild(link); link.click(); document.body.removeChild(link); @@ -153,15 +84,15 @@ export function usePlotterExport(store: DeckStore): UsePlotterExportReturn { }; /** - * 直接导出 PLT(兼容旧接口) + * 直接导出 PLT(打开下载) */ - const exportToPlt = (pages: PageData[]) => { - const data = generatePltData(pages); + const exportToPlt = () => { + const data = generatePltData(); if (!data) { alert('没有可导出的卡片'); return; } - downloadPltFile(data.plotterCode); + downloadPltFile(data.pltCode); }; return { generatePltData, downloadPltFile, exportToPlt }; diff --git a/src/plotcutter/contour.ts b/src/plotcutter/contour.ts index da07d22..5b77050 100644 --- a/src/plotcutter/contour.ts +++ b/src/plotcutter/contour.ts @@ -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'; /** * 生成带圆角的矩形轮廓点 diff --git a/src/plotcutter/index.ts b/src/plotcutter/index.ts index aae761a..fa0649e 100644 --- a/src/plotcutter/index.ts +++ b/src/plotcutter/index.ts @@ -2,4 +2,6 @@ export * from "./bezier"; export * from "./vector"; export * from "./plotter"; export * from "./contour"; -export * from "./layout"; \ No newline at end of file +export * from "./layout"; +export * from "./types"; +export * from "./parser"; \ No newline at end of file diff --git a/src/plotcutter/layout.ts b/src/plotcutter/layout.ts index d156230..9089b2d 100644 --- a/src/plotcutter/layout.ts +++ b/src/plotcutter/layout.ts @@ -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 & { 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; +} diff --git a/src/plotcutter/parser.ts b/src/plotcutter/parser.ts new file mode 100644 index 0000000..3fe5e71 --- /dev/null +++ b/src/plotcutter/parser.ts @@ -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; +} diff --git a/src/plotcutter/types.ts b/src/plotcutter/types.ts new file mode 100644 index 0000000..a9b4ee3 --- /dev/null +++ b/src/plotcutter/types.ts @@ -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; +}