Compare commits
No commits in common. "a840261ae0ea09bac9c7a0faa9fe2df37cb0b600" and "7aba5d0c81e335e23b86152af64b69ce7e1957f1" have entirely different histories.
a840261ae0
...
7aba5d0c81
|
|
@ -1,22 +0,0 @@
|
|||
/** @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,8 +19,7 @@
|
|||
"preview": "rsbuild preview",
|
||||
"cli:dev": "tsc -p tsconfig.cli.json --watch",
|
||||
"cli:build": "tsc -p tsconfig.cli.json",
|
||||
"ttrpg": "node --loader ts-node/esm ./src/cli/index.ts",
|
||||
"test": "jest"
|
||||
"ttrpg": "node --loader ts-node/esm ./src/cli/index.ts"
|
||||
},
|
||||
"keywords": [
|
||||
"ttrpg",
|
||||
|
|
@ -51,13 +50,9 @@
|
|||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.19.13",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"ts-jest": "^29.4.6",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,11 +19,7 @@ export function CardPreview(props: CardPreviewProps) {
|
|||
const selectionStyle = createMemo(() =>
|
||||
getSelectionBoxStyle(store.state.selectStart, store.state.selectEnd, store.state.dimensions)
|
||||
);
|
||||
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 shapeClipPath = createMemo(() => getShapeClipPath(store.state.shape));
|
||||
|
||||
const selection = useCardSelection(store);
|
||||
|
||||
|
|
|
|||
|
|
@ -154,8 +154,9 @@ export function PltPreview(props: PltPreviewProps) {
|
|||
<path
|
||||
d={travelPathD()}
|
||||
fill="none"
|
||||
stroke="#f884"
|
||||
stroke-width="1"
|
||||
stroke="#999"
|
||||
stroke-width="0.2"
|
||||
stroke-dasharray="2 2"
|
||||
/>
|
||||
</Show>
|
||||
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ export function PrintPreview(props: PrintPreviewProps) {
|
|||
const cardWidth = store.state.dimensions?.cardWidth || 56;
|
||||
const cardHeight = store.state.dimensions?.cardHeight || 88;
|
||||
const clipPathId = `clip-${page.pageIndex}-${card.data.id || card.x}-${card.y}`;
|
||||
const shapeClipPath = getShapeSvgClipPath(clipPathId, cardWidth, cardHeight, store.state.shape, store.state.cornerRadius);
|
||||
const shapeClipPath = getShapeSvgClipPath(clipPathId, cardWidth, cardHeight, store.state.shape);
|
||||
|
||||
return (
|
||||
<g class="card-group">
|
||||
|
|
|
|||
|
|
@ -1,52 +1,20 @@
|
|||
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 值用于形状裁剪
|
||||
*
|
||||
* @param shape 卡片形状
|
||||
* @param width 卡片宽度(用于计算多边形坐标)
|
||||
* @param height 卡片高度(用于计算多边形坐标)
|
||||
* @param cornerRadius 圆角半径(可选,>0 时使用多边形近似)
|
||||
* @param segmentsPerCorner 每个圆角的分段数(可选)
|
||||
*/
|
||||
export function getShapeClipPath(
|
||||
shape: CardShape,
|
||||
width: number,
|
||||
height: number,
|
||||
cornerRadius: number = 0,
|
||||
segmentsPerCorner: number = 4
|
||||
): string {
|
||||
// 无圆角的基本形状使用简化的 CSS clip-path
|
||||
if (cornerRadius <= 0) {
|
||||
switch (shape) {
|
||||
case 'circle':
|
||||
return 'circle(50% at 50% 50%)';
|
||||
case 'rectangle':
|
||||
return 'none';
|
||||
}
|
||||
export function getShapeClipPath(shape: CardShape): string {
|
||||
switch (shape) {
|
||||
case 'circle':
|
||||
return 'circle(50% at 50% 50%)';
|
||||
case 'triangle':
|
||||
return 'polygon(50% 0%, 0% 100%, 100% 100%)';
|
||||
case 'hexagon':
|
||||
return 'polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)';
|
||||
case 'rectangle':
|
||||
default:
|
||||
return 'none';
|
||||
}
|
||||
|
||||
// 其他情况使用多边形近似(包括圆角形状、三角形、六边形)
|
||||
const points = getCardShapePoints(shape, width, height, cornerRadius, segmentsPerCorner);
|
||||
return pointsToCssPolygon(points, width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -55,41 +23,34 @@ export function getShapeClipPath(
|
|||
* @param width 卡片宽度
|
||||
* @param height 卡片高度
|
||||
* @param shape 卡片形状
|
||||
* @param cornerRadius 圆角半径(可选)
|
||||
* @param segmentsPerCorner 每个圆角的分段数(可选)
|
||||
*/
|
||||
export function getShapeSvgClipPath(
|
||||
id: string,
|
||||
width: number,
|
||||
height: number,
|
||||
shape: CardShape,
|
||||
cornerRadius: number = 0,
|
||||
segmentsPerCorner: number = 4
|
||||
shape: CardShape
|
||||
): string {
|
||||
const points = getCardShapePoints(shape, width, height, cornerRadius, segmentsPerCorner);
|
||||
const pathData = contourToSvgPath(points, true);
|
||||
const halfW = width / 2;
|
||||
const halfH = height / 2;
|
||||
|
||||
return `
|
||||
switch (shape) {
|
||||
case 'circle':
|
||||
return `
|
||||
<clipPath id="${id}">
|
||||
<path d="${pathData}" />
|
||||
<circle cx="${halfW}" cy="${halfH}" r="${halfW}" />
|
||||
</clipPath>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取卡片形状的 SVG path 数据
|
||||
* @param width 卡片宽度
|
||||
* @param height 卡片高度
|
||||
* @param shape 卡片形状
|
||||
* @param cornerRadius 圆角半径(可选)
|
||||
* @param segmentsPerCorner 每个圆角的分段数(可选)
|
||||
*/
|
||||
export function getCardShapePath(
|
||||
width: number,
|
||||
height: number,
|
||||
shape: CardShape,
|
||||
cornerRadius: number = 0,
|
||||
segmentsPerCorner: number = 4
|
||||
): string {
|
||||
const points = getCardShapePoints(shape, width, height, cornerRadius, segmentsPerCorner);
|
||||
return contourToSvgPath(points, true);
|
||||
case 'triangle':
|
||||
return `
|
||||
<clipPath id="${id}">
|
||||
<polygon points="${halfW},0 0,${height} ${width},${height}" />
|
||||
</clipPath>`;
|
||||
case 'hexagon':
|
||||
return `
|
||||
<clipPath id="${id}">
|
||||
<polygon points="${halfW},0 ${width},${height * 0.25} ${width},${height * 0.75} ${halfW},${height} 0,${height * 0.75} 0,${height * 0.25}" />
|
||||
</clipPath>`;
|
||||
case 'rectangle':
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,74 +1,72 @@
|
|||
import type { CardShape, ContourPoint, ContourBounds } from './types';
|
||||
import {getRoundedPolygonPoints} from "./rounded";
|
||||
|
||||
// 重新导出类型以兼容旧导入路径
|
||||
export type { CardShape, ContourPoint, ContourBounds };
|
||||
|
||||
|
||||
/**
|
||||
* 生成内接正三角形的轮廓点(无圆角版本)
|
||||
* @param width 外框宽度
|
||||
* @param height 外框高度
|
||||
* @returns 正三角形轮廓点(相对于外框左下角,顺时针)
|
||||
* 生成带圆角的矩形轮廓点
|
||||
* @param width 矩形宽度
|
||||
* @param height 矩形高度
|
||||
* @param cornerRadius 圆角半径(mm)
|
||||
* @param segmentsPerCorner 每个圆角的分段数
|
||||
*/
|
||||
export function getInscribedTrianglePoints(
|
||||
export function getRoundedRectPoints(
|
||||
width: number,
|
||||
height: number
|
||||
height: number,
|
||||
cornerRadius: number,
|
||||
segmentsPerCorner: number = 4
|
||||
): [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][] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
// 从 -120度开始,每 60 度一个点,实现平顶且顺时针
|
||||
const angle = (-2 * Math.PI / 3) + (i * Math.PI / 3);
|
||||
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;
|
||||
points.push([
|
||||
centerX + radius * Math.cos(angle),
|
||||
centerY + radius * Math.sin(angle)
|
||||
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),
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 根据形状生成卡片轮廓点(单位:mm,相对于卡片左下角)
|
||||
*/
|
||||
|
|
@ -76,28 +74,10 @@ export function getCardShapePoints(
|
|||
shape: CardShape,
|
||||
width: number,
|
||||
height: number,
|
||||
cornerRadius: number = 0,
|
||||
segmentsPerCorner: number = 4
|
||||
cornerRadius: number = 0
|
||||
): [number, number][] {
|
||||
// 处理带圆角的情况 - 统一使用 getRoundedPolygonPoints
|
||||
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);
|
||||
}
|
||||
if (shape === 'rectangle' && cornerRadius > 0) {
|
||||
return getRoundedRectPoints(width, height, cornerRadius);
|
||||
}
|
||||
|
||||
const points: [number, number][] = [];
|
||||
|
|
@ -117,10 +97,21 @@ export function getCardShapePoints(
|
|||
break;
|
||||
}
|
||||
case 'triangle': {
|
||||
return getInscribedTrianglePoints(width, height);
|
||||
points.push([width / 2, 0]);
|
||||
points.push([0, height]);
|
||||
points.push([width, height]);
|
||||
break;
|
||||
}
|
||||
case 'hexagon': {
|
||||
return getInscribedHexagonPoints(width, height);
|
||||
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: {
|
||||
|
|
@ -203,7 +194,7 @@ export function getPointOnPath(points: [number, number][], progress: number): [n
|
|||
* @param points 轮廓点数组
|
||||
* @param closed 是否闭合路径
|
||||
*/
|
||||
export function contourToSvgPath(points: [number, number][], closed = false): string {
|
||||
export function contourToSvgPath(points: [number, number][], closed = true): string {
|
||||
if (points.length === 0) return '';
|
||||
|
||||
const [startX, startY] = points[0];
|
||||
|
|
|
|||
|
|
@ -19,48 +19,45 @@ export function pts2plotter(
|
|||
) {
|
||||
const start = startPoint ?? [0, height];
|
||||
const end = endPoint ?? [0, height];
|
||||
|
||||
|
||||
let str = init(width * px2mm, height * px2mm);
|
||||
|
||||
// 使用最近邻算法排序路径
|
||||
const sorted = sortPathsByNearestNeighbor(pts, start);
|
||||
// 按 X 轴然后 Y 轴排序路径
|
||||
const sorted = pts.slice();
|
||||
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) {
|
||||
const firstPath = sorted[0];
|
||||
str += ` U${plu(start[0] * px2mm)},${plu((height - start[1]) * px2mm)}`;
|
||||
str += ` D${plu(firstPath[0][0] * px2mm)},${plu((height - firstPath[0][1]) * px2mm)}`;
|
||||
|
||||
|
||||
// 切割第一个路径
|
||||
for (let i = 1; i < firstPath.length; i++) {
|
||||
const pt = firstPath[i];
|
||||
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++) {
|
||||
const prevPath = sorted[i - 1];
|
||||
const currPath = sorted[i];
|
||||
|
||||
|
||||
// 抬刀移动到下一个路径起点
|
||||
str += ` U${plu(currPath[0][0] * px2mm)},${plu((height - currPath[0][1]) * px2mm)}`;
|
||||
// 下刀切割
|
||||
str += ` D${plu(currPath[0][0] * px2mm)},${plu((height - currPath[0][1]) * px2mm)}`;
|
||||
|
||||
|
||||
for (let j = 1; j < currPath.length; j++) {
|
||||
const pt = currPath[j];
|
||||
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)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,72 +88,6 @@ function topleft(pts: [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) {
|
||||
return ` IN TB26,${plu(w)},${plu(h)} CT1`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
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) 段
|
||||
});
|
||||
});
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
/**
|
||||
* 为正多边形添加圆角
|
||||
* 将每个角转换为 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