Skip to content

packages/core/src/lib/geometry/transform-bend.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 {
2   constant,
3   add,
4   sub,
5   scale,
6   mul,
7   trig,
8   absI,
9   type Pair,
10   type Jet,
11 } from './transform-math.js';
12 import { evaluatePolyline } from './transform-guide.js';
13 import { prepareBendGuide, measureBendGuide } from './path-bend-guide.js';
14 import {
15   bendPathAlongArc,
16   bendPathAlongPath,
17   type PathBendOptions,
18   type BendPathAlongArcOptions,
19 } from './path-bending.js';
20 import { PathGeometry } from './path-geometry.js';
21 import {
22   make,
23   coordinate,
24   positive,
25   mergePairs,
26   type PathTransform,
27 } from './path-transforms.js';
28 
29 /**
30  * Alignment, fitting, baseline, and overflow controls shared by reusable bend transforms.
31  *
32  * @see {@link PathBendTransformOptions}
33  */
34 export type BendTransformOptions = Omit<
35   PathBendOptions,
36   'tolerance' | 'maxSegments'
37 >;
38 /**
39  * Circular bending parameters for a reusable arc transform; accuracy is supplied when applying it.
40  *
41  * @see {@link arcBendTransform}
42  */
43 export type ArcBendTransformOptions = Omit<
44   BendPathAlongArcOptions,
45   'tolerance' | 'maxSegments'
46 >;
47 /**
48  * Guide path and bending parameters for a reusable path transform.
49  *
50  * @see {@link PathGeometry}
51  * @see {@link pathBendTransform}
52  */
53 export type PathBendTransformOptions = Omit<
54   PathBendOptions,
55   'tolerance' | 'maxSegments'
56 > & {
57   /** Path used as the bending guide. See {@link PathGeometry}. */
58   readonly guide: PathGeometry;
59 };
60 function placement(input: Omit<PathBendOptions, 'tolerance' | 'maxSegments'>) {
61   const frame = input.source;
62   const source = {
63     x: coordinate(frame?.x),
64     y: coordinate(frame?.y),
65     width: positive(frame?.width, 'source.width'),
66     height: coordinate(frame?.height),
67   };
68   if (source.height < 0)
69     throw new RangeError('source.height must be nonnegative.');
70   const align = input.align ?? 'start',
71     fit = input.fit ?? 'none',
72     overflow = input.overflow ?? 'error';
73   if (
74     !['start', 'center', 'end'].includes(align) ||
75     !['none', 'stretch'].includes(fit) ||
76     !['error', 'extend', 'wrap'].includes(overflow)
77   )
78     throw new TypeError('Invalid bend placement option.');
79   const raw = input.baseline ?? 'center';
80   if (typeof raw !== 'number' && !['top', 'center', 'bottom'].includes(raw))
81     throw new TypeError('Invalid bend baseline.');
82   const baseline =
83     typeof raw === 'number'
84       ? coordinate(raw)
85       : source.y + source.height * { top: 0, center: 0.5, bottom: 1 }[raw];
86   return {
87     source,
88     baseline,
89     fit,
90     overflow,
91     anchor: { start: 0, center: 0.5, end: 1 }[align],
92     offset: coordinate(input.offset ?? 0),
93     normal: coordinate(input.normalOffset ?? 0),
94   };
95 }
96 function distances(
97   p: ReturnType<typeof placement>,
98   length: number,
99   input: Pair,
100 ): Pair {
101   const factor = p.fit === 'stretch' ? length / p.source.width : 1;
102   return [
103     add(
104       scale(sub(input[0], constant(p.source.x)), factor),
105       constant(p.anchor * (length - factor * p.source.width) + p.offset),
106     ),
107     add(sub(input[1], constant(p.baseline)), constant(p.normal)),
108   ];
109 }
110 /**
111  * Creates a reusable mapping that bends source geometry around a circular arc.
112  *
113  * @param options - Source bounds and circular bend geometry. See {@link ArcBendTransformOptions}.
114  * @returns A point mapping that bends the source along a circular arc. See {@link PathTransform}.
115  *
116  * @see {@link ArcBendTransformOptions}
117  * @see {@link PathTransform}
118  */
119 export function arcBendTransform(
120   options: ArcBendTransformOptions,
121 ): PathTransform {
122   const p = placement(options),
123     arc = { ...options.arc };
124   const saved = { ...options, source: { ...options.source }, arc };
125   // Keep the established arc validation and wrapping contract.
126   bendPathAlongArc(new PathGeometry(), {
127     ...options,
128     source: { ...options.source },
129     arc,
130     tolerance: 1,
131   });
132   const length = positive(arc.radius * Math.abs(arc.sweep), 'guide length'),
133     direction = Math.sign(arc.sweep);
134   const curve = (s: Jet, d: Jet, extension = constant(0)): Pair => {
135     const theta = add(
136       scale(s, direction / arc.radius),
137       constant(arc.startAngle % (2 * Math.PI)),
138     );
139     const radius = sub(constant(arc.radius), scale(d, direction));
140     return [
141       sub(
142         add(constant(arc.cx), mul(radius, trig(theta, true))),
143         scale(mul(extension, trig(theta)), direction),
144       ),
145       add(
146         add(constant(arc.cy), mul(radius, trig(theta))),
147         scale(mul(extension, trig(theta, true)), direction),
148       ),
149     ];
150   };
151   return make([
152     () => ({
153       apply: (path, quality) =>
154         bendPathAlongArc(path, {
155           ...saved,
156           tolerance: quality.tolerance,
157           maxSegments: quality.maxSegments,
158         }),
159       error: (input) => {
160         const [s, d] = distances(p, length, input);
161         return (
162           Number.EPSILON *
163           64 *
164           (1 + Math.abs(arc.startAngle) + absI(s.v) / arc.radius) *
165           (arc.radius + absI(d.v) + (p.overflow === 'extend' ? absI(s.v) : 0))
166         );
167       },
168       domain: (input, margin) => {
169         const [s] = distances(p, length, input);
170         return (
171           p.overflow !== 'error' ||
172           (s.v[0] >= -margin && s.v[1] <= length + margin)
173         );
174       },
175       evaluate: (input) => {
176         const [s, d] = distances(p, length, input);
177         if (p.overflow !== 'extend') return curve(s, d);
178         const parts: Pair[] = [];
179         if (s.v[0] < 0) {
180           const before = { ...s, v: [s.v[0], Math.min(0, s.v[1])] as const };
181           parts.push(curve(constant(0), d, before));
182         }
183         if (s.v[1] >= 0 && s.v[0] <= length)
184           parts.push(
185             curve(
186               { ...s, v: [Math.max(0, s.v[0]), Math.min(length, s.v[1])] },
187               d,
188             ),
189           );
190         if (s.v[1] > length) {
191           const after = {
192             ...s,
193             v: [Math.max(length, s.v[0]), s.v[1]] as const,
194           };
195           parts.push(curve(constant(length), d, sub(after, constant(length))));
196         }
197         return parts.length === 1 ? parts[0] : mergePairs(parts);
198       },
199     }),
200   ]);
201 }
202 /**
203  * Creates a reusable mapping that bends source geometry along a guide path.
204  *
205  * @param options - Source bounds, target guide, and alignment along that guide. See
206  * {@link PathBendTransformOptions} .
207  * @returns A point mapping that bends the source along the target path. See {@link PathTransform}.
208  *
209  * @see {@link PathBendTransformOptions}
210  * @see {@link PathTransform}
211  */
212 export function pathBendTransform(
213   options: PathBendTransformOptions,
214 ): PathTransform {
215   const p = placement(options);
216   if (!(options.guide instanceof PathGeometry))
217     throw new TypeError('guide must be PathGeometry.');
218   const guide = options.guide.clone();
219   const saved = { ...options, source: { ...options.source }, guide };
220   const prepared = prepareBendGuide(guide);
221   if (p.overflow === 'wrap' && !prepared.closed)
222     throw new RangeError('wrap requires a closed guide.');
223   return make([
224     (accuracy) => {
225       const measured = measureBendGuide(prepared, accuracy * 4, 1, () => 2);
226       const { length, knots } = measured;
227       const coordinates = knots.map((k) => k.distance),
228         points = knots.map((k) => k.point),
229         normals = knots.map((k) => k.normal);
230       const field = (s: Jet): readonly [Pair, Pair] => {
231         if (s.v[0] >= 0 && s.v[1] <= length)
232           return [
233             evaluatePolyline(coordinates, points, s),
234             evaluatePolyline(coordinates, normals, s),
235           ];
236         const positions = [...points],
237           directions = [...normals],
238           distances = [...coordinates];
239         if (s.v[0] < 0) {
240           const n = normals[0],
241             q = points[0],
242             d = s.v[0];
243           distances.unshift(d);
244           positions.unshift([q[0] + d * n[1], q[1] - d * n[0]]);
245           directions.unshift(n);
246         }
247         if (s.v[1] > length) {
248           const n = normals.at(-1)!,
249             q = points.at(-1)!,
250             d = s.v[1] - length;
251           distances.push(s.v[1]);
252           positions.push([q[0] + d * n[1], q[1] - d * n[0]]);
253           directions.push(n);
254         }
255         return [
256           evaluatePolyline(distances, positions, s),
257           evaluatePolyline(distances, directions, s),
258         ];
259       };
260       const sample = (s: Jet, d: Jet): Pair => {
261         const [q, n] = field(s);
262         return [add(q[0], mul(d, n[0])), add(q[1], mul(d, n[1]))];
263       };
264       return {
265         apply: (path, quality) =>
266           bendPathAlongPath(path, guide, {
267             ...saved,
268             tolerance: quality.tolerance,
269             maxSegments: quality.maxSegments,
270           }),
271         // The measured error bounds unit normal displacement and two units of
272         // length uncertainty. Enlarge for placement, displacement, and wrapped laps.
273         error: (input) => {
274           const [s, d] = distances(p, length, input);
275           return (
276             measured.error *
277             (1 + absI(d.v)) *
278             (2 +
279               absI(s.v) / length +
280               absI(
281                 scale(sub(input[0], constant(p.source.x)), 1 / p.source.width)
282                   .v,
283               ))
284           );
285         },
286         domain: (input, margin) => {
287           const [s] = distances(p, length, input);
288           return (
289             p.overflow !== 'error' ||
290             (s.v[0] >= -margin && s.v[1] <= length + margin)
291           );
292         },
293         evaluate: (input) => {
294           const [s, d] = distances(p, length, input);
295           if (p.overflow !== 'wrap') return sample(s, d);
296           const first = Math.floor(s.v[0] / length),
297             last = Math.floor(s.v[1] / length);
298           if (
299             !Number.isSafeInteger(first) ||
300             !Number.isSafeInteger(last) ||
301             last - first > 1024
302           )
303             throw new RangeError('Wrapped bend exceeds its lap budget.');
304           const parts: Pair[] = [];
305           for (let lap = first; lap <= last; lap++)
306             parts.push(
307               sample(
308                 {
309                   ...s,
310                   v: [
311                     Math.max(0, s.v[0] - lap * length),
312                     Math.min(length, s.v[1] - lap * length),
313                   ],
314                 },
315                 d,
316               ),
317             );
318           return parts.length === 1 ? parts[0] : mergePairs(parts);
319         },
320       };
321     },
322   ]);
323 }
324 

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