Compare commits
8 Commits
7aba5d0c81
...
a840261ae0
| Author | SHA1 | Date |
|---|---|---|
|
|
a840261ae0 | |
|
|
2d4b719e10 | |
|
|
2e03ef3591 | |
|
|
2b1dbd41e1 | |
|
|
ee2fa057f6 | |
|
|
9f665fc403 | |
|
|
92dac64326 | |
|
|
0ec129c2be |
|
|
@ -0,0 +1,22 @@
|
||||||
|
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||||
|
export default {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
testMatch: ['**/*.test.ts'],
|
||||||
|
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||||
|
transform: {
|
||||||
|
'^.+\\.tsx?$': [
|
||||||
|
'ts-jest',
|
||||||
|
{
|
||||||
|
tsconfig: {
|
||||||
|
target: 'ES2020',
|
||||||
|
module: 'ESNext',
|
||||||
|
moduleResolution: 'node',
|
||||||
|
esModuleInterop: true,
|
||||||
|
strict: true,
|
||||||
|
skipLibCheck: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -19,7 +19,8 @@
|
||||||
"preview": "rsbuild preview",
|
"preview": "rsbuild preview",
|
||||||
"cli:dev": "tsc -p tsconfig.cli.json --watch",
|
"cli:dev": "tsc -p tsconfig.cli.json --watch",
|
||||||
"cli:build": "tsc -p tsconfig.cli.json",
|
"cli:build": "tsc -p tsconfig.cli.json",
|
||||||
"ttrpg": "node --loader ts-node/esm ./src/cli/index.ts"
|
"ttrpg": "node --loader ts-node/esm ./src/cli/index.ts",
|
||||||
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ttrpg",
|
"ttrpg",
|
||||||
|
|
@ -50,9 +51,13 @@
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/node": "^22.19.13",
|
"@types/node": "^22.19.13",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
|
"ts-jest": "^29.4.6",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,11 @@ export function CardPreview(props: CardPreviewProps) {
|
||||||
const selectionStyle = createMemo(() =>
|
const selectionStyle = createMemo(() =>
|
||||||
getSelectionBoxStyle(store.state.selectStart, store.state.selectEnd, store.state.dimensions)
|
getSelectionBoxStyle(store.state.selectStart, store.state.selectEnd, store.state.dimensions)
|
||||||
);
|
);
|
||||||
const shapeClipPath = createMemo(() => getShapeClipPath(store.state.shape));
|
const shapeClipPath = createMemo(() => {
|
||||||
|
const dims = store.state.dimensions;
|
||||||
|
if (!dims) return 'none';
|
||||||
|
return getShapeClipPath(store.state.shape, dims.cardWidth, dims.cardHeight, store.state.cornerRadius);
|
||||||
|
});
|
||||||
|
|
||||||
const selection = useCardSelection(store);
|
const selection = useCardSelection(store);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -154,9 +154,8 @@ export function PltPreview(props: PltPreviewProps) {
|
||||||
<path
|
<path
|
||||||
d={travelPathD()}
|
d={travelPathD()}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="#999"
|
stroke="#f884"
|
||||||
stroke-width="0.2"
|
stroke-width="1"
|
||||||
stroke-dasharray="2 2"
|
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,7 @@ export function PrintPreview(props: PrintPreviewProps) {
|
||||||
const cardWidth = store.state.dimensions?.cardWidth || 56;
|
const cardWidth = store.state.dimensions?.cardWidth || 56;
|
||||||
const cardHeight = store.state.dimensions?.cardHeight || 88;
|
const cardHeight = store.state.dimensions?.cardHeight || 88;
|
||||||
const clipPathId = `clip-${page.pageIndex}-${card.data.id || card.x}-${card.y}`;
|
const clipPathId = `clip-${page.pageIndex}-${card.data.id || card.x}-${card.y}`;
|
||||||
const shapeClipPath = getShapeSvgClipPath(clipPathId, cardWidth, cardHeight, store.state.shape);
|
const shapeClipPath = getShapeSvgClipPath(clipPathId, cardWidth, cardHeight, store.state.shape, store.state.cornerRadius);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g class="card-group">
|
<g class="card-group">
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,52 @@
|
||||||
import type { CardShape } from '../types';
|
import type { CardShape } from '../types';
|
||||||
|
import { getCardShapePoints, contourToSvgPath } from '../../../plotcutter/contour';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将轮廓点转换为 CSS polygon() 格式
|
||||||
|
* @param points 轮廓点数组(相对于左上角,单位:mm)
|
||||||
|
* @returns CSS polygon() 字符串,使用百分比坐标
|
||||||
|
*/
|
||||||
|
function pointsToCssPolygon(points: [number, number][], width: number, height: number): string {
|
||||||
|
if (points.length === 0) return 'none';
|
||||||
|
|
||||||
|
const coords = points.map(([x, y]) => {
|
||||||
|
const percentX = (x / width) * 100;
|
||||||
|
const percentY = (y / height) * 100;
|
||||||
|
return `${percentX.toFixed(4)}% ${percentY.toFixed(4)}%`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `polygon(${coords.join(', ')})`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 CSS clip-path 值用于形状裁剪
|
* 获取 CSS clip-path 值用于形状裁剪
|
||||||
|
*
|
||||||
|
* @param shape 卡片形状
|
||||||
|
* @param width 卡片宽度(用于计算多边形坐标)
|
||||||
|
* @param height 卡片高度(用于计算多边形坐标)
|
||||||
|
* @param cornerRadius 圆角半径(可选,>0 时使用多边形近似)
|
||||||
|
* @param segmentsPerCorner 每个圆角的分段数(可选)
|
||||||
*/
|
*/
|
||||||
export function getShapeClipPath(shape: CardShape): string {
|
export function getShapeClipPath(
|
||||||
switch (shape) {
|
shape: CardShape,
|
||||||
case 'circle':
|
width: number,
|
||||||
return 'circle(50% at 50% 50%)';
|
height: number,
|
||||||
case 'triangle':
|
cornerRadius: number = 0,
|
||||||
return 'polygon(50% 0%, 0% 100%, 100% 100%)';
|
segmentsPerCorner: number = 4
|
||||||
case 'hexagon':
|
): string {
|
||||||
return 'polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)';
|
// 无圆角的基本形状使用简化的 CSS clip-path
|
||||||
case 'rectangle':
|
if (cornerRadius <= 0) {
|
||||||
default:
|
switch (shape) {
|
||||||
return 'none';
|
case 'circle':
|
||||||
|
return 'circle(50% at 50% 50%)';
|
||||||
|
case 'rectangle':
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 其他情况使用多边形近似(包括圆角形状、三角形、六边形)
|
||||||
|
const points = getCardShapePoints(shape, width, height, cornerRadius, segmentsPerCorner);
|
||||||
|
return pointsToCssPolygon(points, width, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -23,34 +55,41 @@ export function getShapeClipPath(shape: CardShape): string {
|
||||||
* @param width 卡片宽度
|
* @param width 卡片宽度
|
||||||
* @param height 卡片高度
|
* @param height 卡片高度
|
||||||
* @param shape 卡片形状
|
* @param shape 卡片形状
|
||||||
|
* @param cornerRadius 圆角半径(可选)
|
||||||
|
* @param segmentsPerCorner 每个圆角的分段数(可选)
|
||||||
*/
|
*/
|
||||||
export function getShapeSvgClipPath(
|
export function getShapeSvgClipPath(
|
||||||
id: string,
|
id: string,
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
shape: CardShape
|
shape: CardShape,
|
||||||
|
cornerRadius: number = 0,
|
||||||
|
segmentsPerCorner: number = 4
|
||||||
): string {
|
): string {
|
||||||
const halfW = width / 2;
|
const points = getCardShapePoints(shape, width, height, cornerRadius, segmentsPerCorner);
|
||||||
const halfH = height / 2;
|
const pathData = contourToSvgPath(points, true);
|
||||||
|
|
||||||
switch (shape) {
|
return `
|
||||||
case 'circle':
|
|
||||||
return `
|
|
||||||
<clipPath id="${id}">
|
<clipPath id="${id}">
|
||||||
<circle cx="${halfW}" cy="${halfH}" r="${halfW}" />
|
<path d="${pathData}" />
|
||||||
</clipPath>`;
|
</clipPath>`;
|
||||||
case 'triangle':
|
}
|
||||||
return `
|
|
||||||
<clipPath id="${id}">
|
/**
|
||||||
<polygon points="${halfW},0 0,${height} ${width},${height}" />
|
* 获取卡片形状的 SVG path 数据
|
||||||
</clipPath>`;
|
* @param width 卡片宽度
|
||||||
case 'hexagon':
|
* @param height 卡片高度
|
||||||
return `
|
* @param shape 卡片形状
|
||||||
<clipPath id="${id}">
|
* @param cornerRadius 圆角半径(可选)
|
||||||
<polygon points="${halfW},0 ${width},${height * 0.25} ${width},${height * 0.75} ${halfW},${height} 0,${height * 0.75} 0,${height * 0.25}" />
|
* @param segmentsPerCorner 每个圆角的分段数(可选)
|
||||||
</clipPath>`;
|
*/
|
||||||
case 'rectangle':
|
export function getCardShapePath(
|
||||||
default:
|
width: number,
|
||||||
return '';
|
height: number,
|
||||||
}
|
shape: CardShape,
|
||||||
|
cornerRadius: number = 0,
|
||||||
|
segmentsPerCorner: number = 4
|
||||||
|
): string {
|
||||||
|
const points = getCardShapePoints(shape, width, height, cornerRadius, segmentsPerCorner);
|
||||||
|
return contourToSvgPath(points, true);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,72 +1,74 @@
|
||||||
import type { CardShape, ContourPoint, ContourBounds } from './types';
|
import type { CardShape, ContourPoint, ContourBounds } from './types';
|
||||||
|
import {getRoundedPolygonPoints} from "./rounded";
|
||||||
|
|
||||||
// 重新导出类型以兼容旧导入路径
|
// 重新导出类型以兼容旧导入路径
|
||||||
export type { CardShape, ContourPoint, ContourBounds };
|
export type { CardShape, ContourPoint, ContourBounds };
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成带圆角的矩形轮廓点
|
* 生成内接正三角形的轮廓点(无圆角版本)
|
||||||
* @param width 矩形宽度
|
* @param width 外框宽度
|
||||||
* @param height 矩形高度
|
* @param height 外框高度
|
||||||
* @param cornerRadius 圆角半径(mm)
|
* @returns 正三角形轮廓点(相对于外框左下角,顺时针)
|
||||||
* @param segmentsPerCorner 每个圆角的分段数
|
|
||||||
*/
|
*/
|
||||||
export function getRoundedRectPoints(
|
export function getInscribedTrianglePoints(
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number
|
||||||
cornerRadius: number,
|
|
||||||
segmentsPerCorner: number = 4
|
|
||||||
): [number, number][] {
|
): [number, number][] {
|
||||||
|
// 以短边为基准计算内接正三角形的边长
|
||||||
|
const minDim = Math.min(width, height / Math.sqrt(3) * 2);
|
||||||
|
// 正三角形的高 = 边长 * sqrt(3) / 2
|
||||||
|
const triangleHeight = minDim * Math.sqrt(3) / 2;
|
||||||
|
const sideLength = minDim;
|
||||||
|
|
||||||
|
// 计算居中偏移
|
||||||
|
const offsetX = (width - sideLength) / 2;
|
||||||
|
const offsetY = (height - triangleHeight) / 2;
|
||||||
|
|
||||||
|
// 正三角形三个顶点(底边在下,顶点在上,顺时针:左上→右上→下)
|
||||||
|
const points: [number, number][] = [
|
||||||
|
[offsetX, offsetY + triangleHeight], // 左下顶点
|
||||||
|
[offsetX + sideLength, offsetY + triangleHeight], // 右下顶点
|
||||||
|
[offsetX + sideLength / 2, offsetY] // 顶部顶点
|
||||||
|
];
|
||||||
|
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成内接正六边形的轮廓点(无圆角版本)
|
||||||
|
* @param width 外框宽度
|
||||||
|
* @param height 外框高度
|
||||||
|
* @returns 正六边形轮廓点(相对于外框左下角,顺时针)
|
||||||
|
*/
|
||||||
|
export function getInscribedHexagonPoints(
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): [number, number][] {
|
||||||
|
const minDim = Math.min(width, height / Math.sqrt(3) * 2);
|
||||||
|
const radius = minDim / 2;
|
||||||
|
|
||||||
|
// 中心点
|
||||||
|
const centerX = width / 2;
|
||||||
|
const centerY = height / 2;
|
||||||
|
|
||||||
|
// 正六边形六个顶点(平顶,从左上角开始顺时针)
|
||||||
|
// 角度:210°, 270°, 330°, 30°, 90°, 150° (数学坐标系,Y向上)
|
||||||
|
// 在屏幕坐标系 (Y向下),我们直接按顺时针计算
|
||||||
const points: [number, number][] = [];
|
const points: [number, number][] = [];
|
||||||
const r = Math.min(cornerRadius, width / 2, height / 2);
|
for (let i = 0; i < 6; i++) {
|
||||||
|
// 从 -120度开始,每 60 度一个点,实现平顶且顺时针
|
||||||
if (r <= 0) {
|
const angle = (-2 * Math.PI / 3) + (i * Math.PI / 3);
|
||||||
// 无圆角,返回普通矩形
|
|
||||||
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;
|
|
||||||
points.push([
|
points.push([
|
||||||
r + r * Math.cos(angle),
|
centerX + radius * Math.cos(angle),
|
||||||
r + r * Math.sin(angle)
|
centerY + radius * 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),
|
|
||||||
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),
|
|
||||||
height - r + r * Math.sin(angle)
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 左下角圆角
|
|
||||||
for (let i = 0; i <= segmentsPerCorner; i++) {
|
|
||||||
const angle = (Math.PI / 2) * (i / segmentsPerCorner) + Math.PI/2;
|
|
||||||
points.push([
|
|
||||||
r + r * Math.cos(angle),
|
|
||||||
height - r + r * Math.sin(angle)
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return points;
|
return points;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据形状生成卡片轮廓点(单位:mm,相对于卡片左下角)
|
* 根据形状生成卡片轮廓点(单位:mm,相对于卡片左下角)
|
||||||
*/
|
*/
|
||||||
|
|
@ -74,10 +76,28 @@ export function getCardShapePoints(
|
||||||
shape: CardShape,
|
shape: CardShape,
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
cornerRadius: number = 0
|
cornerRadius: number = 0,
|
||||||
|
segmentsPerCorner: number = 4
|
||||||
): [number, number][] {
|
): [number, number][] {
|
||||||
if (shape === 'rectangle' && cornerRadius > 0) {
|
// 处理带圆角的情况 - 统一使用 getRoundedPolygonPoints
|
||||||
return getRoundedRectPoints(width, height, cornerRadius);
|
if (cornerRadius > 0) {
|
||||||
|
if (shape === 'rectangle') {
|
||||||
|
const vertices: [number, number][] = [
|
||||||
|
[0, 0],
|
||||||
|
[width, 0],
|
||||||
|
[width, height],
|
||||||
|
[0, height]
|
||||||
|
];
|
||||||
|
return getRoundedPolygonPoints(vertices, cornerRadius, segmentsPerCorner);
|
||||||
|
}
|
||||||
|
if (shape === 'triangle') {
|
||||||
|
const vertices = getInscribedTrianglePoints(width, height);
|
||||||
|
return getRoundedPolygonPoints(vertices, cornerRadius, segmentsPerCorner);
|
||||||
|
}
|
||||||
|
if (shape === 'hexagon') {
|
||||||
|
const vertices = getInscribedHexagonPoints(width, height);
|
||||||
|
return getRoundedPolygonPoints(vertices, cornerRadius, segmentsPerCorner);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const points: [number, number][] = [];
|
const points: [number, number][] = [];
|
||||||
|
|
@ -97,21 +117,10 @@ export function getCardShapePoints(
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'triangle': {
|
case 'triangle': {
|
||||||
points.push([width / 2, 0]);
|
return getInscribedTrianglePoints(width, height);
|
||||||
points.push([0, height]);
|
|
||||||
points.push([width, height]);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
case 'hexagon': {
|
case 'hexagon': {
|
||||||
const halfW = width / 2;
|
return getInscribedHexagonPoints(width, height);
|
||||||
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':
|
case 'rectangle':
|
||||||
default: {
|
default: {
|
||||||
|
|
@ -194,7 +203,7 @@ export function getPointOnPath(points: [number, number][], progress: number): [n
|
||||||
* @param points 轮廓点数组
|
* @param points 轮廓点数组
|
||||||
* @param closed 是否闭合路径
|
* @param closed 是否闭合路径
|
||||||
*/
|
*/
|
||||||
export function contourToSvgPath(points: [number, number][], closed = true): string {
|
export function contourToSvgPath(points: [number, number][], closed = false): string {
|
||||||
if (points.length === 0) return '';
|
if (points.length === 0) return '';
|
||||||
|
|
||||||
const [startX, startY] = points[0];
|
const [startX, startY] = points[0];
|
||||||
|
|
|
||||||
|
|
@ -22,15 +22,8 @@ export function pts2plotter(
|
||||||
|
|
||||||
let str = init(width * px2mm, height * px2mm);
|
let str = init(width * px2mm, height * px2mm);
|
||||||
|
|
||||||
// 按 X 轴然后 Y 轴排序路径
|
// 使用最近邻算法排序路径
|
||||||
const sorted = pts.slice();
|
const sorted = sortPathsByNearestNeighbor(pts, start);
|
||||||
sorted.sort(function (a, b) {
|
|
||||||
const [ax, ay] = topleft(a);
|
|
||||||
const [bx, by] = topleft(b);
|
|
||||||
|
|
||||||
if (ax !== bx) return ax - bx;
|
|
||||||
return ay - by;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 从起点到第一个路径
|
// 从起点到第一个路径
|
||||||
if (sorted.length > 0) {
|
if (sorted.length > 0) {
|
||||||
|
|
@ -44,6 +37,11 @@ export function pts2plotter(
|
||||||
str += ` D${plu(pt[0] * px2mm)},${plu((height - pt[1]) * px2mm)}`;
|
str += ` D${plu(pt[0] * px2mm)},${plu((height - pt[1]) * px2mm)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果第一个路径未闭合,添加闭合命令
|
||||||
|
if (!isPathClosed(firstPath)) {
|
||||||
|
str += ` D${plu(firstPath[0][0] * px2mm)},${plu((height - firstPath[0][1]) * px2mm)}`;
|
||||||
|
}
|
||||||
|
|
||||||
// 路径之间移动
|
// 路径之间移动
|
||||||
for (let i = 1; i < sorted.length; i++) {
|
for (let i = 1; i < sorted.length; i++) {
|
||||||
const prevPath = sorted[i - 1];
|
const prevPath = sorted[i - 1];
|
||||||
|
|
@ -58,6 +56,11 @@ export function pts2plotter(
|
||||||
const pt = currPath[j];
|
const pt = currPath[j];
|
||||||
str += ` D${plu(pt[0] * px2mm)},${plu((height - pt[1]) * px2mm)}`;
|
str += ` D${plu(pt[0] * px2mm)},${plu((height - pt[1]) * px2mm)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果当前路径未闭合,添加闭合命令
|
||||||
|
if (!isPathClosed(currPath)) {
|
||||||
|
str += ` D${plu(currPath[0][0] * px2mm)},${plu((height - currPath[0][1]) * px2mm)}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,6 +91,72 @@ function topleft(pts: [number, number][]) {
|
||||||
return [minx, miny] as [number, number];
|
return [minx, miny] as [number, number];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测路径是否已闭合(起点和终点距离小于阈值)
|
||||||
|
* @param path 路径点数组
|
||||||
|
* @param threshold 距离阈值(像素),默认 0.01
|
||||||
|
*/
|
||||||
|
function isPathClosed(path: [number, number][], threshold = 0.01): boolean {
|
||||||
|
if (path.length < 2) return true;
|
||||||
|
const [sx, sy] = path[0];
|
||||||
|
const [ex, ey] = path[path.length - 1];
|
||||||
|
const dist = Math.sqrt((sx - ex) ** 2 + (sy - ey) ** 2);
|
||||||
|
return dist < threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算点到路径的最近距离
|
||||||
|
* @param pt 点坐标
|
||||||
|
* @param path 路径点数组
|
||||||
|
*/
|
||||||
|
function distanceToPath(pt: [number, number], path: [number, number][]): number {
|
||||||
|
let minDist = Infinity;
|
||||||
|
for (const p of path) {
|
||||||
|
const dist = Math.sqrt((pt[0] - p[0]) ** 2 + (pt[1] - p[1]) ** 2);
|
||||||
|
if (dist < minDist) minDist = dist;
|
||||||
|
}
|
||||||
|
return minDist;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用最近邻算法排序路径
|
||||||
|
* @param paths 路径数组
|
||||||
|
* @param startPos 起始位置
|
||||||
|
*/
|
||||||
|
function sortPathsByNearestNeighbor(
|
||||||
|
paths: [number, number][][],
|
||||||
|
startPos: [number, number]
|
||||||
|
): [number, number][][] {
|
||||||
|
if (paths.length === 0) return [];
|
||||||
|
|
||||||
|
const result: [number, number][][] = [];
|
||||||
|
const remaining = paths.slice();
|
||||||
|
let currentPos = startPos;
|
||||||
|
|
||||||
|
while (remaining.length > 0) {
|
||||||
|
// 找到距离当前位置最近的路径
|
||||||
|
let nearestIndex = 0;
|
||||||
|
let nearestDist = Infinity;
|
||||||
|
|
||||||
|
for (let i = 0; i < remaining.length; i++) {
|
||||||
|
const dist = distanceToPath(currentPos, remaining[i]);
|
||||||
|
if (dist < nearestDist) {
|
||||||
|
nearestDist = dist;
|
||||||
|
nearestIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将最近的路径添加到结果中
|
||||||
|
const nearestPath = remaining.splice(nearestIndex, 1)[0];
|
||||||
|
result.push(nearestPath);
|
||||||
|
|
||||||
|
// 更新当前位置为该路径的终点
|
||||||
|
currentPos = nearestPath[nearestPath.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function init(w: number, h: number) {
|
function init(w: number, h: number) {
|
||||||
return ` IN TB26,${plu(w)},${plu(h)} CT1`;
|
return ` IN TB26,${plu(w)},${plu(h)} CT1`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { getRoundedPolygonPoints, getTangentCircleCenter, getProjectedPoint } from './rounded';
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
declare const describe: any;
|
||||||
|
declare const test: any;
|
||||||
|
declare const expect: any;
|
||||||
|
|
||||||
|
describe('getProjectedPoint', () => {
|
||||||
|
test('should project point onto line segment', () => {
|
||||||
|
// 点 (2, 2) 投影到线段 (0, 0) -> (4, 0)
|
||||||
|
const result = getProjectedPoint([2, 2], [0, 0], [4, 0]);
|
||||||
|
expect(result).toEqual([2, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should project point onto vertical line segment', () => {
|
||||||
|
const result = getProjectedPoint([3, 2], [0, 0], [0, 4]);
|
||||||
|
expect(result).toEqual([0, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle point already on line', () => {
|
||||||
|
const result = getProjectedPoint([2, 0], [0, 0], [4, 0]);
|
||||||
|
expect(result).toEqual([2, 0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTangentCircleCenter', () => {
|
||||||
|
test('should find center for 90 degree corner', () => {
|
||||||
|
// 直角:a(0,1), b(0,0), c(1,0)
|
||||||
|
const center = getTangentCircleCenter([0, 1], [0, 0], [1, 0], 1);
|
||||||
|
// 圆心应该在 (1, 1)
|
||||||
|
expect(center[0]).toBeCloseTo(1, 5);
|
||||||
|
expect(center[1]).toBeCloseTo(1, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should find center for equilateral triangle corner', () => {
|
||||||
|
// 等边三角形的一个角
|
||||||
|
const center = getTangentCircleCenter([0, 1], [0, 0], [Math.sqrt(3) / 2, -0.5], 0.5);
|
||||||
|
// 验证圆心到两条边的距离都是半径
|
||||||
|
expect(center).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRoundedPolygonPoints', () => {
|
||||||
|
test('should return empty array for empty input', () => {
|
||||||
|
const result = getRoundedPolygonPoints([], 1);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return same points for less than 3 vertices', () => {
|
||||||
|
const result = getRoundedPolygonPoints([[0, 0], [1, 0]], 1);
|
||||||
|
expect(result).toEqual([[0, 0], [1, 0]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should round a square', () => {
|
||||||
|
// 单位正方形
|
||||||
|
const square: [number, number][] = [[0, 0], [2, 0], [2, 2], [0, 2]];
|
||||||
|
const result = getRoundedPolygonPoints(square, 0.5, 4);
|
||||||
|
|
||||||
|
// 结果应该有 4 个角 * (4+1) 段 = 20 个点
|
||||||
|
expect(result.length).toBe(20);
|
||||||
|
|
||||||
|
// 验证所有点都是有效的坐标
|
||||||
|
result.forEach(point => {
|
||||||
|
expect(typeof point[0]).toBe('number');
|
||||||
|
expect(typeof point[1]).toBe('number');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should round an equilateral triangle', () => {
|
||||||
|
// 等边三角形
|
||||||
|
const triangle: [number, number][] = [
|
||||||
|
[0, 1],
|
||||||
|
[Math.sqrt(3) / 2, -0.5],
|
||||||
|
[-Math.sqrt(3) / 2, -0.5]
|
||||||
|
];
|
||||||
|
const result = getRoundedPolygonPoints(triangle, 0.2, 4);
|
||||||
|
|
||||||
|
expect(result.length).toBe(15); // 3 个角 * (4+1) 段
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
/**
|
||||||
|
* 为正多边形添加圆角
|
||||||
|
* 将每个角转换为 segmentsPerCorner 条线段,线段的端点在圆角的圆弧上
|
||||||
|
*
|
||||||
|
* 对每个角:
|
||||||
|
* 1. 寻找圆心
|
||||||
|
* 2. 将圆心投影到两条边上
|
||||||
|
* 3. 以圆弧连接投影点
|
||||||
|
* @param vertices 多边形顶点数组(顺时针或逆时针)
|
||||||
|
* @param cornerRadius 圆角半径
|
||||||
|
* @param segmentsPerCorner 每个圆角的分段数
|
||||||
|
* @returns 带圆角的多边形轮廓点
|
||||||
|
*/
|
||||||
|
export function getRoundedPolygonPoints(
|
||||||
|
vertices: [number, number][],
|
||||||
|
cornerRadius: number,
|
||||||
|
segmentsPerCorner: number = 4
|
||||||
|
): [number, number][] {
|
||||||
|
if (vertices.length < 3) {
|
||||||
|
return [...vertices];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: [number, number][] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < vertices.length; i++) {
|
||||||
|
const prev = vertices[(i - 1 + vertices.length) % vertices.length];
|
||||||
|
const curr = vertices[i];
|
||||||
|
const next = vertices[(i + 1) % vertices.length];
|
||||||
|
|
||||||
|
// 获取圆角圆心
|
||||||
|
const center = getTangentCircleCenter(prev, curr, next, cornerRadius);
|
||||||
|
|
||||||
|
// 获取圆心到两条边的投影点(圆角的起点和终点)
|
||||||
|
const start = getProjectedPoint(center, prev, curr);
|
||||||
|
const end = getProjectedPoint(center, curr, next);
|
||||||
|
|
||||||
|
// 计算起始角度和结束角度
|
||||||
|
const startAngle = Math.atan2(start[1] - center[1], start[0] - center[0]);
|
||||||
|
const endAngle = Math.atan2(end[1] - center[1], end[0] - center[0]);
|
||||||
|
|
||||||
|
// 判断方向(顺时针或逆时针)
|
||||||
|
const cross = (curr[0] - prev[0]) * (next[1] - prev[1]) - (curr[1] - prev[1]) * (next[0] - prev[0]);
|
||||||
|
const isClockwise = cross < 0;
|
||||||
|
|
||||||
|
// 计算角度差,确保沿着正确的方向
|
||||||
|
let angleDiff = endAngle - startAngle;
|
||||||
|
if (isClockwise) {
|
||||||
|
if (angleDiff > 0) angleDiff -= Math.PI * 2;
|
||||||
|
} else {
|
||||||
|
if (angleDiff < 0) angleDiff += Math.PI * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成圆弧上的点
|
||||||
|
for (let j = 0; j <= segmentsPerCorner; j++) {
|
||||||
|
const t = j / segmentsPerCorner;
|
||||||
|
const angle = startAngle + angleDiff * t;
|
||||||
|
const x = center[0] + cornerRadius * Math.cos(angle);
|
||||||
|
const y = center[1] + cornerRadius * Math.sin(angle);
|
||||||
|
result.push([x, y]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对于给定的角 abc,获取与 ab 和 bc 相切的半径为 radius 的圆的圆心位置。
|
||||||
|
* @param va
|
||||||
|
* @param vb
|
||||||
|
* @param vc
|
||||||
|
* @param radius
|
||||||
|
*/
|
||||||
|
export function getTangentCircleCenter(
|
||||||
|
va: [number, number],
|
||||||
|
vb: [number, number],
|
||||||
|
vc: [number, number],
|
||||||
|
radius: number
|
||||||
|
): [number, number] {
|
||||||
|
// 计算两条边的单位向量
|
||||||
|
const ba: [number, number] = [va[0] - vb[0], va[1] - vb[1]];
|
||||||
|
const bc: [number, number] = [vc[0] - vb[0], vc[1] - vb[1]];
|
||||||
|
|
||||||
|
const baLen = Math.sqrt(ba[0] ** 2 + ba[1] ** 2);
|
||||||
|
const bcLen = Math.sqrt(bc[0] ** 2 + bc[1] ** 2);
|
||||||
|
|
||||||
|
const baUnit: [number, number] = [ba[0] / baLen, ba[1] / baLen];
|
||||||
|
const bcUnit: [number, number] = [bc[0] / bcLen, bc[1] / bcLen];
|
||||||
|
|
||||||
|
// 角平分线方向
|
||||||
|
const bisector: [number, number] = [baUnit[0] + bcUnit[0], baUnit[1] + bcUnit[1]];
|
||||||
|
const bisectorLen = Math.sqrt(bisector[0] ** 2 + bisector[1] ** 2);
|
||||||
|
const bisectorUnit: [number, number] = [bisector[0] / bisectorLen, bisector[1] / bisectorLen];
|
||||||
|
|
||||||
|
// 计算半角的正弦值
|
||||||
|
const halfAngle = Math.acos((baUnit[0] * bcUnit[0] + baUnit[1] * bcUnit[1]));
|
||||||
|
const sinHalfAngle = Math.sin(halfAngle / 2);
|
||||||
|
|
||||||
|
// 圆心到顶点的距离
|
||||||
|
const centerDist = radius / sinHalfAngle;
|
||||||
|
|
||||||
|
// 圆心位置(从顶点沿角平分线向外)
|
||||||
|
return [
|
||||||
|
vb[0] + bisectorUnit[0] * centerDist,
|
||||||
|
vb[1] + bisectorUnit[1] * centerDist
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 v 投影到 ab 线段上
|
||||||
|
* @param v
|
||||||
|
* @param a
|
||||||
|
* @param b
|
||||||
|
*/
|
||||||
|
export function getProjectedPoint(
|
||||||
|
v: [number, number],
|
||||||
|
a: [number, number],
|
||||||
|
b: [number, number],
|
||||||
|
): [number, number] {
|
||||||
|
const ab: [number, number] = [b[0] - a[0], b[1] - a[1]];
|
||||||
|
const av: [number, number] = [v[0] - a[0], v[1] - a[1]];
|
||||||
|
|
||||||
|
const abLenSq = ab[0] ** 2 + ab[1] ** 2;
|
||||||
|
const t = (av[0] * ab[0] + av[1] * ab[1]) / abLenSq;
|
||||||
|
|
||||||
|
return [
|
||||||
|
a[0] + ab[0] * t,
|
||||||
|
a[1] + ab[1] * t
|
||||||
|
];
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue