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