Skip to content

packages/core/src/lib/geometry/path-radial.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 { PathGeometry } from './path-geometry.js';
2 import { simplifyPath, type SimplifyPathOptions } from './path-utilities.js';
3 
4 type Point = readonly [number, number];
5 
6 /**
7  * Simplifies line-only paths with a radial-distance prepass followed by the
8  * default Douglas--Peucker implementation. Each stage receives half of the
9  * requested error budget, so their errors compose within `tolerance`.
10  *
11  * @param path - Source geometry; it is not modified. See {@link PathGeometry}.
12  * @param options - Simplification tolerance and work limits. See {@link SimplifyPathOptions}.
13  * @returns Independent geometry with redundant line vertices removed. See {@link PathGeometry}.
14  *
15  * @see {@link PathGeometry}
16  * @see {@link SimplifyPathOptions}
17  */
18 export function simplifyPathRadial(path: PathGeometry, options: Readonly<SimplifyPathOptions>): PathGeometry {
19   const tolerance = requiredPositive(options.tolerance, 'tolerance');
20   const stageTolerance = tolerance / 2;
21   // A two-stage split cannot represent half of the smallest subnormal number
22   // without exceeding the caller's error budget. Retain the exact default
23   // contract in that case instead of applying the radial prepass.
24   if (stageTolerance === 0) return simplifyPath(path, options);
25   const filtered = new PathGeometry();
26   let current: Point | undefined;
27   let start: Point | undefined;
28   let run: Point[] = [];
29   const flushOpen = () => {
30     if (!run.length) return;
31     appendOpen(filtered, radialFilterOpen(run, stageTolerance));
32     run = current ? [current] : [];
33   };
34   for (const segment of path) {
35     switch (segment.type) {
36       case 'move':
37         flushOpen();
38         current = start = [segment.x, segment.y];
39         run = [current];
40         filtered.moveTo(segment.x, segment.y);
41         break;
42       case 'line':
43         current = [segment.x, segment.y];
44         run.push(current);
45         break;
46       case 'close': {
47         if (!current || !start) throw new TypeError('simplifyPathRadial requires a move segment before close.');
48         const raw = withoutClosingDuplicate(run);
49         const radial = radialFilterClosed(raw, stageTolerance);
50         // Do not make a valid closed subpath degenerate. The raw path is an
51         // exact fallback, and the shared DP stage will enforce the same rule.
52         appendOpen(filtered, radial.length >= 3 && hasThreeDistinct(radial) ? radial : raw);
53         filtered.closePath();
54         current = start;
55         run = [current];
56         break;
57       }
58       default: throw new TypeError('simplifyPathRadial requires line-only geometry.');
59     }
60   }
61   flushOpen();
62   return simplifyPath(filtered, { tolerance: stageTolerance, maxSegments: options.maxSegments });
63 }
64 
65 function appendOpen(path: PathGeometry, points: readonly Point[]): void {
66   for (const [x, y] of points.slice(1)) path.lineTo(x, y);
67 }
68 
69 function radialFilterOpen(points: readonly Point[], tolerance: number): Point[] {
70   if (points.length <= 2) return [...points];
71   const result: Point[] = [points[0]];
72   let previous = points[0];
73   for (let i = 1; i < points.length - 1; i++) {
74     if (distance(points[i], previous) > tolerance) {
75       result.push(points[i]);
76       previous = points[i];
77     }
78   }
79   const last = points.at(-1)!;
80   if (!samePoint(previous, last)) result.push(last);
81   return result;
82 }
83 
84 function radialFilterClosed(points: readonly Point[], tolerance: number): Point[] {
85   if (points.length <= 3) return [...points];
86   const result: Point[] = [points[0]];
87   let previous = points[0];
88   for (let i = 1; i < points.length; i++) {
89     if (distance(points[i], previous) > tolerance) {
90       result.push(points[i]);
91       previous = points[i];
92     }
93   }
94   return result;
95 }
96 
97 function withoutClosingDuplicate(points: readonly Point[]): Point[] {
98   return points.length > 1 && samePoint(points[0], points.at(-1)!) ? points.slice(0, -1) : [...points];
99 }
100 function hasThreeDistinct(points: readonly Point[]): boolean {
101   let first: Point | undefined;
102   let second: Point | undefined;
103   for (const point of points) {
104     if (!first) { first = point; continue; }
105     if (samePoint(point, first)) continue;
106     if (!second) { second = point; continue; }
107     if (!samePoint(point, second)) return true;
108   }
109   return false;
110 }
111 function requiredPositive(value: number, name: string): number {
112   if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${name} must be a finite positive number.`);
113   return value;
114 }
115 function samePoint(a: Point, b: Point): boolean { return a[0] === b[0] && a[1] === b[1]; }
116 function distance(a: Point, b: Point): number { return Math.hypot(a[0] - b[0], a[1] - b[1]); }
117 

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