Skip to content

packages/core/src/lib/geometry/transform-guide.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 { MeshTransformOptions } from './transform-mesh.js';
2 import type { EnvelopeGuide, EnvelopeTransformOptions } from './path-transforms.js';
3 import { PathGeometry } from './path-geometry.js';
4 import {
5   bendSpans,
6   bendSpanPoint,
7   bendSpanBounds,
8   splitBendSpan,
9   type BendSpan,
10   type BendPoint,
11 } from './path-bend-spans.js';
12 import {
13   type Jet,
14   type Pair,
15   type Interval,
16   mulI,
17   addI,
18   interval,
19 } from './transform-math.js';
20 
21 /**
22  * A station mapping normalized guide position to normalized arc-length distance along its path.
23  *
24  * @see {@link PathGuideOptions}
25  */
26 export interface PathGuideStation {
27   /** Normalized station position. See {@link PathGuideStation}. */
28   readonly at: number;
29   /** Normalized arc-length distance from 0 to 1. See {@link PathGuideStation}. */
30   readonly distance: number;
31 }
32 /**
33  * Geometry and stations that define how a transformation samples a guide.
34  *
35  * @see {@link PathGeometry}
36  * @see {@link PathGuideStation}
37  * @see {@link PathGuide}
38  */
39 export interface PathGuideOptions {
40   /** Path geometry used by this operation. See {@link PathGeometry}. */
41   readonly path: PathGeometry;
42   /** Whether to reverse the direction of the mapping or guide. See {@link PathGuideOptions}. */
43   readonly reverse?: boolean;
44   /**
45    * Explicit correspondence between normalized stations and guide distance. See
46    * {@link PathGuideStation}.
47    */
48   readonly stations?: readonly PathGuideStation[];
49 }
50 /**
51  * A geometry path or a path with explicit guide-sampling configuration.
52  *
53  * @see {@link PathGeometry}
54  * @see {@link PathGuideOptions}
55  * @see {@link MeshTransformOptions}
56  * @see {@link EnvelopeTransformOptions}
57  * @see {@link EnvelopeGuide}
58  */
59 export type PathGuide = PathGeometry | PathGuideOptions;
60 export interface GuideSnapshot {
61   readonly spans: readonly BendSpan[];
62   readonly reverse: boolean;
63   readonly stations: readonly PathGuideStation[];
64 }
65 export interface SampledGuide {
66   readonly error: number;
67   readonly length: number;
68   evaluate(t: Jet): Pair;
69 }
70 export function snapshotGuide(input: PathGuide): GuideSnapshot {
71   const options = input instanceof PathGeometry ? { path: input } : input;
72   if (!(options?.path instanceof PathGeometry))
73     throw new TypeError('A guide requires PathGeometry.');
74   if (options.reverse !== undefined && typeof options.reverse !== 'boolean')
75     throw new TypeError('reverse must be boolean.');
76   const spans: BendSpan[] = [];
77   let moves = 0;
78   for (const item of bendSpans(options.path)) {
79     if (item.type === 'move') moves++;
80     else if (item.type === 'close')
81       throw new RangeError('A guide must be one open contour.');
82     else spans.push(item.span);
83   }
84   if (moves !== 1 || !spans.length)
85     throw new RangeError('A guide must be one nonempty open contour.');
86   const stations = (
87     options.stations ?? [
88       { at: 0, distance: 0 },
89       { at: 1, distance: 1 },
90     ]
91   ).map((s) => ({ at: s.at, distance: s.distance }));
92   if (
93     stations.length < 2 ||
94     stations[0].at !== 0 ||
95     stations[0].distance !== 0 ||
96     stations.at(-1)!.at !== 1 ||
97     stations.at(-1)!.distance !== 1 ||
98     stations.some(
99       (s, i) =>
100         !Number.isFinite(s.at) ||
101         !Number.isFinite(s.distance) ||
102         (i > 0 &&
103           (s.at <= stations[i - 1].at ||
104             s.distance <= stations[i - 1].distance)),
105     )
106   ) {
107     throw new RangeError(
108       'Guide stations must strictly increase from (0,0) to (1,1).',
109     );
110   }
111   return { spans, reverse: options.reverse ?? false, stations };
112 }
113 
114 interface Leaf {
115   span: BendSpan;
116   a: BendPoint;
117   b: BendPoint;
118   chord: number;
119   gap: number;
120   sag: number;
121 }
122 function leaf(span: BendSpan): Leaf {
123   const a = bendSpanPoint(span, 0),
124     b = bendSpanPoint(span, 1),
125     chord = Math.hypot(b[0] - a[0], b[1] - a[1]);
126   let upper: number;
127   if ('points' in span)
128     upper = span.points
129       .slice(1)
130       .reduce(
131         (n, p, i) =>
132           n + Math.hypot(p[0] - span.points[i][0], p[1] - span.points[i][1]),
133         0,
134       );
135   else {
136     const arc = span.arc,
137       mid = arc.startAngle + arc.sweep / 2;
138     const speed =
139       Math.abs(arc.sweep) *
140       Math.hypot(
141         -arc.ux * Math.sin(mid) + arc.vx * Math.cos(mid),
142         -arc.uy * Math.sin(mid) + arc.vy * Math.cos(mid),
143       );
144     const bounds = bendSpanBounds(span);
145     upper = speed + Math.hypot(bounds.ddx, bounds.ddy) / 4;
146   }
147   const gap = Math.max(0, upper - chord);
148   // Every point lies in the ellipse with these endpoints and total distance <= upper.
149   const sag = Math.sqrt(gap * (upper + chord)) / 2;
150   return { span, a, b, chord, gap, sag };
151 }
152 
153 export function sampleGuide(
154   snapshot: GuideSnapshot,
155   accuracy: number,
156 ): SampledGuide {
157   let leaves = snapshot.spans.map(leaf);
158   let gap: number, sag: number;
159   for (let depth = 0; ; depth++) {
160     gap = leaves.reduce((sum, p) => sum + p.gap, 0);
161     sag = leaves.reduce((max, p) => Math.max(max, p.sag), 0);
162     if (sag + 3 * gap <= accuracy) break;
163     if (depth >= 30 || leaves.length > 32768)
164       throw new RangeError('Guide approximation exceeded its work limit.');
165     const share = accuracy / (12 * leaves.length);
166     leaves = leaves.flatMap((p) =>
167       p.gap > share || p.sag > accuracy / 4
168         ? splitBendSpan(p.span).map(leaf)
169         : [p],
170     );
171   }
172   const length = leaves.reduce((sum, p) => sum + p.chord, 0);
173   if (!(length > 0) || !Number.isFinite(length))
174     throw new RangeError('Guide length must be finite and nonzero.');
175   let distance = 0;
176   const points: BendPoint[] = [leaves[0].a],
177     knots = [0];
178   for (const p of leaves) {
179     if (!p.chord) continue;
180     distance += p.chord;
181     points.push(p.b);
182     knots.push(distance / length);
183   }
184   knots[knots.length - 1] = 1;
185   if (snapshot.reverse) {
186     points.reverse();
187     knots.reverse();
188     for (let i = 0; i < knots.length; i++) knots[i] = 1 - knots[i];
189   }
190   const stations = snapshot.stations;
191   const remap = (d: number) => {
192     let i = 0;
193     while (i < stations.length - 2 && d > stations[i + 1].distance) i++;
194     const a = stations[i],
195       b = stations[i + 1];
196     return (
197       a.at + ((d - a.distance) * (b.at - a.at)) / (b.distance - a.distance)
198     );
199   };
200   // Include correspondence breaks, not just geometric polyline vertices.
201   const parameters = [...knots.map(remap), ...stations.map((s) => s.at)]
202     .sort((a, b) => a - b)
203     .filter((v, i, a) => !i || v !== a[i - 1]);
204   const values = parameters.map((t) => {
205     let j = 0;
206     while (j < stations.length - 2 && t > stations[j + 1].at) j++;
207     const a = stations[j],
208       b = stations[j + 1];
209     const d =
210       a.distance + ((t - a.at) * (b.distance - a.distance)) / (b.at - a.at);
211     const i = locate(knots, d),
212       f = (d - knots[i]) / (knots[i + 1] - knots[i]);
213     return [
214       points[i][0] + (points[i + 1][0] - points[i][0]) * f,
215       points[i][1] + (points[i + 1][1] - points[i][1]) * f,
216     ] as BendPoint;
217   });
218   return {
219     error: sag + 3 * gap,
220     length,
221     evaluate: (t) => evaluatePolyline(parameters, values, t),
222   };
223 }
224 function locate(knots: readonly number[], t: number): number {
225   let lo = 0,
226     hi = knots.length - 1;
227   while (hi - lo > 1) {
228     const mid = (lo + hi) >>> 1;
229     if (knots[mid] <= t) lo = mid;
230     else hi = mid;
231   }
232   return Math.min(lo, knots.length - 2);
233 }
234 export function evaluatePolyline(
235   knots: readonly number[],
236   points: readonly BendPoint[],
237   t: Jet,
238 ): Pair {
239   const first = locate(knots, t.v[0]),
240     last = locate(knots, t.v[1]);
241   return [0, 1].map((axis) => {
242     let low = Infinity,
243       high = -Infinity,
244       slopeLow = Infinity,
245       slopeHigh = -Infinity;
246     for (let i = first; i <= last; i++) {
247       const slope =
248         (points[i + 1][axis] - points[i][axis]) / (knots[i + 1] - knots[i]);
249       const a = i === first ? t.v[0] : knots[i],
250         b = i === last ? t.v[1] : knots[i + 1];
251       const va = points[i][axis] + (a - knots[i]) * slope,
252         vb = points[i][axis] + (b - knots[i]) * slope;
253       low = Math.min(low, va, vb);
254       high = Math.max(high, va, vb);
255       slopeLow = Math.min(slopeLow, slope);
256       slopeHigh = Math.max(slopeHigh, slope);
257     }
258     const slopes: Interval = [slopeLow, slopeHigh];
259     return {
260       v: [low, high] as Interval,
261       d: mulI(slopes, t.d),
262       dd: addI(
263         mulI(slopes, t.dd),
264         first === last || slopeLow === slopeHigh
265           ? interval(0)
266           : [-Infinity, Infinity],
267       ),
268     };
269   }) as unknown as Pair;
270 }
271 

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