@flighthq/path 0.3.0-next.906.07cea63 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract.d.ts +4 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +4 -0
- package/dist/contract.js.map +1 -1
- package/dist/explainPathMorphCreation.d.ts +4 -0
- package/dist/explainPathMorphCreation.d.ts.map +1 -0
- package/dist/explainPathMorphCreation.js +24 -0
- package/dist/explainPathMorphCreation.js.map +1 -0
- package/dist/explainStrokePathTessellation.d.ts +4 -0
- package/dist/explainStrokePathTessellation.d.ts.map +1 -0
- package/dist/explainStrokePathTessellation.js +26 -0
- package/dist/explainStrokePathTessellation.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/pathMorph.d.ts +5 -0
- package/dist/pathMorph.d.ts.map +1 -0
- package/dist/pathMorph.js +35 -0
- package/dist/pathMorph.js.map +1 -0
- package/dist/pathMorphGeometry.d.ts +14 -0
- package/dist/pathMorphGeometry.d.ts.map +1 -0
- package/dist/pathMorphGeometry.js +347 -0
- package/dist/pathMorphGeometry.js.map +1 -0
- package/dist/strokePath.d.ts.map +1 -1
- package/dist/strokePath.js +30 -451
- package/dist/strokePath.js.map +1 -1
- package/dist/strokePathGeometry.d.ts +23 -0
- package/dist/strokePathGeometry.d.ts.map +1 -0
- package/dist/strokePathGeometry.js +422 -0
- package/dist/strokePathGeometry.js.map +1 -0
- package/dist/tessellateStrokePath.d.ts +3 -0
- package/dist/tessellateStrokePath.d.ts.map +1 -0
- package/dist/tessellateStrokePath.js +66 -0
- package/dist/tessellateStrokePath.js.map +1 -0
- package/package.json +4 -2
- package/src/explainPathMorphCreation.test.ts +81 -0
- package/src/explainStrokePathTessellation.test.ts +39 -0
- package/src/pathMorph.test.ts +290 -0
- package/src/pathMorphGeometry.test.ts +18 -0
- package/src/strokePathGeometry.test.ts +27 -0
- package/src/tessellateStrokePath.test.ts +91 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { explainStrokePathTessellation } from './explainStrokePathTessellation';
|
|
2
|
+
import { appendPathClose, appendPathLineTo, appendPathMoveTo, appendPathRectangle, createPath } from './path';
|
|
3
|
+
|
|
4
|
+
describe('explainStrokePathTessellation', () => {
|
|
5
|
+
it('reports a supported simple closed ring', () => {
|
|
6
|
+
const path = createPath();
|
|
7
|
+
appendPathRectangle(path, 0, 0, 100, 50);
|
|
8
|
+
expect(explainStrokePathTessellation(path, { width: 10 })).toEqual({
|
|
9
|
+
reason: 'ok',
|
|
10
|
+
subpath: null,
|
|
11
|
+
supported: true,
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('identifies the self-intersecting subpath behind a null mesh', () => {
|
|
16
|
+
const path = createPath();
|
|
17
|
+
appendPathMoveTo(path, 0, 0);
|
|
18
|
+
appendPathLineTo(path, 40, 40);
|
|
19
|
+
appendPathLineTo(path, 0, 40);
|
|
20
|
+
appendPathLineTo(path, 40, 0);
|
|
21
|
+
appendPathClose(path);
|
|
22
|
+
expect(explainStrokePathTessellation(path, { width: 8 })).toEqual({
|
|
23
|
+
reason: 'self-intersecting-centerline',
|
|
24
|
+
subpath: 0,
|
|
25
|
+
supported: false,
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('reports invalid style input without throwing', () => {
|
|
30
|
+
const path = createPath();
|
|
31
|
+
appendPathMoveTo(path, 0, 0);
|
|
32
|
+
appendPathLineTo(path, 10, 0);
|
|
33
|
+
expect(explainStrokePathTessellation(path, { width: 0 })).toEqual({
|
|
34
|
+
reason: 'invalid-style',
|
|
35
|
+
subpath: null,
|
|
36
|
+
supported: false,
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { PathCommand } from '@flighthq/types/contract';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
appendPathClose,
|
|
5
|
+
appendPathCircle,
|
|
6
|
+
appendPathCubicCurveTo,
|
|
7
|
+
appendPathCurveTo,
|
|
8
|
+
appendPathLineTo,
|
|
9
|
+
appendPathMoveTo,
|
|
10
|
+
appendPathRectangle,
|
|
11
|
+
createPath,
|
|
12
|
+
} from './path';
|
|
13
|
+
import { createPathMorph, samplePathMorph } from './pathMorph';
|
|
14
|
+
|
|
15
|
+
describe('createPathMorph', () => {
|
|
16
|
+
it('normalizes different line and quadratic verbs to one cubic command stream', () => {
|
|
17
|
+
const start = createPath();
|
|
18
|
+
appendPathMoveTo(start, 0, 0);
|
|
19
|
+
appendPathLineTo(start, 6, 0);
|
|
20
|
+
const end = createPath();
|
|
21
|
+
appendPathMoveTo(end, 2, 2);
|
|
22
|
+
appendPathCurveTo(end, 5, 8, 8, 2);
|
|
23
|
+
|
|
24
|
+
const morph = createPathMorph(start, end)!;
|
|
25
|
+
|
|
26
|
+
expect(morph.commands).toStrictEqual([PathCommand.MOVE_TO, PathCommand.CUBIC_CURVE_TO]);
|
|
27
|
+
expect(morph.startData).toStrictEqual([0, 0, 2, 0, 4, 0, 6, 0]);
|
|
28
|
+
expect(morph.endData).toStrictEqual([2, 2, 4, 6, 6, 6, 8, 2]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('preserves authored cubic control points', () => {
|
|
32
|
+
const start = createPath();
|
|
33
|
+
appendPathMoveTo(start, 0, 0);
|
|
34
|
+
appendPathLineTo(start, 9, 0);
|
|
35
|
+
const end = createPath();
|
|
36
|
+
appendPathMoveTo(end, 1, 2);
|
|
37
|
+
appendPathCubicCurveTo(end, 3, 4, 6, 8, 10, 12);
|
|
38
|
+
|
|
39
|
+
const morph = createPathMorph(start, end)!;
|
|
40
|
+
|
|
41
|
+
expect(morph.endData).toStrictEqual([1, 2, 3, 4, 6, 8, 10, 12]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('subdivides the lower segment count without changing its endpoint geometry', () => {
|
|
45
|
+
const start = createPath();
|
|
46
|
+
appendPathMoveTo(start, 0, 0);
|
|
47
|
+
appendPathLineTo(start, 10, 0);
|
|
48
|
+
const end = createPath();
|
|
49
|
+
appendPathMoveTo(end, 0, 0);
|
|
50
|
+
appendPathLineTo(end, 5, 0);
|
|
51
|
+
appendPathLineTo(end, 10, 0);
|
|
52
|
+
|
|
53
|
+
const morph = createPathMorph(start, end)!;
|
|
54
|
+
|
|
55
|
+
expect(morph.commands).toStrictEqual([PathCommand.MOVE_TO, PathCommand.CUBIC_CURVE_TO, PathCommand.CUBIC_CURVE_TO]);
|
|
56
|
+
for (let i = 0; i < morph.startData.length; i++) {
|
|
57
|
+
expect(morph.startData[i]).toBeCloseTo(morph.endData[i]);
|
|
58
|
+
}
|
|
59
|
+
expect(morph.startData.slice(-2)).toStrictEqual([10, 0]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('aligns equivalent closed contours authored from different starting vertices', () => {
|
|
63
|
+
const start = createPath();
|
|
64
|
+
appendPathRectangle(start, 0, 0, 10, 10);
|
|
65
|
+
const end = createPath();
|
|
66
|
+
appendPathMoveTo(end, 10, 0);
|
|
67
|
+
appendPathLineTo(end, 10, 10);
|
|
68
|
+
appendPathLineTo(end, 0, 10);
|
|
69
|
+
appendPathLineTo(end, 0, 0);
|
|
70
|
+
appendPathClose(end);
|
|
71
|
+
|
|
72
|
+
const morph = createPathMorph(start, end)!;
|
|
73
|
+
|
|
74
|
+
expect(morph.startData).toEqual(morph.endData);
|
|
75
|
+
expect(morph.commands[morph.commands.length - 1]).toBe(PathCommand.CLOSE);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('prepares a closed line rectangle against a closed cubic circle', () => {
|
|
79
|
+
const start = createPath();
|
|
80
|
+
appendPathRectangle(start, -10, -10, 20, 20);
|
|
81
|
+
const end = createPath();
|
|
82
|
+
appendPathCircle(end, 0, 0, 10);
|
|
83
|
+
|
|
84
|
+
const morph = createPathMorph(start, end)!;
|
|
85
|
+
|
|
86
|
+
expect(morph.commands).toStrictEqual([
|
|
87
|
+
PathCommand.MOVE_TO,
|
|
88
|
+
PathCommand.CUBIC_CURVE_TO,
|
|
89
|
+
PathCommand.CUBIC_CURVE_TO,
|
|
90
|
+
PathCommand.CUBIC_CURVE_TO,
|
|
91
|
+
PathCommand.CUBIC_CURVE_TO,
|
|
92
|
+
PathCommand.CLOSE,
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('normalizes wide move and line commands without retaining their dummy coordinates', () => {
|
|
97
|
+
const start = createPath();
|
|
98
|
+
start.commands.push(PathCommand.WIDE_MOVE_TO, PathCommand.WIDE_LINE_TO);
|
|
99
|
+
start.data.push(99, 98, 1, 2, 97, 96, 7, 8);
|
|
100
|
+
const end = createPath();
|
|
101
|
+
appendPathMoveTo(end, 1, 2);
|
|
102
|
+
appendPathLineTo(end, 7, 8);
|
|
103
|
+
|
|
104
|
+
const morph = createPathMorph(start, end)!;
|
|
105
|
+
|
|
106
|
+
expect(morph.startData).toEqual(morph.endData);
|
|
107
|
+
expect(morph.startData).toStrictEqual([1, 2, 3, 4, 5, 6, 7, 8]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('expands a point contour into degenerate cubics when the other endpoint has segments', () => {
|
|
111
|
+
const start = createPath();
|
|
112
|
+
appendPathMoveTo(start, 3, 4);
|
|
113
|
+
const end = createPath();
|
|
114
|
+
appendPathMoveTo(end, 0, 0);
|
|
115
|
+
appendPathLineTo(end, 10, 0);
|
|
116
|
+
|
|
117
|
+
const morph = createPathMorph(start, end)!;
|
|
118
|
+
|
|
119
|
+
expect(morph.startData).toStrictEqual([3, 4, 3, 4, 3, 4, 3, 4]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('expands a closed point contour without inventing an orientation', () => {
|
|
123
|
+
const start = createPath();
|
|
124
|
+
appendPathMoveTo(start, 3, 4);
|
|
125
|
+
appendPathClose(start);
|
|
126
|
+
const end = createPath();
|
|
127
|
+
appendPathRectangle(end, 0, 0, 10, 10);
|
|
128
|
+
|
|
129
|
+
const morph = createPathMorph(start, end)!;
|
|
130
|
+
|
|
131
|
+
expect(morph.commands).toStrictEqual([
|
|
132
|
+
PathCommand.MOVE_TO,
|
|
133
|
+
PathCommand.CUBIC_CURVE_TO,
|
|
134
|
+
PathCommand.CUBIC_CURVE_TO,
|
|
135
|
+
PathCommand.CUBIC_CURVE_TO,
|
|
136
|
+
PathCommand.CUBIC_CURVE_TO,
|
|
137
|
+
PathCommand.CLOSE,
|
|
138
|
+
]);
|
|
139
|
+
expect(morph.startData.every((value, index) => value === (index % 2 === 0 ? 3 : 4))).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('does not mutate either endpoint', () => {
|
|
143
|
+
const start = createPath();
|
|
144
|
+
appendPathMoveTo(start, 0, 0);
|
|
145
|
+
appendPathLineTo(start, 10, 0);
|
|
146
|
+
const end = createPath();
|
|
147
|
+
appendPathMoveTo(end, 0, 0);
|
|
148
|
+
appendPathLineTo(end, 5, 5);
|
|
149
|
+
appendPathLineTo(end, 10, 0);
|
|
150
|
+
const startCommands = start.commands.slice();
|
|
151
|
+
const startData = start.data.slice();
|
|
152
|
+
const endCommands = end.commands.slice();
|
|
153
|
+
const endData = end.data.slice();
|
|
154
|
+
|
|
155
|
+
createPathMorph(start, end);
|
|
156
|
+
|
|
157
|
+
expect(start.commands).toStrictEqual(startCommands);
|
|
158
|
+
expect(start.data).toStrictEqual(startData);
|
|
159
|
+
expect(end.commands).toStrictEqual(endCommands);
|
|
160
|
+
expect(end.data).toStrictEqual(endData);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('returns null for paths with incompatible topology', () => {
|
|
164
|
+
const start = createPath('nonZero');
|
|
165
|
+
const end = createPath('evenOdd');
|
|
166
|
+
expect(createPathMorph(start, end)).toBeNull();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('normalizes a consistently reversed closed endpoint', () => {
|
|
170
|
+
const start = createPath();
|
|
171
|
+
appendPathRectangle(start, 0, 0, 10, 10);
|
|
172
|
+
const end = createPath();
|
|
173
|
+
appendPathMoveTo(end, 0, 0);
|
|
174
|
+
appendPathLineTo(end, 0, 10);
|
|
175
|
+
appendPathLineTo(end, 10, 10);
|
|
176
|
+
appendPathLineTo(end, 10, 0);
|
|
177
|
+
appendPathClose(end);
|
|
178
|
+
|
|
179
|
+
const morph = createPathMorph(start, end)!;
|
|
180
|
+
|
|
181
|
+
for (let i = 0; i < morph.startData.length; i++) {
|
|
182
|
+
expect(morph.startData[i]).toBeCloseTo(morph.endData[i]);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('normalizes opposing contours independently under even-odd winding', () => {
|
|
187
|
+
const start = createPath('evenOdd');
|
|
188
|
+
appendPathRectangle(start, 0, 0, 20, 20);
|
|
189
|
+
appendPathRectangle(start, 5, 5, 10, 10);
|
|
190
|
+
const end = createPath('evenOdd');
|
|
191
|
+
appendPathRectangle(end, 0, 0, 20, 20);
|
|
192
|
+
appendPathMoveTo(end, 5, 5);
|
|
193
|
+
appendPathLineTo(end, 5, 15);
|
|
194
|
+
appendPathLineTo(end, 15, 15);
|
|
195
|
+
appendPathLineTo(end, 15, 5);
|
|
196
|
+
appendPathClose(end);
|
|
197
|
+
|
|
198
|
+
const morph = createPathMorph(start, end)!;
|
|
199
|
+
|
|
200
|
+
for (let i = 0; i < morph.startData.length; i++) {
|
|
201
|
+
expect(morph.startData[i]).toBeCloseTo(morph.endData[i]);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('samplePathMorph', () => {
|
|
207
|
+
it('samples the prepared endpoints at zero and one', () => {
|
|
208
|
+
const start = createPath();
|
|
209
|
+
appendPathMoveTo(start, 0, 2);
|
|
210
|
+
appendPathLineTo(start, 6, 8);
|
|
211
|
+
const end = createPath();
|
|
212
|
+
appendPathMoveTo(end, 10, 12);
|
|
213
|
+
appendPathCurveTo(end, 14, 20, 18, 16);
|
|
214
|
+
const morph = createPathMorph(start, end)!;
|
|
215
|
+
const out = createPath();
|
|
216
|
+
|
|
217
|
+
samplePathMorph(out, morph, 0);
|
|
218
|
+
expect(out.data).toStrictEqual(morph.startData);
|
|
219
|
+
samplePathMorph(out, morph, 1);
|
|
220
|
+
expect(out.data).toStrictEqual(morph.endData);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('copies endpoint coordinates exactly instead of reconstructing them arithmetically', () => {
|
|
224
|
+
const start = createPath();
|
|
225
|
+
appendPathMoveTo(start, -255_815_156_688.713_68, 12);
|
|
226
|
+
const end = createPath();
|
|
227
|
+
appendPathMoveTo(end, 0.000_030_389_885_488_608_44, -24);
|
|
228
|
+
const morph = createPathMorph(start, end)!;
|
|
229
|
+
const out = createPath();
|
|
230
|
+
|
|
231
|
+
samplePathMorph(out, morph, 0);
|
|
232
|
+
expect(out.data).toStrictEqual(morph.startData);
|
|
233
|
+
samplePathMorph(out, morph, 1);
|
|
234
|
+
expect(out.data).toStrictEqual(morph.endData);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('writes the midpoint and winding into an existing path', () => {
|
|
238
|
+
const start = createPath('evenOdd');
|
|
239
|
+
appendPathMoveTo(start, 0, 0);
|
|
240
|
+
appendPathLineTo(start, 6, 0);
|
|
241
|
+
const end = createPath('evenOdd');
|
|
242
|
+
appendPathMoveTo(end, 2, 2);
|
|
243
|
+
appendPathCurveTo(end, 5, 8, 8, 2);
|
|
244
|
+
const out = createPath('nonZero');
|
|
245
|
+
|
|
246
|
+
samplePathMorph(out, createPathMorph(start, end)!, 0.5);
|
|
247
|
+
|
|
248
|
+
expect(out.commands).toStrictEqual([PathCommand.MOVE_TO, PathCommand.CUBIC_CURVE_TO]);
|
|
249
|
+
expect(out.data).toStrictEqual([1, 1, 3, 3, 5, 3, 7, 1]);
|
|
250
|
+
expect(out.winding).toBe('evenOdd');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('reuses the output command and data arrays across samples', () => {
|
|
254
|
+
const start = createPath();
|
|
255
|
+
appendPathMoveTo(start, 0, 0);
|
|
256
|
+
appendPathLineTo(start, 10, 0);
|
|
257
|
+
const end = createPath();
|
|
258
|
+
appendPathMoveTo(end, 10, 10);
|
|
259
|
+
appendPathLineTo(end, 20, 10);
|
|
260
|
+
const morph = createPathMorph(start, end)!;
|
|
261
|
+
const out = createPath();
|
|
262
|
+
const commands = out.commands;
|
|
263
|
+
const data = out.data;
|
|
264
|
+
|
|
265
|
+
samplePathMorph(out, morph, 0.25);
|
|
266
|
+
samplePathMorph(out, morph, 0.75);
|
|
267
|
+
|
|
268
|
+
expect(out.commands).toBe(commands);
|
|
269
|
+
expect(out.data).toBe(data);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('permits progress outside zero to one for easing overshoot', () => {
|
|
273
|
+
const start = createPath();
|
|
274
|
+
appendPathMoveTo(start, 0, 0);
|
|
275
|
+
const end = createPath();
|
|
276
|
+
appendPathMoveTo(end, 10, 20);
|
|
277
|
+
const out = createPath();
|
|
278
|
+
|
|
279
|
+
samplePathMorph(out, createPathMorph(start, end)!, 1.5);
|
|
280
|
+
|
|
281
|
+
expect(out.data).toStrictEqual([15, 30]);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('samples an empty path pair', () => {
|
|
285
|
+
const out = createPath();
|
|
286
|
+
samplePathMorph(out, createPathMorph(createPath(), createPath())!, 0.5);
|
|
287
|
+
expect(out.commands).toStrictEqual([]);
|
|
288
|
+
expect(out.data).toStrictEqual([]);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { appendPathLineTo, appendPathMoveTo, createPath } from './path';
|
|
2
|
+
import { buildPathMorph, PathMorphIssueNone } from './pathMorphGeometry';
|
|
3
|
+
|
|
4
|
+
describe('buildPathMorph', () => {
|
|
5
|
+
it('returns the prepared buffers with the internal success issue', () => {
|
|
6
|
+
const start = createPath();
|
|
7
|
+
appendPathMoveTo(start, 0, 0);
|
|
8
|
+
appendPathLineTo(start, 1, 0);
|
|
9
|
+
const end = createPath();
|
|
10
|
+
appendPathMoveTo(end, 0, 0);
|
|
11
|
+
appendPathLineTo(end, 2, 0);
|
|
12
|
+
|
|
13
|
+
const result = buildPathMorph(start, end);
|
|
14
|
+
|
|
15
|
+
expect(result.issue).toBe(PathMorphIssueNone);
|
|
16
|
+
expect(result.morph).not.toBeNull();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { appendPathLineTo, appendPathMoveTo, appendPathRectangle, createPath } from './path';
|
|
2
|
+
import {
|
|
3
|
+
buildStrokePathGeometry,
|
|
4
|
+
StrokePathTessellationIssueNone,
|
|
5
|
+
StrokePathTessellationIssueSelfIntersectingCenterline,
|
|
6
|
+
} from './strokePathGeometry';
|
|
7
|
+
|
|
8
|
+
describe('buildStrokePathGeometry', () => {
|
|
9
|
+
it('builds paired closed-ring sections and reports pathological centerlines', () => {
|
|
10
|
+
const rectangle = createPath();
|
|
11
|
+
appendPathRectangle(rectangle, 0, 0, 100, 50);
|
|
12
|
+
const valid = buildStrokePathGeometry(rectangle, { width: 10 }, 0.25);
|
|
13
|
+
expect(valid.issue).toBe(StrokePathTessellationIssueNone);
|
|
14
|
+
expect(valid.pieces).toHaveLength(1);
|
|
15
|
+
expect(valid.pieces[0].closed).toBe(true);
|
|
16
|
+
expect(valid.pieces[0].left.length).toBe(valid.pieces[0].right.length);
|
|
17
|
+
|
|
18
|
+
const crossing = createPath();
|
|
19
|
+
appendPathMoveTo(crossing, 0, 0);
|
|
20
|
+
appendPathLineTo(crossing, 40, 40);
|
|
21
|
+
appendPathLineTo(crossing, 0, 40);
|
|
22
|
+
appendPathLineTo(crossing, 40, 0);
|
|
23
|
+
const invalid = buildStrokePathGeometry(crossing, { width: 10 }, 0.25);
|
|
24
|
+
expect(invalid.issue).toBe(StrokePathTessellationIssueSelfIntersectingCenterline);
|
|
25
|
+
expect(invalid.issueSubpath).toBe(0);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { appendPathClose, appendPathLineTo, appendPathMoveTo, appendPathRectangle, createPath } from './path';
|
|
2
|
+
import { tessellateStrokePath } from './tessellateStrokePath';
|
|
3
|
+
|
|
4
|
+
describe('tessellateStrokePath', () => {
|
|
5
|
+
it('tessellates an open butt-capped line without overlapping triangles', () => {
|
|
6
|
+
const path = createPath();
|
|
7
|
+
appendPathMoveTo(path, 0, 0);
|
|
8
|
+
appendPathLineTo(path, 100, 0);
|
|
9
|
+
const mesh = tessellateStrokePath(path, { cap: 'butt', width: 10 });
|
|
10
|
+
expect(mesh).not.toBeNull();
|
|
11
|
+
expect(mesh!.indices.length).toBe(6);
|
|
12
|
+
expect(getMeshArea(mesh!)).toBeCloseTo(1000, 6);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('tessellates a closed rectangle as a hollow ring', () => {
|
|
16
|
+
const path = createPath();
|
|
17
|
+
appendPathRectangle(path, 0, 0, 100, 50);
|
|
18
|
+
const mesh = tessellateStrokePath(path, { join: 'miter', width: 10 });
|
|
19
|
+
expect(mesh).not.toBeNull();
|
|
20
|
+
// Outer 110×60 minus inner 90×40 = 3000. Filling both contours would be 10200 instead.
|
|
21
|
+
expect(getMeshArea(mesh!)).toBeCloseTo(3000, 6);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('normalizes an explicit CLOSE and a return-to-start centerline to the same ring', () => {
|
|
25
|
+
const closed = createPath();
|
|
26
|
+
appendPathRectangle(closed, 0, 0, 100, 50);
|
|
27
|
+
const returned = createPath();
|
|
28
|
+
appendPathMoveTo(returned, 0, 0);
|
|
29
|
+
appendPathLineTo(returned, 100, 0);
|
|
30
|
+
appendPathLineTo(returned, 100, 50);
|
|
31
|
+
appendPathLineTo(returned, 0, 50);
|
|
32
|
+
appendPathLineTo(returned, 0, 0);
|
|
33
|
+
expect(tessellateStrokePath(closed, { width: 10 })).toEqual(tessellateStrokePath(returned, { width: 10 }));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('uses the same round-join sampling tolerance as stroke outlines', () => {
|
|
37
|
+
const path = createPath();
|
|
38
|
+
appendPathRectangle(path, 0, 0, 100, 50);
|
|
39
|
+
const fine = tessellateStrokePath(path, { join: 'round', width: 10 }, 0.1);
|
|
40
|
+
const coarse = tessellateStrokePath(path, { join: 'round', width: 10 }, 5);
|
|
41
|
+
expect(fine).not.toBeNull();
|
|
42
|
+
expect(coarse).not.toBeNull();
|
|
43
|
+
expect(fine!.vertices.length).toBeGreaterThan(coarse!.vertices.length);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('turns a dashed closed path into independently capped open stroke pieces', () => {
|
|
47
|
+
const path = createPath();
|
|
48
|
+
appendPathRectangle(path, 0, 0, 100, 50);
|
|
49
|
+
const mesh = tessellateStrokePath(path, { cap: 'round', dash: [20, 10], width: 6 });
|
|
50
|
+
expect(mesh).not.toBeNull();
|
|
51
|
+
expect(mesh!.indices.length).toBeGreaterThan(6);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns null for a self-intersecting centerline', () => {
|
|
55
|
+
const path = bowTie();
|
|
56
|
+
expect(tessellateStrokePath(path, { width: 8 })).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns null for invalid stroke dimensions instead of emitting non-finite geometry', () => {
|
|
60
|
+
const path = createPath();
|
|
61
|
+
appendPathMoveTo(path, 0, 0);
|
|
62
|
+
appendPathLineTo(path, 10, 0);
|
|
63
|
+
expect(tessellateStrokePath(path, { width: 0 })).toBeNull();
|
|
64
|
+
expect(tessellateStrokePath(path, { width: Number.NaN })).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
function bowTie() {
|
|
69
|
+
const path = createPath();
|
|
70
|
+
appendPathMoveTo(path, 0, 0);
|
|
71
|
+
appendPathLineTo(path, 40, 40);
|
|
72
|
+
appendPathLineTo(path, 0, 40);
|
|
73
|
+
appendPathLineTo(path, 40, 0);
|
|
74
|
+
appendPathClose(path);
|
|
75
|
+
return path;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getMeshArea(mesh: { indices: readonly number[]; vertices: readonly number[] }): number {
|
|
79
|
+
let area = 0;
|
|
80
|
+
for (let i = 0; i < mesh.indices.length; i += 3) {
|
|
81
|
+
const a = mesh.indices[i] * 2;
|
|
82
|
+
const b = mesh.indices[i + 1] * 2;
|
|
83
|
+
const c = mesh.indices[i + 2] * 2;
|
|
84
|
+
area +=
|
|
85
|
+
Math.abs(
|
|
86
|
+
(mesh.vertices[b] - mesh.vertices[a]) * (mesh.vertices[c + 1] - mesh.vertices[a + 1]) -
|
|
87
|
+
(mesh.vertices[b + 1] - mesh.vertices[a + 1]) * (mesh.vertices[c] - mesh.vertices[a]),
|
|
88
|
+
) / 2;
|
|
89
|
+
}
|
|
90
|
+
return area;
|
|
91
|
+
}
|