From ee2fa057f65f19565557992339b6903f6715a0fa Mon Sep 17 00:00:00 2001 From: hypercross Date: Sun, 15 Mar 2026 11:48:20 +0800 Subject: [PATCH] fix: contour gen --- src/plotcutter/contour.test.ts | 263 --------------------------------- src/plotcutter/contour.ts | 126 +--------------- src/plotcutter/rounded.test.ts | 80 ++++++++++ src/plotcutter/rounded.ts | 129 ++++++++++++++++ 4 files changed, 216 insertions(+), 382 deletions(-) delete mode 100644 src/plotcutter/contour.test.ts create mode 100644 src/plotcutter/rounded.test.ts create mode 100644 src/plotcutter/rounded.ts diff --git a/src/plotcutter/contour.test.ts b/src/plotcutter/contour.test.ts deleted file mode 100644 index bdc710c..0000000 --- a/src/plotcutter/contour.test.ts +++ /dev/null @@ -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); - } - }); - }); -}); diff --git a/src/plotcutter/contour.ts b/src/plotcutter/contour.ts index e821b33..2276168 100644 --- a/src/plotcutter/contour.ts +++ b/src/plotcutter/contour.ts @@ -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,相对于卡片左下角) diff --git a/src/plotcutter/rounded.test.ts b/src/plotcutter/rounded.test.ts new file mode 100644 index 0000000..43f482e --- /dev/null +++ b/src/plotcutter/rounded.test.ts @@ -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) 段 + }); +}); diff --git a/src/plotcutter/rounded.ts b/src/plotcutter/rounded.ts new file mode 100644 index 0000000..4ae9933 --- /dev/null +++ b/src/plotcutter/rounded.ts @@ -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 + ]; +}