fix: contour gen

This commit is contained in:
hypercross 2026-03-15 11:48:20 +08:00
parent 9f665fc403
commit ee2fa057f6
4 changed files with 216 additions and 382 deletions

View File

@ -1,263 +0,0 @@
import { describe, test, expect } from '@jest/globals';
import {
getRoundedPolygonPoints,
getCardShapePoints,
getInscribedTrianglePoints,
getInscribedHexagonPoints
} from './contour';
/**
*
*/
function distance(p1: [number, number], p2: [number, number]): number {
return Math.sqrt((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2);
}
/**
*
*/
function pointCloseTo(actual: [number, number], expected: [number, number], tolerance: number = 0.01): boolean {
return Math.abs(actual[0] - expected[0]) < tolerance &&
Math.abs(actual[1] - expected[1]) < tolerance;
}
describe('getRoundedPolygonPoints', () => {
describe('矩形圆角', () => {
test('应该生成正确的矩形圆角 (segmentsPerCorner=2)', () => {
const vertices: [number, number][] = [
[0, 0],
[100, 0],
[100, 60],
[0, 60]
];
const cornerRadius = 10;
const segmentsPerCorner = 2;
const points = getRoundedPolygonPoints(vertices, cornerRadius, segmentsPerCorner);
// 每个角应该有:起点 + 2 个弧线点 = 3 个点4 个角共 12 个点
// 但由于结构是:起点 + 弧线点 1 + 弧线点 2然后下一条边的起点就是上一个角的终点
// 所以总点数应该是 4 * (1 + 2) = 12 个点
expect(points.length).toBe(12);
// 验证第一个角(左下角,顶点 [0, 0])的起点应该在 (0, 10)
expect(pointCloseTo(points[0], [0, 10])).toBe(true);
// 验证第一个角的弧线点应该在圆上
const center0: [number, number] = [10, 10];
const arcPoint1: [number, number] = points[1];
const arcPoint2: [number, number] = points[2];
// 检查弧线点到圆心的距离是否接近半径
const dist1 = distance(arcPoint1, center0);
const dist2 = distance(arcPoint2, center0);
expect(Math.abs(dist1 - cornerRadius)).toBeLessThan(0.1);
expect(Math.abs(dist2 - cornerRadius)).toBeLessThan(0.1);
// 验证第二个角(右下角,顶点 [100, 0])的起点应该在 (90, 0)
expect(pointCloseTo(points[3], [90, 0])).toBe(true);
});
test('应该生成闭合的轮廓', () => {
const vertices: [number, number][] = [
[0, 0],
[100, 0],
[100, 60],
[0, 60]
];
const cornerRadius = 10;
const segmentsPerCorner = 4;
const points = getRoundedPolygonPoints(vertices, cornerRadius, segmentsPerCorner);
// 验证最后一个点和第一个点之间的距离应该合理(闭合路径)
const firstPoint = points[0];
const lastPoint = points[points.length - 1];
// 最后一个点应该是最后一个角的弧线终点,接近第一个点
// 实际上,由于每个角都从起点开始,最后一个点应该接近第一个角的起点
const dist = distance(firstPoint, lastPoint);
expect(dist).toBeLessThan(cornerRadius * 0.5); // 应该比较接近
});
test('圆角半径过大时应该被限制', () => {
const vertices: [number, number][] = [
[0, 0],
[20, 0],
[20, 60],
[0, 60]
];
const cornerRadius = 50; // 远大于边长的一半
const points = getRoundedPolygonPoints(vertices, cornerRadius);
// 验证所有点都在边界框内
for (const [x, y] of points) {
expect(x).toBeGreaterThanOrEqual(0);
expect(x).toBeLessThanOrEqual(20);
expect(y).toBeGreaterThanOrEqual(0);
expect(y).toBeLessThanOrEqual(60);
}
});
});
describe('三角形圆角', () => {
test('应该为三角形生成正确的圆角 (segmentsPerCorner=2)', () => {
const vertices = getInscribedTrianglePoints(100, 60);
const cornerRadius = 5;
const segmentsPerCorner = 2;
const points = getRoundedPolygonPoints(vertices, cornerRadius, segmentsPerCorner);
// 3 个角,每个角有 1 个起点 + 2 个弧线点 = 3 个点
expect(points.length).toBe(9);
// 验证所有弧线点到对应圆心的距离接近半径
// (这里简化验证,只检查点数和合理性)
expect(points.length).toBeGreaterThan(0);
// 验证所有点都在边界框内
for (const [x, y] of points) {
expect(x).toBeGreaterThanOrEqual(0);
expect(x).toBeLessThanOrEqual(100);
expect(y).toBeGreaterThanOrEqual(0);
expect(y).toBeLessThanOrEqual(60);
}
});
});
describe('六边形圆角', () => {
test('应该为六边形生成正确的圆角 (segmentsPerCorner=2)', () => {
const vertices = getInscribedHexagonPoints(100, 60);
const cornerRadius = 5;
const segmentsPerCorner = 2;
const points = getRoundedPolygonPoints(vertices, cornerRadius, segmentsPerCorner);
// 6 个角,每个角有 1 个起点 + 2 个弧线点 = 3 个点
expect(points.length).toBe(18);
// 验证所有点都在边界框内
for (const [x, y] of points) {
expect(x).toBeGreaterThanOrEqual(0);
expect(x).toBeLessThanOrEqual(100);
expect(y).toBeGreaterThanOrEqual(0);
expect(y).toBeLessThanOrEqual(60);
}
});
});
describe('边界情况', () => {
test('圆角半径为 0 时返回原始顶点', () => {
const vertices: [number, number][] = [
[0, 0],
[100, 0],
[100, 60],
[0, 60]
];
const points = getRoundedPolygonPoints(vertices, 0);
expect(points).toEqual(vertices);
});
test('顶点数少于 3 个时返回原始顶点', () => {
const line: [number, number][] = [[0, 0], [100, 0]];
const points = getRoundedPolygonPoints(line, 10);
expect(points).toEqual(line);
});
});
});
describe('getCardShapePoints', () => {
describe('矩形', () => {
test('无圆角矩形', () => {
const points = getCardShapePoints('rectangle', 100, 60, 0);
expect(points.length).toBe(4);
expect(points).toEqual([
[0, 0],
[100, 0],
[100, 60],
[0, 60]
]);
});
test('带圆角矩形 (segmentsPerCorner=2)', () => {
const points = getCardShapePoints('rectangle', 100, 60, 10, 2);
// 4 个角,每个角有 1 个起点 + 2 个弧线点 = 3 个点
expect(points.length).toBe(12);
// 验证第一个点在左边y 坐标为圆角半径
expect(pointCloseTo(points[0], [0, 10])).toBe(true);
});
test('圆角矩形应该是对称的', () => {
const width = 100;
const height = 60;
const cornerRadius = 10;
const segmentsPerCorner = 4;
const points = getCardShapePoints('rectangle', width, height, cornerRadius, segmentsPerCorner);
// 验证左右对称
const leftPoints = points.filter(([x]) => x < width / 2);
const rightPoints = points.filter(([x]) => x > width / 2);
expect(leftPoints.length).toBe(rightPoints.length);
// 验证上下对称
const topPoints = points.filter(([, y]) => y < height / 2);
const bottomPoints = points.filter(([, y]) => y > height / 2);
expect(topPoints.length).toBe(bottomPoints.length);
});
});
describe('三角形', () => {
test('无圆角三角形', () => {
const points = getCardShapePoints('triangle', 100, 60, 0);
expect(points.length).toBe(3);
});
test('带圆角三角形 (segmentsPerCorner=2)', () => {
const points = getCardShapePoints('triangle', 100, 60, 10, 2);
// 3 个角,每个角有 1 个起点 + 2 个弧线点 = 3 个点
expect(points.length).toBe(9);
});
});
describe('六边形', () => {
test('无圆角六边形', () => {
const points = getCardShapePoints('hexagon', 100, 60, 0);
expect(points.length).toBe(6);
});
test('带圆角六边形 (segmentsPerCorner=2)', () => {
const points = getCardShapePoints('hexagon', 100, 60, 10, 2);
// 6 个角,每个角有 1 个起点 + 2 个弧线点 = 3 个点
expect(points.length).toBe(18);
});
});
describe('圆形', () => {
test('圆形轮廓', () => {
const points = getCardShapePoints('circle', 100, 60, 0);
// 圆形固定生成 36 个点
expect(points.length).toBe(36);
// 验证所有点到中心的距离接近半径
const centerX = 50;
const centerY = 30;
const radius = 30; // min(100, 60) / 2
for (const [x, y] of points) {
const dist = distance([x, y], [centerX, centerY]);
expect(Math.abs(dist - radius)).toBeLessThan(0.1);
}
});
});
});

View File

@ -1,4 +1,5 @@
import type { CardShape, ContourPoint, ContourBounds } from './types';
import {getRoundedPolygonPoints} from "./rounded";
// 重新导出类型以兼容旧导入路径
export type { CardShape, ContourPoint, ContourBounds };
@ -52,135 +53,22 @@ export function getInscribedHexagonPoints(
const centerX = width / 2;
const centerY = height / 2;
// 正六边形六个顶点(平顶,从右上角开始顺时针)
// 角度:-60°, 0°, 60°, 120°, 180°, 240° (顺时针)
// 正六边形六个顶点(平顶,从左上角开始顺时针)
// 角度210°, 270°, 330°, 30°, 90°, 150° (数学坐标系Y向上)
// 在屏幕坐标系 (Y向下),我们直接按顺时针计算
const points: [number, number][] = [];
for (let i = 0; i < 6; i++) {
const angle = (-Math.PI / 3) + (i * Math.PI / 3); // 从 -60° 开始,顺时针
// 从 -120度开始每 60 度一个点,实现平顶且顺时针
const angle = (-2 * Math.PI / 3) + (i * Math.PI / 3);
points.push([
centerX + radius * Math.cos(angle),
centerY - radius * Math.sin(angle) // Y 向下为正,所以减去 sin
centerY + radius * Math.sin(angle)
]);
}
return points;
}
/**
*
* @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 n = vertices.length;
// 计算最大允许的圆角半径(不超过边长的一半)
let maxRadius = Infinity;
for (let i = 0; i < n; i++) {
const [x1, y1] = vertices[i];
const [x2, y2] = vertices[(i + 1) % n];
const edgeLength = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
maxRadius = Math.min(maxRadius, edgeLength / 2);
}
const r = Math.min(cornerRadius, maxRadius);
if (r <= 0) {
return vertices;
}
const points: [number, number][] = [];
for (let i = 0; i < n; i++) {
const currVertex = vertices[i];
const nextVertex = vertices[(i + 1) % n];
// 计算当前边的向量
const edgeDx = nextVertex[0] - currVertex[0];
const edgeDy = nextVertex[1] - currVertex[1];
const edgeLen = Math.sqrt(edgeDx ** 2 + edgeDy ** 2);
const edgeUx = edgeDx / edgeLen;
const edgeUy = edgeDy / edgeLen;
// 计算当前边上的圆角终点(距离顶点 r 的位置)
const endPx = currVertex[0] + edgeUx * r;
const endPy = currVertex[1] + edgeUy * r;
// 计算上一条边的向量
const prevVertex = vertices[(i - 1 + n) % n];
const prevEdgeDx = currVertex[0] - prevVertex[0];
const prevEdgeDy = currVertex[1] - prevVertex[1];
const prevEdgeLen = Math.sqrt(prevEdgeDx ** 2 + prevEdgeDy ** 2);
const prevEdgeUx = prevEdgeDx / prevEdgeLen;
const prevEdgeUy = prevEdgeDy / prevEdgeLen;
// 计算上一条边上的圆角起点(距离顶点 r 的位置)
const startPx = currVertex[0] - prevEdgeUx * r;
const startPy = currVertex[1] - prevEdgeUy * r;
// 计算角平分线方向
const bisectorX = prevEdgeUx + edgeUx;
const bisectorY = prevEdgeUy + edgeUy;
const bisectorLen = Math.sqrt(bisectorX ** 2 + bisectorY ** 2);
// 如果 bisectorLen 接近 0说明是 180 度角(直线),跳过圆角
if (bisectorLen < 1e-6) {
points.push([endPx, endPy]);
continue;
}
const normalX = bisectorX / bisectorLen;
const normalY = bisectorY / bisectorLen;
// 计算叉积判断方向(顺时针/逆时针)
const crossProduct = prevEdgeUx * edgeUy - prevEdgeUy * edgeUx;
const direction = crossProduct >= 0 ? -1 : 1; // -1 表示内角1 表示外角
// 计算圆角中心
const angle = Math.acos(Math.max(-1, Math.min(1, prevEdgeUx * edgeUx + prevEdgeUy * edgeUy)));
const tangentDistance = r / Math.tan(angle / 2);
const centerX = currVertex[0] + direction * normalX * tangentDistance;
const centerY = currVertex[1] + direction * normalY * tangentDistance;
// 计算起始角度和结束角度
const startAngle = Math.atan2(startPy - centerY, startPx - centerX);
const endAngle = Math.atan2(endPy - centerY, endPx - centerX);
// 添加圆角起点
points.push([startPx, startPy]);
// 生成圆角弧线点
let angleDiff = endAngle - startAngle;
// 根据方向调整角度差
if (direction === -1) {
// 顺时针(内角)
if (angleDiff < 0) angleDiff += 2 * Math.PI;
} else {
// 逆时针(外角)
if (angleDiff > 0) angleDiff -= 2 * Math.PI;
}
// 生成弧线点(不包括起点,因为已经添加了)
for (let j = 1; j <= segmentsPerCorner; j++) {
const t = j / segmentsPerCorner;
const arcAngle = startAngle + angleDiff * t;
points.push([
centerX + r * Math.cos(arcAngle),
centerY + r * Math.sin(arcAngle)
]);
}
}
return points;
}
/**
* mm

View File

@ -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) 段
});
});

129
src/plotcutter/rounded.ts Normal file
View File

@ -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
];
}