ttrpg-tools/src/plotcutter/contour.ts

359 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { CardShape, ContourPoint, ContourBounds } from './types';
// 重新导出类型以兼容旧导入路径
export type { CardShape, ContourPoint, ContourBounds };
/**
* 生成内接正三角形的轮廓点(无圆角版本)
* @param width 外框宽度
* @param height 外框高度
* @returns 正三角形轮廓点(相对于外框左下角,顺时针)
*/
export function getInscribedTrianglePoints(
width: number,
height: number
): [number, number][] {
// 以短边为基准计算内接正三角形的边长
const minDim = Math.min(width, height);
// 正三角形的高 = 边长 * 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);
const radius = minDim / 2;
// 中心点
const centerX = width / 2;
const centerY = height / 2;
// 正六边形六个顶点(平顶,从右上角开始顺时针)
// 角度:-60°, 0°, 60°, 120°, 180°, 240° (顺时针)
const points: [number, number][] = [];
for (let i = 0; i < 6; i++) {
const angle = (-Math.PI / 3) + (i * Math.PI / 3); // 从 -60° 开始,顺时针
points.push([
centerX + radius * Math.cos(angle),
centerY - radius * Math.sin(angle) // Y 向下为正,所以减去 sin
]);
}
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相对于卡片左下角
*/
export function getCardShapePoints(
shape: CardShape,
width: number,
height: number,
cornerRadius: number = 0,
segmentsPerCorner: number = 4
): [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);
}
}
const points: [number, number][] = [];
switch (shape) {
case 'circle': {
const radius = Math.min(width, height) / 2;
const centerX = width / 2;
const centerY = height / 2;
for (let i = 0; i < 36; i++) {
const angle = (i / 36) * Math.PI * 2;
points.push([
centerX + radius * Math.cos(angle),
centerY + radius * Math.sin(angle)
]);
}
break;
}
case 'triangle': {
return getInscribedTrianglePoints(width, height);
}
case 'hexagon': {
return getInscribedHexagonPoints(width, height);
}
case 'rectangle':
default: {
points.push([0, 0]);
points.push([width, 0]);
points.push([width, height]);
points.push([0, height]);
break;
}
}
return points;
}
/**
* 计算多边形的中心点
*/
export function calculateCenter(points: [number, number][]): { x: number; y: number } {
let sumX = 0;
let sumY = 0;
for (const [x, y] of points) {
sumX += x;
sumY += y;
}
return {
x: sumX / points.length,
y: sumY / points.length
};
}
/**
* 计算轮廓的边界框
*/
export function calculateBounds(points: [number, number][]): ContourBounds {
if (points.length === 0) {
return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const [x, y] of points) {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
return { minX, minY, maxX, maxY };
}
/**
* 根据进度计算点在路径上的位置
*/
export function getPointOnPath(points: [number, number][], progress: number): [number, number] {
if (points.length === 0) return [0, 0];
if (points.length === 1) return points[0];
const totalSegments = points.length;
const scaledProgress = progress * totalSegments;
const segmentIndex = Math.floor(scaledProgress);
const segmentProgress = scaledProgress - segmentIndex;
const currentIndex = Math.min(segmentIndex, points.length - 1);
const nextIndex = (currentIndex + 1) % points.length;
const [x1, y1] = points[currentIndex];
const [x2, y2] = points[nextIndex];
return [
x1 + (x2 - x1) * segmentProgress,
y1 + (y2 - y1) * segmentProgress
];
}
/**
* 将轮廓点转换为 SVG path 命令
* @param points 轮廓点数组
* @param closed 是否闭合路径
*/
export function contourToSvgPath(points: [number, number][], closed = true): string {
if (points.length === 0) return '';
const [startX, startY] = points[0];
let d = `M ${startX} ${startY}`;
for (let i = 1; i < points.length; i++) {
const [x, y] = points[i];
d += ` L ${x} ${y}`;
}
if (closed) {
d += ' Z';
}
return d;
}
/**
* 平移轮廓点(添加偏移量)
*/
export function translateContour(
points: [number, number][],
offsetX: number,
offsetY: number
): [number, number][] {
return points.map(([x, y]) => [x + offsetX, y + offsetY] as [number, number]);
}
/**
* 翻转轮廓(用于 SVG 坐标转换Y 轴翻转)
* @param points 轮廓点
* @param height 画布高度
*/
export function flipContourY(
points: [number, number][],
height: number
): [number, number][] {
return points.map(([x, y]) => [x, height - y] as [number, number]);
}