Skip to content

packages/core/src/lib/geometry/rounded-corners.ts

Read as Markdown

This is the source snapshot used to build these API details. View this revision on GitHub.

Back to reference

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 built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.