ttrpg-tools/src/plotcutter/contour.ts

246 lines
6.5 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';
import {getRoundedPolygonPoints} from "./rounded";
// 重新导出类型以兼容旧导入路径
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 / 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][] = [];
for (let i = 0; i < 6; i++) {
// 从 -120度开始每 60 度一个点,实现平顶且顺时针
const angle = (-2 * Math.PI / 3) + (i * Math.PI / 3);
points.push([
centerX + radius * Math.cos(angle),
centerY + radius * Math.sin(angle)
]);
}
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 = false): 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]);
}