packages/core/src/lib/geometry/transform-mesh.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 lerp,
7 type Pair,
8 } from './transform-math.js';
9 import {
10 snapshotGuide,
11 sampleGuide,
12 type PathGuide,
13 } from './transform-guide.js';
14 import {
15 make,
16 rectangle,
17 coordinate,
18 normalize,
19 mergePairs,
20 sourceDomain,
21 type PathRect,
22 type PathTransform,
23 } from './path-transforms.js';
24
25 /**
26 * Source rectangle, normalized cell boundaries, and horizontal/vertical path guides for a mesh
27 * transform.
28 *
29 * @see {@link PathRect}
30 * @see {@link PathGuide}
31 * @see {@link meshTransform}
32 */
33 export interface MeshTransformOptions {
34 /** Source rectangle whose normalized cells are mapped through the guide mesh. See {@link PathRect}. */
35 readonly source: PathRect;
36 /**
37 * Strictly increasing normalized column boundaries, beginning at 0 and ending at 1. See
38 * {@link MeshTransformOptions}.
39 */
40 readonly columns: readonly number[];
41 /**
42 * Strictly increasing normalized row boundaries, beginning at 0 and ending at 1. See
43 * {@link MeshTransformOptions}.
44 */
45 readonly rows: readonly number[];
46 /** Boundary rows; each contains columns.length - 1 left-to-right edges. */
47 readonly horizontal: readonly (readonly PathGuide[])[];
48 /** Boundary columns; each contains rows.length - 1 top-to-bottom edges. */
49 readonly vertical: readonly (readonly PathGuide[])[];
50 }
51 /**
52 * Creates a mapping through a mesh bounded by horizontal and vertical path guides.
53 *
54 * @param options - Source rectangle and the four boundary guides used to deform it. See
55 * {@link MeshTransformOptions} .
56 * @returns A point mapping suitable for transformPath. See {@link PathTransform}.
57 *
58 * @see {@link MeshTransformOptions}
59 * @see {@link PathTransform}
60 */
61 export function meshTransform(options: MeshTransformOptions): PathTransform {
62 const source = rectangle(options.source);
63 const axis = (values: readonly number[]) => {
64 const result = Array.from(values, (n) => coordinate(n));
65 if (
66 result.length < 2 ||
67 result[0] !== 0 ||
68 result.at(-1) !== 1 ||
69 result.some((n, i) => i > 0 && n <= result[i - 1])
70 )
71 throw new RangeError(
72 'Mesh axes must strictly increase from zero to one.',
73 );
74 return result;
75 };
76 const columns = axis(options.columns),
77 rows = axis(options.rows);
78 if (
79 options.horizontal.length !== rows.length ||
80 options.vertical.length !== columns.length ||
81 options.horizontal.some((row) => row.length !== columns.length - 1) ||
82 options.vertical.some((col) => col.length !== rows.length - 1)
83 )
84 throw new RangeError('Mesh edge dimensions must match its axes.');
85 if ((columns.length - 1) * (rows.length - 1) > 4096)
86 throw new RangeError('Mesh exceeds 4096 cells.');
87 const horizontal = options.horizontal.map((row) => row.map(snapshotGuide));
88 const vertical = options.vertical.map((col) => col.map(snapshotGuide));
89 const ends = (guide: ReturnType<typeof snapshotGuide>) => {
90 const first = guide.spans[0],
91 last = guide.spans.at(-1)!;
92 const a = bendSpanPoint(first, 0),
93 b = bendSpanPoint(last, 1);
94 return guide.reverse ? [b, a] : [a, b];
95 };
96 const equal = (a: readonly number[], b: readonly number[]) => {
97 if (a[0] !== b[0] || a[1] !== b[1])
98 throw new RangeError('Mesh edges must share exactly matching endpoints.');
99 };
100 for (let row = 0; row < rows.length - 1; row++)
101 for (let col = 0; col < columns.length - 1; col++) {
102 const t = ends(horizontal[row][col]),
103 b = ends(horizontal[row + 1][col]),
104 l = ends(vertical[col][row]),
105 r = ends(vertical[col + 1][row]);
106 equal(t[0], l[0]);
107 equal(t[1], r[0]);
108 equal(b[0], l[1]);
109 equal(b[1], r[1]);
110 }
111 return make([
112 (accuracy) => {
113 const h = horizontal.map((row) =>
114 row.map((g) => sampleGuide(g, accuracy)),
115 ),
116 v = vertical.map((col) => col.map((g) => sampleGuide(g, accuracy)));
117 const error =
118 2 *
119 Math.max(
120 ...h.flat().map((g) => g.error),
121 ...v.flat().map((g) => g.error),
122 );
123 return {
124 error: () => error,
125 domain: (p, margin) => sourceDomain(p, source, margin),
126 evaluate: (input) => {
127 const [u, w] = normalize(input, source),
128 cells: Pair[] = [];
129 for (let row = 0; row < rows.length - 1; row++) {
130 if (w.v[1] < rows[row] || w.v[0] > rows[row + 1]) continue;
131 for (let col = 0; col < columns.length - 1; col++) {
132 if (u.v[1] < columns[col] || u.v[0] > columns[col + 1]) continue;
133 const localU = scale(
134 sub(
135 {
136 ...u,
137 v: [
138 Math.max(u.v[0], columns[col]),
139 Math.min(u.v[1], columns[col + 1]),
140 ],
141 },
142 constant(columns[col]),
143 ),
144 1 / (columns[col + 1] - columns[col]),
145 );
146 const localV = scale(
147 sub(
148 {
149 ...w,
150 v: [
151 Math.max(w.v[0], rows[row]),
152 Math.min(w.v[1], rows[row + 1]),
153 ],
154 },
155 constant(rows[row]),
156 ),
157 1 / (rows[row + 1] - rows[row]),
158 );
159 const top = h[row][col].evaluate(localU),
160 bottom = h[row + 1][col].evaluate(localU),
161 left = v[col][row].evaluate(localV),
162 right = v[col + 1][row].evaluate(localV);
163 const a = ends(horizontal[row][col]),
164 b = ends(horizontal[row + 1][col]);
165 cells.push(
166 [0, 1].map((axis) =>
167 sub(
168 add(
169 lerp(top[axis], bottom[axis], localV),
170 lerp(left[axis], right[axis], localU),
171 ),
172 lerp(
173 lerp(constant(a[0][axis]), constant(a[1][axis]), localU),
174 lerp(constant(b[0][axis]), constant(b[1][axis]), localU),
175 localV,
176 ),
177 ),
178 ) as unknown as Pair,
179 );
180 }
181 }
182 if (!cells.length)
183 throw new RangeError('Point lies outside the mesh source.');
184 return cells.length === 1 ? cells[0] : mergePairs(cells);
185 },
186 };
187 },
188 ]);
189 }
190 import { bendSpanPoint } from './path-bend-spans.js';
191
Documentation version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.