Skip to content

packages/core/src/lib/geometry/path-utilities.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 { flattenPathWang } from './path-wang.js';
2 import type { simplifyPathRadial } from './path-radial.js';
3 import { arcPoint, TAU, type ArcSegment } from './path-arc.js';
4 import { PathGeometry, type PathSegment } from './path-geometry.js';
5 
6 const DEFAULT_MAX_SEGMENTS = 1_000_000;
7 
8 /**
9  * Positive approximation tolerance and output-segment budget for curve flattening.
10  *
11  * @see {@link flattenPath}
12  * @see {@link flattenPathWang}
13  */
14 export interface FlattenPathOptions {
15   /** Positive maximum geometric approximation error. See {@link FlattenPathOptions}. */
16   readonly tolerance: number;
17   /** Upper bound on the number of output path segments. See {@link FlattenPathOptions}. */
18   readonly maxSegments?: number;
19 }
20 
21 /**
22  * Positive tolerance and output-segment budget for polyline simplification.
23  *
24  * @see {@link simplifyPath}
25  * @see {@link simplifyPathRadial}
26  */
27 export interface SimplifyPathOptions {
28   /** Positive maximum geometric approximation error. See {@link SimplifyPathOptions}. */
29   readonly tolerance: number;
30   /** Upper bound on the number of output path segments. See {@link SimplifyPathOptions}. */
31   readonly maxSegments?: number;
32 }
33 
34 /**
35  * Subdivision count and output-segment budget for splitting path segments.
36  *
37  * @see {@link subdividePath}
38  */
39 export interface SubdividePathOptions {
40   /**
41    * Number of pieces produced from each drawable path segment. See {@link SubdividePathOptions}.
42    */
43   readonly divisions: number;
44   /** Upper bound on the number of output path segments. See {@link SubdividePathOptions}. */
45   readonly maxSegments?: number;
46 }
47 
48 type Point = readonly [number, number];
49 
50 /**
51  * Returns independent geometry with curves approximated by line segments within the requested
52  * tolerance.
53  *
54  * @param path - Source geometry; it is not modified. See {@link PathGeometry}.
55  * @param options - Maximum approximation error and flattening work limits. See
56  * {@link FlattenPathOptions} .
57  * @returns Independent geometry with curves replaced by line segments. See {@link PathGeometry}.
58  *
59  * @see {@link PathGeometry}
60  * @see {@link FlattenPathOptions}
61  */
62 export function flattenPath(path: PathGeometry, options: Readonly<FlattenPathOptions>): PathGeometry {
63   const tolerance = requiredPositive(options.tolerance, 'tolerance');
64   const output = collector(options.maxSegments);
65   let current: Point = [0, 0];
66   let start: Point = [0, 0];
67   for (const segment of path) {
68     switch (segment.type) {
69       case 'move': current = start = [segment.x, segment.y]; output.add(segment); break;
70       case 'line': current = [segment.x, segment.y]; output.add(segment); break;
71       case 'close': current = start; output.add(segment); break;
72       case 'quadratic': {
73         const end: Point = [segment.x, segment.y];
74         flattenQuadratic(current, [segment.cpx, segment.cpy], end, tolerance, output);
75         current = end;
76         break;
77       }
78       case 'cubic': {
79         const end: Point = [segment.x, segment.y];
80         flattenCubic(current, [segment.cp1x, segment.cp1y], [segment.cp2x, segment.cp2y], end, tolerance, output);
81         current = end;
82         break;
83       }
84       case 'arc': {
85         const arcStart = arcPoint(segment, segment.startAngle);
86         if (!samePoint(current, arcStart)) output.add(line(arcStart));
87         flattenArc(segment, tolerance, output);
88         current = arcPoint(segment, segment.startAngle + segment.sweep);
89         break;
90       }
91     }
92   }
93   return output.finish();
94 }
95 
96 /**
97  * Returns independent geometry with each drawable segment split into the requested number of
98  * pieces.
99  *
100  * @param path - Source geometry; it is not modified. See {@link PathGeometry}.
101  * @param options - Subdivision length and work limits. See {@link SubdividePathOptions}.
102  * @returns Independent geometry with segments split according to the requested limits. See
103  * {@link PathGeometry} .
104  *
105  * @see {@link PathGeometry}
106  * @see {@link SubdividePathOptions}
107  */
108 export function subdividePath(path: PathGeometry, options: Readonly<SubdividePathOptions>): PathGeometry {
109   const divisions = requiredInteger(options.divisions, 'divisions');
110   const output = collector(options.maxSegments);
111   let current: Point = [0, 0];
112   let start: Point = [0, 0];
113   for (const segment of path) {
114     switch (segment.type) {
115       case 'move': current = start = [segment.x, segment.y]; output.add(segment); break;
116       case 'line': {
117         const end: Point = [segment.x, segment.y];
118         subdivideLine(current, end, divisions, output);
119         current = end;
120         break;
121       }
122       case 'quadratic': {
123         const end: Point = [segment.x, segment.y];
124         subdivideQuadratic(current, [segment.cpx, segment.cpy], end, divisions, output);
125         current = end;
126         break;
127       }
128       case 'cubic': {
129         const end: Point = [segment.x, segment.y];
130         subdivideCubic(current, [segment.cp1x, segment.cp1y], [segment.cp2x, segment.cp2y], end, divisions, output);
131         current = end;
132         break;
133       }
134       case 'arc': {
135         const arcStart = arcPoint(segment, segment.startAngle);
136         if (!samePoint(current, arcStart)) output.add(line(arcStart));
137         for (let i = 0; i < divisions; i++) {
138           output.add({ ...segment, startAngle: normalizeAngle(segment.startAngle + segment.sweep * i / divisions), sweep: segment.sweep / divisions });
139         }
140         current = arcPoint(segment, segment.startAngle + segment.sweep);
141         break;
142       }
143       case 'close':
144         if (!samePoint(current, start)) subdivideLine(current, start, divisions, output);
145         current = start;
146         output.add(segment);
147         break;
148     }
149   }
150   return output.finish();
151 }
152 
153 /**
154  * Returns independent polyline geometry with redundant points removed within the requested
155  * tolerance.
156  *
157  * @param path - Source geometry; it is not modified. See {@link PathGeometry}.
158  * @param options - Simplification tolerance and work limits. See {@link SimplifyPathOptions}.
159  * @returns Independent geometry with redundant line vertices removed. See {@link PathGeometry}.
160  *
161  * @see {@link PathGeometry}
162  * @see {@link SimplifyPathOptions}
163  */
164 export function simplifyPath(path: PathGeometry, options: Readonly<SimplifyPathOptions>): PathGeometry {
165   const tolerance = requiredPositive(options.tolerance, 'tolerance');
166   const output = collector(options.maxSegments);
167   let current: Point | undefined;
168   let start: Point | undefined;
169   let run: Point[] = [];
170   const flushOpen = () => {
171     if (!run.length) return;
172     for (const point of simplifyOpen(run, tolerance).slice(1)) output.add(line(point));
173     run = current ? [current] : [];
174   };
175   for (const segment of path) {
176     switch (segment.type) {
177       case 'move':
178         flushOpen();
179         current = start = [segment.x, segment.y];
180         run = [current];
181         output.add(segment);
182         break;
183       case 'line':
184         current = [segment.x, segment.y];
185         run.push(current);
186         break;
187       case 'close': {
188         if (!current || !start) throw new TypeError('simplifyPath requires a move segment before close.');
189         const simplified = simplifyClosed(withoutClosingDuplicate(run), tolerance);
190         for (const point of simplified.slice(1)) output.add(line(point));
191         output.add(segment);
192         current = start;
193         run = [current];
194         break;
195       }
196       default: throw new TypeError('simplifyPath requires line-only geometry.');
197     }
198   }
199   flushOpen();
200   return output.finish();
201 }
202 
203 function collector(maxSegments: number | undefined) {
204   const limit = maxSegments === undefined ? DEFAULT_MAX_SEGMENTS : requiredInteger(maxSegments, 'maxSegments');
205   const segments: PathSegment[] = [];
206   return {
207     add(segment: PathSegment) {
208       if (segments.length >= limit) throw new RangeError('Path utility output exceeds maxSegments.');
209       segments.push(segment);
210     },
211     finish() {
212       const output = new PathGeometry();
213       if (segments.length) output.spliceSegments(0, 0, segments);
214       return output;
215     },
216   };
217 }
218 
219 function requiredPositive(value: number, name: string): number {
220   if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${name} must be a finite positive number.`);
221   return value;
222 }
223 
224 function requiredInteger(value: number, name: string): number {
225   if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`${name} must be a positive integer.`);
226   return value;
227 }
228 
229 function flattenQuadratic(a: Point, b: Point, c: Point, tolerance: number, output: ReturnType<typeof collector>): void {
230   const stack: Array<readonly [Point, Point, Point, number]> = [[a, b, c, 0]];
231   while (stack.length) {
232     const [p0, p1, p2, depth] = stack.pop()!;
233     if (pointSegmentDistance(p1, p0, p2) <= tolerance) { output.add(line(p2)); continue; }
234     if (depth >= 60) throw new RangeError('flattenPath cannot satisfy tolerance due to numerical nonprogress.');
235     const p01 = midpoint(p0, p1), p12 = midpoint(p1, p2), p = midpoint(p01, p12);
236     stack.push([p, p12, p2, depth + 1], [p0, p01, p, depth + 1]);
237   }
238 }
239 
240 function flattenCubic(a: Point, b: Point, c: Point, d: Point, tolerance: number, output: ReturnType<typeof collector>): void {
241   const stack: Array<readonly [Point, Point, Point, Point, number]> = [[a, b, c, d, 0]];
242   while (stack.length) {
243     const [p0, p1, p2, p3, depth] = stack.pop()!;
244     if (Math.max(pointSegmentDistance(p1, p0, p3), pointSegmentDistance(p2, p0, p3)) <= tolerance) { output.add(line(p3)); continue; }
245     if (depth >= 60) throw new RangeError('flattenPath cannot satisfy tolerance due to numerical nonprogress.');
246     const p01 = midpoint(p0, p1), p12 = midpoint(p1, p2), p23 = midpoint(p2, p3);
247     const p012 = midpoint(p01, p12), p123 = midpoint(p12, p23), p = midpoint(p012, p123);
248     stack.push([p, p123, p23, p3, depth + 1], [p0, p01, p012, p, depth + 1]);
249   }
250 }
251 
252 function flattenArc(arc: ArcSegment, tolerance: number, output: ReturnType<typeof collector>): void {
253   const radius = Math.hypot(arc.ux, arc.uy, arc.vx, arc.vy);
254   const stack: Array<readonly [number, number, number]> = [[arc.startAngle, arc.startAngle + arc.sweep, 0]];
255   while (stack.length) {
256     const [from, to, depth] = stack.pop()!;
257     const middle = (from + to) / 2;
258     const a = arcPoint(arc, from), b = arcPoint(arc, to);
259     const sampled = Math.max(
260       pointSegmentDistance(arcPoint(arc, from + (to - from) / 4), a, b),
261       pointSegmentDistance(arcPoint(arc, middle), a, b),
262       pointSegmentDistance(arcPoint(arc, to - (to - from) / 4), a, b),
263     );
264     const bound = radius * (to - from) ** 2 / 8;
265     if (sampled <= tolerance && bound <= tolerance) { output.add(line(b)); continue; }
266     if (depth >= 60 || middle === from || middle === to) throw new RangeError('flattenPath cannot satisfy tolerance due to numerical nonprogress.');
267     stack.push([middle, to, depth + 1], [from, middle, depth + 1]);
268   }
269 }
270 
271 function subdivideLine(a: Point, b: Point, divisions: number, output: ReturnType<typeof collector>) {
272   for (let i = 1; i <= divisions; i++) output.add(line(interpolate(a, b, i / divisions)));
273 }
274 
275 function subdivideQuadratic(a: Point, b: Point, c: Point, divisions: number, output: ReturnType<typeof collector>) {
276   let rest: readonly [Point, Point, Point] = [a, b, c];
277   for (let i = divisions; i > 1; i--) {
278     const [left, right] = splitQuadratic(...rest, 1 / i);
279     output.add({ type: 'quadratic', cpx: left[1][0], cpy: left[1][1], x: left[2][0], y: left[2][1] });
280     rest = right;
281   }
282   output.add({ type: 'quadratic', cpx: rest[1][0], cpy: rest[1][1], x: rest[2][0], y: rest[2][1] });
283 }
284 
285 function subdivideCubic(a: Point, b: Point, c: Point, d: Point, divisions: number, output: ReturnType<typeof collector>) {
286   let rest: readonly [Point, Point, Point, Point] = [a, b, c, d];
287   for (let i = divisions; i > 1; i--) {
288     const [left, right] = splitCubic(...rest, 1 / i);
289     output.add({ type: 'cubic', cp1x: left[1][0], cp1y: left[1][1], cp2x: left[2][0], cp2y: left[2][1], x: left[3][0], y: left[3][1] });
290     rest = right;
291   }
292   output.add({ type: 'cubic', cp1x: rest[1][0], cp1y: rest[1][1], cp2x: rest[2][0], cp2y: rest[2][1], x: rest[3][0], y: rest[3][1] });
293 }
294 
295 function simplifyOpen(points: readonly Point[], tolerance: number): Point[] {
296   if (points.length <= 2) return [...points];
297   const keep = new Uint8Array(points.length);
298   keep[0] = keep[points.length - 1] = 1;
299   const stack: Array<readonly [number, number]> = [[0, points.length - 1]];
300   while (stack.length) {
301     const [first, last] = stack.pop()!;
302     let index = -1, maximum = tolerance;
303     for (let i = first + 1; i < last; i++) {
304       const distance = pointSegmentDistance(points[i], points[first], points[last]);
305       if (distance > maximum) { maximum = distance; index = i; }
306     }
307     if (index !== -1) { keep[index] = 1; stack.push([first, index], [index, last]); }
308   }
309   const result: Point[] = [];
310   for (let i = 0; i < points.length; i++) if (keep[i]) result.push(points[i]);
311   return result;
312 }
313 
314 function simplifyClosed(points: readonly Point[], tolerance: number): Point[] {
315   if (points.length <= 3 || !hasThreeDistinct(points)) return [...points];
316   const [firstAnchor, secondAnchor] = closedAnchors(points);
317   const first = simplifyOpen(points.slice(0, firstAnchor + 1), tolerance);
318   const second = simplifyOpen(points.slice(firstAnchor, secondAnchor + 1), tolerance);
319   const third = simplifyOpen([...points.slice(secondAnchor), points[0]], tolerance);
320   return [...first, ...second.slice(1), ...third.slice(1, -1)];
321 }
322 
323 function closedAnchors(points: readonly Point[]): readonly [number, number] {
324   let farthest = 1, farthestDistance = -1;
325   for (let i = 1; i < points.length; i++) {
326     const distance = pointDistance(points[0], points[i]);
327     if (distance > farthestDistance) { farthestDistance = distance; farthest = i; }
328   }
329   let third = -1, thirdDistance = -1;
330   for (let i = 1; i < points.length; i++) {
331     if (i === farthest || samePoint(points[i], points[0]) || samePoint(points[i], points[farthest])) continue;
332     const distance = pointSegmentDistance(points[i], points[0], points[farthest]);
333     if (distance > thirdDistance) { thirdDistance = distance; third = i; }
334   }
335   const [firstAnchor, secondAnchor] = [farthest, third].sort((a, b) => a - b);
336   return [firstAnchor, secondAnchor];
337 }
338 
339 function withoutClosingDuplicate(points: readonly Point[]): Point[] {
340   return points.length > 1 && samePoint(points[0], points.at(-1)!) ? points.slice(0, -1) : [...points];
341 }
342 function hasThreeDistinct(points: readonly Point[]): boolean {
343   const first = points[0];
344   let second: Point | undefined;
345   for (let i = 1; i < points.length; i++) {
346     const point = points[i];
347     if (samePoint(point, first)) continue;
348     if (!second) second = point;
349     else if (!samePoint(point, second)) return true;
350   }
351   return false;
352 }
353 function splitQuadratic(a: Point, b: Point, c: Point, t: number): readonly [readonly [Point, Point, Point], readonly [Point, Point, Point]] {
354   const ab = interpolate(a, b, t), bc = interpolate(b, c, t), p = interpolate(ab, bc, t);
355   return [[a, ab, p], [p, bc, c]];
356 }
357 function splitCubic(a: Point, b: Point, c: Point, d: Point, t: number): readonly [readonly [Point, Point, Point, Point], readonly [Point, Point, Point, Point]] {
358   const ab = interpolate(a, b, t), bc = interpolate(b, c, t), cd = interpolate(c, d, t);
359   const abc = interpolate(ab, bc, t), bcd = interpolate(bc, cd, t), p = interpolate(abc, bcd, t);
360   return [[a, ab, abc, p], [p, bcd, cd, d]];
361 }
362 function line([x, y]: Point): PathSegment { return { type: 'line', x, y }; }
363 function midpoint(a: Point, b: Point): Point { return interpolate(a, b, 0.5); }
364 function interpolate(a: Point, b: Point, t: number): Point { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; }
365 function pointDistance(a: Point, b: Point): number { return Math.hypot(difference(a[0], b[0]), difference(a[1], b[1])); }
366 function samePoint(a: Point, b: Point): boolean { return a[0] === b[0] && a[1] === b[1]; }
367 function pointSegmentDistance(point: Point, a: Point, b: Point): number {
368   const x = difference(b[0], a[0]), y = difference(b[1], a[1]);
369   const px = difference(point[0], a[0]), py = difference(point[1], a[1]);
370   const scale = Math.max(Math.abs(x), Math.abs(y), Math.abs(px), Math.abs(py));
371   if (scale === 0) return Math.hypot(px, py);
372   const scaledX = x / scale, scaledY = y / scale;
373   const scaledPX = px / scale, scaledPY = py / scale;
374   const length = scaledX * scaledX + scaledY * scaledY;
375   if (length === 0) return Math.hypot(px, py);
376   const t = Math.max(0, Math.min(1, (scaledPX * scaledX + scaledPY * scaledY) / length));
377   return scale * Math.hypot(scaledPX - scaledX * t, scaledPY - scaledY * t);
378 }
379 function difference(value: number, origin: number): number {
380   const result = value - origin;
381   if (!Number.isFinite(result)) throw new RangeError('Path utility cannot compare coordinates with an unrepresentable difference.');
382   return result;
383 }
384 function normalizeAngle(angle: number): number { const normalized = angle % TAU; return Object.is(normalized, -0) ? 0 : normalized; }
385 

Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.