packages/core/src/lib/geometry/rounded-corners.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
1 import type { PolygonPathOptions } from './polygon.js';
2 import type { RectanglePathOptions } from './rectangle.js';
3 import type { StarPathOptions } from './star.js';
4 import type { RegularPolygonPathOptions } from './regular-polygon.js';
5 import { createLinePath } from './line.js';
6 import {
7 nonnegativeNumber,
8 pathRangeError,
9 type GeometryErrorFactory,
10 } from './validation.js';
11
12 /**
13 * Uniform or per-corner radii for rounding supported path vertices.
14 *
15 * @see {@link PolygonPathOptions}
16 * @see {@link RectanglePathOptions}
17 * @see {@link StarPathOptions}
18 * @see {@link RegularPolygonPathOptions}
19 */
20 export interface RoundedCornerPathOptions {
21 /**
22 * Uniform corner radius used where no per-corner override is supplied. See
23 * {@link RoundedCornerPathOptions}.
24 */
25 cornerRadius?: number;
26 /**
27 * Per-corner radii; nullish entries use the uniform corner radius. See
28 * {@link RoundedCornerPathOptions}.
29 */
30 cornerRadii?: readonly (number | null | undefined)[];
31 }
32
33 export type ResolvedCornerRadii = readonly number[];
34
35 export function resolveNumericCornerRadii(
36 cornerCount: number,
37 options: Readonly<RoundedCornerPathOptions>,
38 errorFactory: GeometryErrorFactory = pathRangeError,
39 ): ResolvedCornerRadii {
40 if (options.cornerRadii && options.cornerRadii.length > cornerCount) {
41 throw errorFactory(
42 'cornerRadii',
43 options.cornerRadii.length,
44 `length must not exceed corner count ${cornerCount}`,
45 );
46 }
47
48 const cornerRadius = options.cornerRadius === undefined ? 0 :
49 nonnegativeNumber(options.cornerRadius, 'cornerRadius', errorFactory);
50 const cornerRadii = options.cornerRadii;
51
52 if (cornerRadii) {
53 for (let index = 0; index < cornerRadii.length; index++) {
54 const radius = cornerRadii[index];
55 if (radius !== null && radius !== undefined) {
56 nonnegativeNumber(radius, `cornerRadii[${index}]`, errorFactory);
57 }
58 }
59 }
60
61 return Array.from(
62 { length: cornerCount },
63 (_, index) => cornerRadii?.[index] ?? cornerRadius,
64 );
65 }
66
67 interface RoundedCorner {
68 x: number;
69 y: number;
70 towardPreviousX: number;
71 towardPreviousY: number;
72 towardNextX: number;
73 towardNextY: number;
74 radius: number;
75 demand: number;
76 rounded: boolean;
77 }
78
79 export function createRoundedClosedPolygonPath(
80 coords: readonly (readonly [number, number])[],
81 radii: ResolvedCornerRadii,
82 ): Path2D {
83 if (coords.length < 3) return new Path2D();
84 if (radii.every(radius => radius === 0)) {
85 return createLinePath({ coords, closePath: true });
86 }
87
88 const corners = coords.map(([x, y], index): RoundedCorner => {
89 const [previousX, previousY] = coords[
90 (index - 1 + coords.length) % coords.length
91 ];
92 const [nextX, nextY] = coords[(index + 1) % coords.length];
93 const previousDx = previousX - x;
94 const previousDy = previousY - y;
95 const nextDx = nextX - x;
96 const nextDy = nextY - y;
97 const previousLength = Math.hypot(previousDx, previousDy);
98 const nextLength = Math.hypot(nextDx, nextDy);
99 const radius = radii[index];
100 const hasUnitRays = previousLength !== 0 && nextLength !== 0;
101 const towardPreviousX = hasUnitRays ? previousDx / previousLength : 0;
102 const towardPreviousY = hasUnitRays ? previousDy / previousLength : 0;
103 const towardNextX = hasUnitRays ? nextDx / nextLength : 0;
104 const towardNextY = hasUnitRays ? nextDy / nextLength : 0;
105 const exactlyCollinear = hasUnitRays &&
106 towardPreviousX * towardNextY === towardPreviousY * towardNextX;
107
108 if (
109 radius === 0 ||
110 !hasUnitRays ||
111 exactlyCollinear
112 ) {
113 return {
114 x,
115 y,
116 towardPreviousX: 0,
117 towardPreviousY: 0,
118 towardNextX: 0,
119 towardNextY: 0,
120 radius,
121 demand: 0,
122 rounded: false,
123 };
124 }
125
126 const dot = Math.max(-1, Math.min(1,
127 towardPreviousX * towardNextX + towardPreviousY * towardNextY,
128 ));
129 const theta = Math.acos(dot);
130
131 return {
132 x,
133 y,
134 towardPreviousX,
135 towardPreviousY,
136 towardNextX,
137 towardNextY,
138 radius,
139 demand: radius / Math.tan(theta / 2),
140 rounded: true,
141 };
142 });
143
144 const edgeLengths = coords.map(([x, y], index) => {
145 const [nextX, nextY] = coords[(index + 1) % coords.length];
146 return Math.hypot(nextX - x, nextY - y);
147 });
148 const scale = edgeLengths.reduce((current, edgeLength, index) => {
149 const next = (index + 1) % corners.length;
150 const totalDemand = corners[index].demand + corners[next].demand;
151 return totalDemand > 0 ?
152 Math.min(current, edgeLength / totalDemand) :
153 current;
154 }, 1);
155 const scaledDemand = (corner: RoundedCorner) =>
156 scale === 0 ? 0 : corner.demand * scale;
157 const first = corners[0];
158 const firstDemand = scaledDemand(first);
159 const path = new Path2D();
160 path.moveTo(
161 first.x + first.towardPreviousX * firstDemand,
162 first.y + first.towardPreviousY * firstDemand,
163 );
164
165 for (const corner of corners) {
166 if (!corner.rounded) {
167 path.lineTo(corner.x, corner.y);
168 continue;
169 }
170 const demand = scaledDemand(corner);
171 path.arcTo(
172 corner.x,
173 corner.y,
174 corner.x + corner.towardNextX * demand,
175 corner.y + corner.towardNextY * demand,
176 corner.radius * scale,
177 );
178 }
179 path.closePath();
180 return path;
181 }
182
Documentation version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.