Compare commits

..

No commits in common. "a840261ae0ea09bac9c7a0faa9fe2df37cb0b600" and "7aba5d0c81e335e23b86152af64b69ce7e1957f1" have entirely different histories.

11 changed files with 123 additions and 6094 deletions

View File

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

5619
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -19,8 +19,7 @@
"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",
@ -51,13 +50,9 @@
"@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"
} }

View File

@ -19,11 +19,7 @@ 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(() => { const shapeClipPath = createMemo(() => getShapeClipPath(store.state.shape));
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);

View File

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

View File

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

View File

@ -1,52 +1,20 @@
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( export function getShapeClipPath(shape: CardShape): string {
shape: CardShape, switch (shape) {
width: number, case 'circle':
height: number, return 'circle(50% at 50% 50%)';
cornerRadius: number = 0, case 'triangle':
segmentsPerCorner: number = 4 return 'polygon(50% 0%, 0% 100%, 100% 100%)';
): string { case 'hexagon':
// 无圆角的基本形状使用简化的 CSS clip-path return 'polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)';
if (cornerRadius <= 0) { case 'rectangle':
switch (shape) { default:
case 'circle': return 'none';
return 'circle(50% at 50% 50%)';
case 'rectangle':
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 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 points = getCardShapePoints(shape, width, height, cornerRadius, segmentsPerCorner); const halfW = width / 2;
const pathData = contourToSvgPath(points, true); const halfH = height / 2;
return ` switch (shape) {
case 'circle':
return `
<clipPath id="${id}"> <clipPath id="${id}">
<path d="${pathData}" /> <circle cx="${halfW}" cy="${halfH}" r="${halfW}" />
</clipPath>`; </clipPath>`;
} case 'triangle':
return `
/** <clipPath id="${id}">
* SVG path <polygon points="${halfW},0 0,${height} ${width},${height}" />
* @param width </clipPath>`;
* @param height case 'hexagon':
* @param shape return `
* @param cornerRadius <clipPath id="${id}">
* @param segmentsPerCorner <polygon points="${halfW},0 ${width},${height * 0.25} ${width},${height * 0.75} ${halfW},${height} 0,${height * 0.75} 0,${height * 0.25}" />
*/ </clipPath>`;
export function getCardShapePath( case 'rectangle':
width: number, default:
height: number, return '';
shape: CardShape, }
cornerRadius: number = 0,
segmentsPerCorner: number = 4
): string {
const points = getCardShapePoints(shape, width, height, cornerRadius, segmentsPerCorner);
return contourToSvgPath(points, true);
} }

View File

@ -1,74 +1,72 @@
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
* @returns * @param cornerRadius mm
* @param segmentsPerCorner
*/ */
export function getInscribedTrianglePoints( export function getRoundedRectPoints(
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][] = [];
for (let i = 0; i < 6; i++) { const r = Math.min(cornerRadius, width / 2, height / 2);
// 从 -120度开始每 60 度一个点,实现平顶且顺时针
const angle = (-2 * Math.PI / 3) + (i * Math.PI / 3); 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([ points.push([
centerX + radius * Math.cos(angle), r + r * Math.cos(angle),
centerY + radius * Math.sin(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; return points;
} }
/** /**
* mm * mm
*/ */
@ -76,28 +74,10 @@ 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][] {
// 处理带圆角的情况 - 统一使用 getRoundedPolygonPoints if (shape === 'rectangle' && cornerRadius > 0) {
if (cornerRadius > 0) { return getRoundedRectPoints(width, height, cornerRadius);
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][] = [];
@ -117,10 +97,21 @@ export function getCardShapePoints(
break; break;
} }
case 'triangle': { case 'triangle': {
return getInscribedTrianglePoints(width, height); points.push([width / 2, 0]);
points.push([0, height]);
points.push([width, height]);
break;
} }
case 'hexagon': { 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': case 'rectangle':
default: { default: {
@ -203,7 +194,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 = false): string { export function contourToSvgPath(points: [number, number][], closed = true): string {
if (points.length === 0) return ''; if (points.length === 0) return '';
const [startX, startY] = points[0]; const [startX, startY] = points[0];

View File

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

View File

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

View File

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