Skip to content

packages/core/src/lib/geometry/path-bending.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 { ArcBendTransformOptions, BendTransformOptions } from './transform-bend.js';
2 import { PathGeometry, type PathSegment } from './path-geometry.js';
3 import { TAU } from './path-arc.js';
4 import { bendSpanBounds, bendSpanPoint, bendSpans, splitBendSpan, type BendBounds, type BendPoint, type BendSpan } from './path-bend-spans.js';
5 import { measureBendGuide, prepareBendGuide } from './path-bend-guide.js';
6 
7 export interface PathBendFrame {
8   readonly x: number; readonly y: number; readonly width: number; readonly height: number;
9 }
10 /**
11  * Circular guide geometry used when bending a path along an arc.
12  *
13  * @see {@link ArcBendTransformOptions}
14  */
15 export interface PathBendArc {
16   /** Horizontal coordinate of the center. See {@link PathBendArc}. */
17   readonly cx: number;
18   /** Vertical coordinate of the center. See {@link PathBendArc}. */
19   readonly cy: number;
20   /** Radius in the coordinate system of this geometry or effect. See {@link PathBendArc}. */
21   readonly radius: number;
22   /** Angle at which the arc or slice begins. See {@link PathBendArc}. */
23   readonly startAngle: number;
24   /** Angular extent of the arc. See {@link PathBendArc}. */
25   readonly sweep: number;
26 }
27 /**
28  * Placement of the source along the beginning, center, or end of a bending guide.
29  *
30  * @see {@link BendTransformOptions}
31  */
32 export type PathBendAlign = 'start' | 'center' | 'end';
33 /**
34  * The source baseline used for bending: an edge, center, or explicit coordinate.
35  *
36  * @see {@link BendTransformOptions}
37  */
38 export type PathBendBaseline = 'center' | 'top' | 'bottom' | number;
39 /**
40  * Whether bending preserves source length or stretches it to the guide.
41  *
42  * @see {@link BendTransformOptions}
43  */
44 export type PathBendFit = 'none' | 'stretch';
45 /**
46  * Policy for source geometry extending beyond a bending guide.
47  *
48  * @see {@link BendTransformOptions}
49  */
50 export type PathBendOverflow = 'error' | 'extend' | 'wrap';
51 /**
52  * Source bounds, guide alignment, baseline, fitting, overflow, and approximation limits for path bending.
53  *
54  * @see {@link PathBendAlign}
55  * @see {@link PathBendBaseline}
56  * @see {@link PathBendFit}
57  * @see {@link PathBendOverflow}
58  */
59 export interface PathBendOptions {
60   /**
61    * Source coordinate frame used to normalize the geometry before bending. See
62    * {@link PathBendFrame}.
63    */
64   readonly source: PathBendFrame;
65   /** Placement of the source length along the guide. See {@link PathBendAlign}. */
66   readonly align?: PathBendAlign;
67   /** Source baseline mapped onto the guide. See {@link PathBendBaseline}. */
68   readonly baseline?: PathBendBaseline;
69   /** Policy for fitting content within its available box. See {@link PathBendFit}. */
70   readonly fit?: PathBendFit;
71   /** Offset along the guide. See {@link PathBendOptions}. */
72   readonly offset?: number;
73   /** Offset perpendicular to the guide. See {@link PathBendOptions}. */
74   readonly normalOffset?: number;
75   /**
76    * Policy for geometry extending beyond the guide's available length. See
77    * {@link PathBendOverflow}.
78    */
79   readonly overflow?: PathBendOverflow;
80   /** Positive maximum geometric approximation error. See {@link PathBendOptions}. */
81   readonly tolerance: number;
82   /** Upper bound on the number of output path segments. See {@link PathBendOptions}. */
83   readonly maxSegments?: number;
84 }
85 export interface BendPathAlongArcOptions extends PathBendOptions { readonly arc: PathBendArc }
86 
87 // Work limits bound failed certification attempts, not just successful output.
88 const MAX_WORK = 2_000_000;
89 const MAX_DEPTH = 52;
90 interface Placement {
91   readonly x: number; readonly baseline: number; readonly normal: number;
92   readonly shift: number; readonly scale: number; readonly length: number;
93   readonly tolerance: number; readonly maxSegments: number; readonly overflow: PathBendOverflow;
94   readonly width: number; readonly fit: PathBendFit; readonly anchor: number; readonly offset: number;
95 }
96 
97 /** Bends continuous geometry along an analytic circle; returns independent line geometry. */
98 export function bendPathAlongArc(source: PathGeometry, options: Readonly<BendPathAlongArcOptions>): PathGeometry {
99   if (!(source instanceof PathGeometry)) throw new TypeError('source must be a PathGeometry.');
100   object(options, 'options');
101   const input = options.arc;
102   object(input, 'arc');
103   const startAngle = finite(input.startAngle, 'arc.startAngle');
104   const arc: PathBendArc = {
105     cx: finite(input.cx, 'arc.cx'), cy: finite(input.cy, 'arc.cy'),
106     radius: positive(input.radius, 'arc.radius'), startAngle: startAngle % TAU,
107     sweep: finite(input.sweep, 'arc.sweep'),
108   };
109   if (!arc.sweep || Math.abs(arc.sweep) > TAU) throw new RangeError('arc.sweep must be nonzero and at most one revolution.');
110   const p = placement(options, positive(arc.radius * Math.abs(arc.sweep), 'guide length'));
111   if (p.overflow === 'wrap' && Math.abs(arc.sweep) !== TAU) throw new RangeError('wrap requires a closed guide.');
112   const direction = Math.sign(arc.sweep);
113   const distance = (x: number) => p.shift + p.scale * (x - p.x);
114   const displacement = (y: number) => y - p.baseline + p.normal;
115   const map = ([x, y]: BendPoint): BendPoint => {
116     const s = finite(distance(x), 'mapped guide distance'), d = finite(displacement(y), 'normal displacement');
117     if (p.overflow === 'error' && (s < 0 || s > p.length)) throw new RangeError('Source geometry extends beyond the guide.');
118     const onGuide = p.overflow === 'extend' ? Math.max(0, Math.min(p.length, s)) : s;
119     const theta = arc.startAngle + direction * ((onGuide / arc.radius) % TAU);
120     const cos = Math.cos(theta), sin = Math.sin(theta);
121     const radial = arc.radius - direction * d, extension = s - onGuide;
122     // Division/modulo can lose the phase of enormous unwrapped angles. Its
123     // output effect scales with the displaced radius (not just the guide radius).
124     checkPrecision(Math.max(Math.abs(startAngle), Math.abs(onGuide / arc.radius)) * Math.max(Math.abs(radial), Math.abs(extension)), p.tolerance);
125     return [
126       finite(arc.cx + radial * cos - direction * extension * sin, 'bent x'),
127       finite(arc.cy + radial * sin + direction * extension * cos, 'bent y'),
128     ];
129   };
130   const bound = (b: BendBounds): number => {
131     const low = distance(b.xmin), high = distance(b.xmax);
132     if (p.overflow === 'error' && (low < 0 || high > p.length)) return Infinity;
133     const d = Math.max(Math.abs(displacement(b.ymin)), Math.abs(displacement(b.ymax)));
134     const dx = p.scale * b.dx, ddx = p.scale * b.ddx;
135     if (p.overflow === 'extend') {
136       if (high <= 0 || low >= p.length) return Math.hypot(ddx, b.ddy) / 8;
137       // The normal is continuous at extension boundaries, but the derivative
138       // may jump. A Lipschitz bound remains valid across the boundary.
139       if (low < 0 || high > p.length) return (b.dy + (1 + d / arc.radius) * dx) / 2;
140     }
141     const theta1 = dx / arc.radius, theta2 = ddx / arc.radius;
142     const radial = Math.max(Math.abs(arc.radius - direction * displacement(b.ymin)), Math.abs(arc.radius - direction * displacement(b.ymax)));
143     // For F = center + rho * unit(theta), ||F''|| <= |rho''|
144     // + 2|rho'||theta'| + |rho|(|theta'|² + |theta''|).
145     // Linear interpolation on [0,1] has error at most sup ||F''|| / 8.
146     return (b.ddy + 2 * b.dy * theta1 + radial * (theta1 * theta1 + theta2)) / 8;
147   };
148   const scale = Math.max(Math.abs(arc.cx), Math.abs(arc.cy), arc.radius, p.length);
149   return approximate(source, p, map, bound, scale);
150 }
151 
152 /** Bends geometry by distance along one smooth guide contour. */
153 export function bendPathAlongPath(source: PathGeometry, guide: PathGeometry, options: Readonly<PathBendOptions>): PathGeometry {
154   if (!(source instanceof PathGeometry) || !(guide instanceof PathGeometry)) throw new TypeError('source and guide must be PathGeometry values.');
155   object(options, 'options');
156   const input = placement(options, 1); // Snapshot options once; guide measurement supplies length later.
157   const prepared = prepareBendGuide(guide);
158   if (input.overflow === 'wrap' && !prepared.closed) throw new RangeError('wrap requires an explicitly closed guide.');
159   let xmin = Infinity, xmax = -Infinity, displacement = 0;
160   const normalDistance = (y: number) => y - input.baseline + input.normal;
161   for (const item of bendSpans(source)) {
162     if (item.type === 'close') continue;
163     const b = item.type === 'move' ? { xmin: item.point[0], xmax: item.point[0], ymin: item.point[1], ymax: item.point[1] } : bendSpanBounds(item.span);
164     xmin = Math.min(xmin, b.xmin); xmax = Math.max(xmax, b.xmax);
165     displacement = Math.max(displacement, Math.abs(normalDistance(b.ymin)), Math.abs(normalDistance(b.ymax)));
166   }
167   if (xmin === Infinity) xmin = xmax = input.x;
168   finite(displacement, 'normal displacement');
169   const atLength = (length: number): Placement => {
170     const scale = input.fit === 'stretch' ? positive(length / input.width, 'fit scale') : 1;
171     return { ...input, length, scale, shift: input.anchor * (length - scale * input.width) + input.offset };
172   };
173   const distance = (p: Placement, x: number) => p.shift + p.scale * (x - p.x);
174   const measured = measureBendGuide(prepared, input.tolerance, displacement, length => {
175     const p = atLength(length);
176     const sensitivity = input.fit === 'stretch' ? Math.max(Math.abs((xmin - input.x) / input.width), Math.abs((xmax - input.x) / input.width)) : input.anchor;
177     const laps = input.overflow === 'wrap' ? Math.ceil(Math.max(Math.abs(distance(p, xmin)), Math.abs(distance(p, xmax))) / length) : 0;
178     return finite(2 + sensitivity + laps, 'guide distance sensitivity');
179   });
180   const p = atLength(measured.length), upper = atLength(measured.length + measured.uncertainty);
181   const wrap = p.overflow === 'wrap';
182   const rangeCertified = (x: number) => {
183     const d = distance(p, x), u = distance(upper, x);
184     const roundoff = Number.EPSILON * 64 * Math.max(Math.abs(d), Math.abs(u), p.length);
185     return Math.min(d, u) >= -roundoff && Math.min(p.length - d, upper.length - u) >= -roundoff;
186   };
187   const map = ([x, y]: BendPoint): BendPoint => {
188     if (p.overflow === 'error' && !rangeCertified(x)) throw new RangeError('Source geometry exceeds the guide or its range cannot be certified.');
189     const field = measured.field(distance(p, x), wrap), d = normalDistance(y);
190     return [finite(field.point[0] + d * field.normal[0], 'bent x'), finite(field.point[1] + d * field.normal[1], 'bent y')];
191   };
192   const bound = (b: BendBounds, span: BendSpan): number => {
193     if (p.overflow === 'error' && (!rangeCertified(b.xmin) || !rangeCertified(b.xmax))) return Infinity;
194     const s0 = distance(p, bendSpanPoint(span, 0)[0]), s1 = distance(p, bendSpanPoint(span, 1)[0]);
195     const d = Math.max(Math.abs(normalDistance(b.ymin)), Math.abs(normalDistance(b.ymax)));
196     return measured.chordError(distance(p, b.xmin), distance(p, b.xmax), s0, s1, d, p.scale * b.dx, p.scale * b.ddx, b.dy, b.ddy, wrap);
197   };
198   return approximate(source, p, map, bound, Math.max(prepared.scale, p.length));
199 }
200 
201 function approximate(source: PathGeometry, p: Placement, map: (point: BendPoint) => BendPoint, bound: (bounds: BendBounds, span: BendSpan) => number, scale: number): PathGeometry {
202   const output: PathSegment[] = [];
203   let work = 0;
204   const add = (segment: PathSegment) => {
205     if (output.length >= p.maxSegments) throw new RangeError('Path bending exceeds maxSegments.');
206     output.push(segment);
207   };
208   for (const item of bendSpans(source)) {
209     if (item.type === 'move') {
210       const [x, y] = map(item.point);
211       checkPrecision(Math.max(scale, Math.abs(x), Math.abs(y), ...item.point.map(Math.abs)), p.tolerance);
212       add({ type: 'move', x, y });
213     } else if (item.type === 'close') {
214       add({ type: 'close' });
215     } else {
216       const stack: Array<readonly [BendSpan, number]> = [[item.span, 0]];
217       while (stack.length) {
218         if (++work > MAX_WORK) throw new RangeError('Path bending exceeded its work budget.');
219         const [span, depth] = stack.pop()!;
220         const b = bendSpanBounds(span);
221         const end = map(bendSpanPoint(span, 1));
222         map(bendSpanPoint(span, 0));
223         const roundoff = checkPrecision(Math.max(scale, Math.abs(b.xmin), Math.abs(b.xmax), Math.abs(b.ymin), Math.abs(b.ymax), ...end.map(Math.abs)), p.tolerance);
224         const error = bound(b, span);
225         if (Number.isFinite(error) && error + roundoff <= p.tolerance) {
226           add({ type: 'line', x: end[0], y: end[1] });
227           continue;
228         }
229         if (depth >= MAX_DEPTH) throw new RangeError('Path bending cannot certify tolerance or guide range due to numerical nonprogress.');
230         const [left, right] = splitBendSpan(span);
231         stack.push([right, depth + 1], [left, depth + 1]);
232       }
233     }
234   }
235   const result = new PathGeometry();
236   if (output.length) result.spliceSegments(0, 0, output);
237   return result;
238 }
239 
240 function placement(options: Readonly<PathBendOptions>, length: number): Placement {
241   const frame = options.source;
242   object(frame, 'source frame');
243   const x = finite(frame.x, 'source.x'), y = finite(frame.y, 'source.y');
244   const width = positive(frame.width, 'source.width'), height = finite(frame.height, 'source.height');
245   if (height < 0) throw new RangeError('source.height must be nonnegative.');
246   const align = option(options.align, ['start', 'center', 'end'], 'start', 'align');
247   const fit = option(options.fit, ['none', 'stretch'], 'none', 'fit');
248   const overflow = option(options.overflow, ['error', 'extend', 'wrap'], 'error', 'overflow');
249   const rawBaseline = options.baseline;
250   const inputBaseline = rawBaseline === undefined ? 'center' : rawBaseline;
251   const baseline = typeof inputBaseline === 'number' ? finite(inputBaseline, 'baseline')
252     : y + height * ({ top: 0, center: 0.5, bottom: 1 }[option(inputBaseline, ['top', 'center', 'bottom'], 'center', 'baseline')]);
253   const scale = fit === 'stretch' ? positive(length / width, 'fit scale') : 1;
254   const rawOffset = options.offset, rawNormal = options.normalOffset, rawMaxSegments = options.maxSegments;
255   const anchor = { start: 0, center: 0.5, end: 1 }[align], offset = finite(rawOffset === undefined ? 0 : rawOffset, 'offset');
256   const shift = finite(anchor * (length - scale * width) + offset, 'alignment');
257   const maxSegments = rawMaxSegments === undefined ? 1_000_000 : rawMaxSegments;
258   if (!Number.isSafeInteger(maxSegments) || maxSegments <= 0) throw new RangeError('maxSegments must be a positive safe integer.');
259   return {
260     x, width, fit, anchor, offset, baseline: finite(baseline, 'resolved baseline'), normal: finite(rawNormal === undefined ? 0 : rawNormal, 'normalOffset'),
261     shift, scale, length, tolerance: positive(options.tolerance, 'tolerance'), maxSegments, overflow,
262   };
263 }
264 function checkPrecision(scale: number, tolerance: number): number {
265   const roundoff = scale * Number.EPSILON * 64;
266   if (!Number.isFinite(roundoff) || roundoff >= tolerance / 4) throw new RangeError('Path bending cannot certify tolerance at this coordinate scale.');
267   return roundoff;
268 }
269 function object(value: unknown, name: string): asserts value is Record<string, unknown> {
270   if (value === null || typeof value !== 'object') throw new TypeError(`${name} must be an object.`);
271 }
272 function finite(value: number, name: string): number {
273   if (typeof value !== 'number') throw new TypeError(`${name} must be a number.`);
274   if (!Number.isFinite(value)) throw new RangeError(`${name} must be finite.`);
275   return value;
276 }
277 function positive(value: number, name: string): number {
278   finite(value, name);
279   if (value <= 0) throw new RangeError(`${name} must be positive.`);
280   return value;
281 }
282 function option<T extends string>(value: T | undefined, allowed: readonly T[], fallback: T, name: string): T {
283   const resolved = value === undefined ? fallback : value;
284   if (!allowed.includes(resolved)) throw new TypeError(`Invalid path bending ${name}.`);
285   return resolved;
286 }
287 

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