From 7fd53650e3440cdac2060ac7cae00b509c465e78 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sun, 15 Mar 2026 00:52:00 +0800 Subject: [PATCH] refactor: plt preview --- src/components/md-deck/PltPreview.tsx | 385 ++++++++++++++++++ src/components/md-deck/PrintPreview.tsx | 35 +- src/components/md-deck/PrintPreviewHeader.tsx | 6 +- .../md-deck/hooks/usePlotterExport.ts | 116 ++++-- 4 files changed, 500 insertions(+), 42 deletions(-) create mode 100644 src/components/md-deck/PltPreview.tsx diff --git a/src/components/md-deck/PltPreview.tsx b/src/components/md-deck/PltPreview.tsx new file mode 100644 index 0000000..a58a879 --- /dev/null +++ b/src/components/md-deck/PltPreview.tsx @@ -0,0 +1,385 @@ +import { createSignal, For, Show, createMemo } from 'solid-js'; +import type { PageData } from './hooks/usePDFExport'; +import type { CardShape } from './types'; +import { pts2plotter } from '../../plotcutter'; + +export interface PltPreviewProps { + pages: PageData[]; + cardWidth: number; + cardHeight: number; + shape: CardShape; + bleed: number; + onClose: () => void; +} + +export interface CardPath { + pageIndex: number; + cardIndex: number; + points: [number, number][]; + centerX: number; + centerY: number; + pathD: string; +} + +/** + * 根据形状生成卡片轮廓点(单位:mm,相对于卡片左下角) + */ +function getCardShapePoints( + shape: CardShape, + width: number, + height: number +): [number, number][] { + 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 + ]; +} + +/** + * PLT 预览组件 - 显示切割路径预览 + */ +export function PltPreview(props: PltPreviewProps) { + const a4Width = 297; // 横向 A4 + const a4Height = 210; + + // 收集所有卡片路径 + const cardPaths: CardPath[] = []; + let pathIndex = 0; + + // 计算切割尺寸(排版尺寸减去出血) + const cutWidth = props.cardWidth - props.bleed * 2; + const cutHeight = props.cardHeight - props.bleed * 2; + + for (const page of props.pages) { + for (const card of page.cards) { + if (card.side !== 'front') continue; + + const shapePoints = getCardShapePoints(props.shape, cutWidth, cutHeight); + const pagePoints = shapePoints.map(([x, y]) => [ + card.x + props.bleed + x, + a4Height - (card.y + props.bleed + y) + ] as [number, number]); + + const center = calculateCenter(pagePoints); + const pathD = pointsToSvgPath(pagePoints); + + cardPaths.push({ + pageIndex: page.pageIndex, + cardIndex: pathIndex++, + points: pagePoints, + centerX: center.x, + centerY: center.y, + pathD + }); + } + } + + // 生成 HPGL 代码用于下载 + const allPaths = cardPaths.map(p => p.points); + const plotterCode = allPaths.length > 0 ? pts2plotter(allPaths, a4Width, a4Height, 1) : ''; + + // 进度控制 (0 到 cardPaths.length) + const [progress, setProgress] = createSignal(0); + + // 计算当前正在切割的卡片索引 + const currentPathIndex = createMemo(() => { + const p = progress(); + if (p <= 0) return -1; + if (p >= cardPaths.length) return cardPaths.length - 1; + return Math.floor(p); + }); + + // 计算当前小球位置 + const ballPosition = createMemo(() => { + const p = progress(); + if (p <= 0 || cardPaths.length === 0) return null; + + const cardIndex = Math.min(Math.floor(p), cardPaths.length - 1); + const cardProgress = p - cardIndex; + const cardPath = cardPaths[cardIndex]; + + return getPointOnPath(cardPath.points, cardProgress); + }); + + const handleDownload = () => { + if (!plotterCode) { + alert('没有可导出的卡片'); + return; + } + + const blob = new Blob([plotterCode], { 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`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + const handleProgressChange = (e: Event) => { + const target = e.target as HTMLInputElement; + setProgress(Number(target.value)); + }; + + return ( +
+
+ {/* 头部控制栏 */} +
+

PLT 切割预览

+
+ 切割进度 + + + {Math.floor(progress())} / {cardPaths.length} + +
+
+ + + +
+
+ + {/* 预览区域 */} +
+ + {(page) => { + const pageCardPaths = cardPaths.filter(p => p.pageIndex === page.pageIndex); + + return ( + + {/* A4 边框 */} + + + {/* 页面边框 */} + + + {/* 切割路径 */} + + {(path) => { + const currentIndex = currentPathIndex(); + const isCurrentPath = currentIndex === path.cardIndex; + const isCompleted = currentIndex > path.cardIndex; + const displayColor = isCurrentPath ? '#ef4444' : (isCompleted ? '#10b981' : '#3b82f6'); + + return ( + + {/* 切割路径(虚线) */} + + + {/* 序号标签 */} + + + + {path.cardIndex + 1} + + + + ); + }} + + + {/* 动画小球 */} + + {(pos) => ( + + )} + + + ); + }} + +
+ + {/* 图例说明 */} +
+
+
+ 待切割路径 +
+
+
+ 正在切割 +
+
+
+ 已切割 +
+
+
+ 刀头 +
+
+
+
+ ); +} + +/** + * 将路径点转换为 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; +} diff --git a/src/components/md-deck/PrintPreview.tsx b/src/components/md-deck/PrintPreview.tsx index b2059e5..1eb7c50 100644 --- a/src/components/md-deck/PrintPreview.tsx +++ b/src/components/md-deck/PrintPreview.tsx @@ -1,4 +1,4 @@ -import { For, Show } from 'solid-js'; +import { createSignal, For, Show } from 'solid-js'; import type { DeckStore } from './hooks/deckStore'; import { usePageLayout } from './hooks/usePageLayout'; import { usePDFExport, type ExportOptions } from './hooks/usePDFExport'; @@ -7,6 +7,7 @@ import { getShapeSvgClipPath } from './hooks/shape-styles'; import { PrintPreviewHeader } from './PrintPreviewHeader'; import { PrintPreviewFooter } from './PrintPreviewFooter'; import { CardLayer } from './CardLayer'; +import { PltPreview } from './PltPreview'; export interface PrintPreviewProps { store: DeckStore; @@ -21,7 +22,9 @@ export function PrintPreview(props: PrintPreviewProps) { const { store } = props; const { getA4Size, pages, cropMarks } = usePageLayout(store); const { exportToPDF } = usePDFExport(store, props.onClose); - const { exportToPlt } = usePlotterExport(store); + const { generatePltData, downloadPltFile } = usePlotterExport(store); + + const [showPltPreview, setShowPltPreview] = createSignal(false); const frontVisibleLayers = () => store.state.frontLayerConfigs.filter((l) => l.visible); const backVisibleLayers = () => store.state.backLayerConfigs.filter((l) => l.visible); @@ -41,20 +44,25 @@ export function PrintPreview(props: PrintPreviewProps) { await exportToPDF(pages(), cropMarks(), options); }; - const handleExportPlt = () => { - exportToPlt(pages()); + const handleOpenPltPreview = () => { + setShowPltPreview(true); + }; + + const handleClosePltPreview = () => { + setShowPltPreview(false); }; return ( -
-
- + }> +
+
+ @@ -181,5 +189,6 @@ export function PrintPreview(props: PrintPreviewProps) {
+ ); } diff --git a/src/components/md-deck/PrintPreviewHeader.tsx b/src/components/md-deck/PrintPreviewHeader.tsx index f95ce98..196230c 100644 --- a/src/components/md-deck/PrintPreviewHeader.tsx +++ b/src/components/md-deck/PrintPreviewHeader.tsx @@ -4,7 +4,7 @@ export interface PrintPreviewHeaderProps { store: DeckStore; pageCount: number; onExport: () => void; - onExportPlt: () => void; + onOpenPltPreview: () => void; onClose: () => void; } @@ -88,11 +88,11 @@ export function PrintPreviewHeader(props: PrintPreviewHeaderProps) {