Compare commits

...

8 Commits

Author SHA1 Message Date
hypercross a840261ae0 feat: path closure 2026-03-15 18:28:52 +08:00
hypercross 2d4b719e10 refactor: better preview 2026-03-15 15:44:35 +08:00
hypercross 2e03ef3591 refactor: better sizing 2026-03-15 15:01:01 +08:00
hypercross 2b1dbd41e1 refactor: preview contour correctly 2026-03-15 14:55:50 +08:00
hypercross ee2fa057f6 fix: contour gen 2026-03-15 14:44:45 +08:00
hypercross 9f665fc403 chore: contour test 2026-03-15 11:45:35 +08:00
hypercross 92dac64326 chore: test suite 2026-03-15 11:44:23 +08:00
hypercross 0ec129c2be feat: hex/trig gen 2026-03-15 11:41:20 +08:00
11 changed files with 6094 additions and 123 deletions

22
jest.config.js Normal file
View File

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

5619
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -19,7 +19,8 @@
"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"
"ttrpg": "node --loader ts-node/esm ./src/cli/index.ts",
"test": "jest"
},
"keywords": [
"ttrpg",
@ -50,9 +51,13 @@
"@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"
}

View File

@ -19,7 +19,11 @@ export function CardPreview(props: CardPreviewProps) {
const selectionStyle = createMemo(() =>
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);

View File

@ -154,9 +154,8 @@ export function PltPreview(props: PltPreviewProps) {
<path
d={travelPathD()}
fill="none"
stroke="#999"
stroke-width="0.2"
stroke-dasharray="2 2"
stroke="#f884"
stroke-width="1"
/>
</Show>

View File

@ -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);
const shapeClipPath = getShapeSvgClipPath(clipPathId, cardWidth, cardHeight, store.state.shape, store.state.cornerRadius);
return (
<g class="card-group">

View File

@ -1,20 +1,52 @@
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): string {
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 '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);
}
/**
@ -23,34 +55,41 @@ export function getShapeClipPath(shape: CardShape): string {
* @param width
* @param height
* @param shape
* @param cornerRadius
* @param segmentsPerCorner
*/
export function getShapeSvgClipPath(
id: string,
width: number,
height: number,
shape: CardShape
shape: CardShape,
cornerRadius: number = 0,
segmentsPerCorner: number = 4
): string {
const halfW = width / 2;
const halfH = height / 2;
const points = getCardShapePoints(shape, width, height, cornerRadius, segmentsPerCorner);
const pathData = contourToSvgPath(points, true);
switch (shape) {
case 'circle':
return `
<clipPath id="${id}">
<circle cx="${halfW}" cy="${halfH}" r="${halfW}" />
<path d="${pathData}" />
</clipPath>`;
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 '';
}
}
/**
* 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);
}

View File

@ -1,72 +1,74 @@
import type { CardShape, ContourPoint, ContourBounds } from './types';
import {getRoundedPolygonPoints} from "./rounded";
// 重新导出类型以兼容旧导入路径
export type { CardShape, ContourPoint, ContourBounds };
/**
*
* @param width
* @param height
* @param cornerRadius mm
* @param segmentsPerCorner
*
* @param width
* @param height
* @returns
*/
export function getRoundedRectPoints(
export function getInscribedTrianglePoints(
width: number,
height: number,
cornerRadius: number,
segmentsPerCorner: number = 4
height: number
): [number, number][] {
const points: [number, number][] = [];
const r = Math.min(cornerRadius, width / 2, height / 2);
// 以短边为基准计算内接正三角形的边长
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] // 顶部顶点
];
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([
r + r * Math.cos(angle),
r + r * Math.sin(angle)
]);
}
/**
*
* @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;
// 右上角圆角
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)
]);
}
// 中心点
const centerX = width / 2;
const centerY = height / 2;
// 右下角圆角
for (let i = 0; i <= segmentsPerCorner; i++) {
const angle = (Math.PI / 2) * (i / segmentsPerCorner);
// 正六边形六个顶点(平顶,从左上角开始顺时针)
// 角度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);
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)
centerX + radius * Math.cos(angle),
centerY + radius * Math.sin(angle)
]);
}
return points;
}
/**
* mm
*/
@ -74,10 +76,28 @@ export function getCardShapePoints(
shape: CardShape,
width: number,
height: number,
cornerRadius: number = 0
cornerRadius: number = 0,
segmentsPerCorner: number = 4
): [number, number][] {
if (shape === 'rectangle' && cornerRadius > 0) {
return getRoundedRectPoints(width, height, cornerRadius);
// 处理带圆角的情况 - 统一使用 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);
}
}
const points: [number, number][] = [];
@ -97,21 +117,10 @@ export function getCardShapePoints(
break;
}
case 'triangle': {
points.push([width / 2, 0]);
points.push([0, height]);
points.push([width, height]);
break;
return getInscribedTrianglePoints(width, height);
}
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;
return getInscribedHexagonPoints(width, height);
}
case 'rectangle':
default: {
@ -194,7 +203,7 @@ export function getPointOnPath(points: [number, number][], progress: number): [n
* @param points
* @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 '';
const [startX, startY] = points[0];

View File

@ -22,15 +22,8 @@ export function pts2plotter(
let str = init(width * px2mm, height * px2mm);
// 按 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;
});
// 使用最近邻算法排序路径
const sorted = sortPathsByNearestNeighbor(pts, start);
// 从起点到第一个路径
if (sorted.length > 0) {
@ -44,6 +37,11 @@ export function pts2plotter(
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];
@ -58,6 +56,11 @@ export function pts2plotter(
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)}`;
}
}
}
@ -88,6 +91,72 @@ 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`;
}

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