packages/core/src/lib/geometry/transform-bend.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
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 version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.