@flighthq/shape 0.1.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.
@@ -0,0 +1,429 @@
1
+ import { createShape } from './shape';
2
+ import {
3
+ appendShapeArc,
4
+ appendShapeArcTo,
5
+ appendShapeBeginBitmapFill,
6
+ appendShapeBeginFill,
7
+ appendShapeBeginGradientFill,
8
+ appendShapeCircle,
9
+ appendShapeCubicCurveTo,
10
+ appendShapeCurveTo,
11
+ appendShapeDrawTriangles,
12
+ appendShapeEllipse,
13
+ appendShapeEndFill,
14
+ appendShapeLineBitmapStyle,
15
+ appendShapeLineGradientStyle,
16
+ appendShapeLineStyle,
17
+ appendShapeLineTo,
18
+ appendShapeMoveTo,
19
+ appendShapePath,
20
+ appendShapePolygon,
21
+ appendShapePolyline,
22
+ appendShapeRectangle,
23
+ appendShapeRoundRectangle,
24
+ appendShapeRoundRectangleVarying,
25
+ PathCommand,
26
+ } from './shapeCommands';
27
+
28
+ const fakeImageSource = { id: 1, height: 10, source: null, width: 10 } as never;
29
+ const fakeMatrix = { id: 2, a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 } as never;
30
+
31
+ describe('appendShapeArc', () => {
32
+ it('emits a moveTo followed by at least one cubicCurveTo', () => {
33
+ const shape = createShape();
34
+ appendShapeArc(shape, 50, 50, 25, 0, Math.PI);
35
+ const keys: string[] = [];
36
+ let i = 0;
37
+ while (i < shape.data.commands.length) {
38
+ const key = shape.data.commands[i] as string;
39
+ const argCount = shape.data.commands[i + 1] as number;
40
+ keys.push(key);
41
+ i += argCount + 2;
42
+ }
43
+ expect(keys[0]).toBe('moveTo');
44
+ expect(keys.slice(1).every((k) => k === 'cubicCurveTo')).toBe(true);
45
+ expect(keys.length).toBeGreaterThan(1);
46
+ });
47
+
48
+ it('arc start point is on the circle at startAngle', () => {
49
+ const shape = createShape();
50
+ appendShapeArc(shape, 0, 0, 10, 0, Math.PI / 2);
51
+ // moveTo args are at indices [2] and [3]
52
+ expect(shape.data.commands[2]).toBeCloseTo(10, 5);
53
+ expect(shape.data.commands[3]).toBeCloseTo(0, 5);
54
+ });
55
+
56
+ it('a full circle uses 4 cubic segments', () => {
57
+ const shape = createShape();
58
+ appendShapeArc(shape, 0, 0, 10, 0, Math.PI * 2);
59
+ let count = 0;
60
+ let i = 0;
61
+ while (i < shape.data.commands.length) {
62
+ const key = shape.data.commands[i] as string;
63
+ const argCount = shape.data.commands[i + 1] as number;
64
+ if (key === 'cubicCurveTo') count++;
65
+ i += argCount + 2;
66
+ }
67
+ expect(count).toBe(4);
68
+ });
69
+
70
+ it('anticlockwise arc sweeps in the negative direction', () => {
71
+ const shape = createShape();
72
+ appendShapeArc(shape, 0, 0, 10, 0, Math.PI / 2, true);
73
+ // 3/4-circle anticlockwise; should use 3 cubicCurveTo segments.
74
+ let count = 0;
75
+ let i = 0;
76
+ while (i < shape.data.commands.length) {
77
+ const key = shape.data.commands[i] as string;
78
+ const argCount = shape.data.commands[i + 1] as number;
79
+ if (key === 'cubicCurveTo') count++;
80
+ i += argCount + 2;
81
+ }
82
+ expect(count).toBe(3);
83
+ });
84
+ });
85
+
86
+ describe('appendShapeArcTo', () => {
87
+ it('emits a lineTo followed by cubicCurveTo commands for a right-angle corner', () => {
88
+ const shape = createShape();
89
+ // Start at (100, 0), corner at (100, 100), end direction toward (0, 100) with radius 20.
90
+ appendShapeMoveTo(shape, 100, 0);
91
+ appendShapeArcTo(shape, 100, 100, 0, 100, 20);
92
+ const keys: string[] = [];
93
+ let i = 0;
94
+ while (i < shape.data.commands.length) {
95
+ const key = shape.data.commands[i] as string;
96
+ const argCount = shape.data.commands[i + 1] as number;
97
+ keys.push(key);
98
+ i += argCount + 2;
99
+ }
100
+ // moveTo, lineTo (to tangent start), then cubicCurveTo arc segments.
101
+ expect(keys[0]).toBe('moveTo');
102
+ expect(keys[1]).toBe('lineTo');
103
+ expect(keys.slice(2).every((k) => k === 'cubicCurveTo')).toBe(true);
104
+ });
105
+
106
+ it('falls back to a lineTo when the tangent has zero length', () => {
107
+ const shape = createShape();
108
+ // pen at (100, 100), corner at (100, 100) — zero-length tangent.
109
+ appendShapeMoveTo(shape, 100, 100);
110
+ appendShapeArcTo(shape, 100, 100, 200, 100, 10);
111
+ const keys: string[] = [];
112
+ let i = 0;
113
+ while (i < shape.data.commands.length) {
114
+ const key = shape.data.commands[i] as string;
115
+ const argCount = shape.data.commands[i + 1] as number;
116
+ keys.push(key);
117
+ i += argCount + 2;
118
+ }
119
+ // Degenerate: should only have the original moveTo and one lineTo.
120
+ expect(keys).toContain('lineTo');
121
+ expect(keys.every((k) => k === 'moveTo' || k === 'lineTo')).toBe(true);
122
+ });
123
+ });
124
+
125
+ describe('appendShapeBeginBitmapFill', () => {
126
+ it('pushes a beginBitmapFill command with bitmap, matrix, repeat, smooth', () => {
127
+ const shape = createShape();
128
+ appendShapeBeginBitmapFill(shape, fakeImageSource, fakeMatrix, false, true);
129
+ expect(shape.data.commands).toEqual(['beginBitmapFill', 4, fakeImageSource, fakeMatrix, false, true]);
130
+ });
131
+
132
+ it('defaults matrix to null, repeat to true, smooth to false', () => {
133
+ const shape = createShape();
134
+ appendShapeBeginBitmapFill(shape, fakeImageSource);
135
+ expect(shape.data.commands).toEqual(['beginBitmapFill', 4, fakeImageSource, null, true, false]);
136
+ });
137
+ });
138
+
139
+ describe('appendShapeBeginFill', () => {
140
+ it('pushes a beginFill command with color and alpha', () => {
141
+ const shape = createShape();
142
+ appendShapeBeginFill(shape, 0xff0000, 0.5);
143
+ expect(shape.data.commands).toEqual(['beginFill', 2, 0xff0000, 0.5]);
144
+ });
145
+
146
+ it('defaults to color 0 and alpha 1', () => {
147
+ const shape = createShape();
148
+ appendShapeBeginFill(shape);
149
+ expect(shape.data.commands).toEqual(['beginFill', 2, 0, 1]);
150
+ });
151
+ });
152
+
153
+ describe('appendShapeBeginGradientFill', () => {
154
+ it('pushes a beginGradientFill command with all fields', () => {
155
+ const shape = createShape();
156
+ appendShapeBeginGradientFill(
157
+ shape,
158
+ 'linear',
159
+ [0xff0000, 0x0000ff],
160
+ [1, 1],
161
+ [0, 255],
162
+ fakeMatrix,
163
+ 'reflect',
164
+ 'linearRGB',
165
+ 0.5,
166
+ );
167
+ expect(shape.data.commands).toEqual([
168
+ 'beginGradientFill',
169
+ 8,
170
+ 'linear',
171
+ [0xff0000, 0x0000ff],
172
+ [1, 1],
173
+ [0, 255],
174
+ fakeMatrix,
175
+ 'reflect',
176
+ 'linearRGB',
177
+ 0.5,
178
+ ]);
179
+ });
180
+
181
+ it('defaults matrix null, spreadMethod pad, interpolationMethod rgb, focalPointRatio 0', () => {
182
+ const shape = createShape();
183
+ appendShapeBeginGradientFill(shape, 'radial', [0xffffff], [1], [0]);
184
+ expect(shape.data.commands).toEqual([
185
+ 'beginGradientFill',
186
+ 8,
187
+ 'radial',
188
+ [0xffffff],
189
+ [1],
190
+ [0],
191
+ null,
192
+ 'pad',
193
+ 'rgb',
194
+ 0,
195
+ ]);
196
+ });
197
+ });
198
+
199
+ describe('appendShapeCircle', () => {
200
+ it('pushes a drawCircle command with position and radius', () => {
201
+ const shape = createShape();
202
+ appendShapeCircle(shape, 50, 50, 25);
203
+ expect(shape.data.commands).toEqual(['drawCircle', 3, 50, 50, 25]);
204
+ });
205
+ });
206
+
207
+ describe('appendShapeCubicCurveTo', () => {
208
+ it('pushes a cubicCurveTo command with all control and anchor points', () => {
209
+ const shape = createShape();
210
+ appendShapeCubicCurveTo(shape, 10, 20, 30, 40, 50, 60);
211
+ expect(shape.data.commands).toEqual(['cubicCurveTo', 6, 10, 20, 30, 40, 50, 60]);
212
+ });
213
+ });
214
+
215
+ describe('appendShapeCurveTo', () => {
216
+ it('pushes a curveTo command with control and anchor points', () => {
217
+ const shape = createShape();
218
+ appendShapeCurveTo(shape, 10, 20, 30, 40);
219
+ expect(shape.data.commands).toEqual(['curveTo', 4, 10, 20, 30, 40]);
220
+ });
221
+ });
222
+
223
+ describe('appendShapeDrawTriangles', () => {
224
+ it('pushes a drawTriangles command with vertices, null indices, null uvtData, and culling none', () => {
225
+ const shape = createShape();
226
+ const verts = [0, 0, 100, 0, 50, 80];
227
+ appendShapeDrawTriangles(shape, verts);
228
+ expect(shape.data.commands).toEqual(['drawTriangles', 4, verts, null, null, 'none']);
229
+ });
230
+
231
+ it('pushes a drawTriangles command with indices and uvtData', () => {
232
+ const shape = createShape();
233
+ const verts = [0, 0, 100, 0, 50, 80];
234
+ const indices = [0, 1, 2];
235
+ const uvt = [0, 0, 1, 0, 0.5, 1];
236
+ appendShapeDrawTriangles(shape, verts, indices, uvt, 'positive');
237
+ expect(shape.data.commands).toEqual(['drawTriangles', 4, verts, indices, uvt, 'positive']);
238
+ });
239
+ });
240
+
241
+ describe('appendShapeEllipse', () => {
242
+ it('pushes a drawEllipse command with position and dimensions', () => {
243
+ const shape = createShape();
244
+ appendShapeEllipse(shape, 10, 20, 100, 50);
245
+ expect(shape.data.commands).toEqual(['drawEllipse', 4, 10, 20, 100, 50]);
246
+ });
247
+ });
248
+
249
+ describe('appendShapeEndFill', () => {
250
+ it('pushes an endFill command', () => {
251
+ const shape = createShape();
252
+ appendShapeEndFill(shape);
253
+ expect(shape.data.commands).toEqual(['endFill', 0]);
254
+ });
255
+ });
256
+
257
+ describe('appendShapeLineBitmapStyle', () => {
258
+ it('pushes a lineBitmapStyle command with bitmap, matrix, repeat, smooth', () => {
259
+ const shape = createShape();
260
+ appendShapeLineBitmapStyle(shape, fakeImageSource, fakeMatrix, false, true);
261
+ expect(shape.data.commands).toEqual(['lineBitmapStyle', 4, fakeImageSource, fakeMatrix, false, true]);
262
+ });
263
+
264
+ it('defaults matrix to null, repeat to true, smooth to false', () => {
265
+ const shape = createShape();
266
+ appendShapeLineBitmapStyle(shape, fakeImageSource);
267
+ expect(shape.data.commands).toEqual(['lineBitmapStyle', 4, fakeImageSource, null, true, false]);
268
+ });
269
+ });
270
+
271
+ describe('appendShapeLineGradientStyle', () => {
272
+ it('pushes a lineGradientStyle command with all fields', () => {
273
+ const shape = createShape();
274
+ appendShapeLineGradientStyle(shape, 'linear', [0xff0000], [1], [0]);
275
+ expect(shape.data.commands).toEqual([
276
+ 'lineGradientStyle',
277
+ 8,
278
+ 'linear',
279
+ [0xff0000],
280
+ [1],
281
+ [0],
282
+ null,
283
+ 'pad',
284
+ 'rgb',
285
+ 0,
286
+ ]);
287
+ });
288
+ });
289
+
290
+ describe('appendShapeLineStyle', () => {
291
+ it('pushes a lineStyle command with all parameters', () => {
292
+ const shape = createShape();
293
+ appendShapeLineStyle(shape, 2, 0x0000ff, 0.8, true, 'horizontal', 'round', 'bevel', 5);
294
+ expect(shape.data.commands).toEqual(['lineStyle', 8, 2, 0x0000ff, 0.8, true, 'horizontal', 'round', 'bevel', 5]);
295
+ });
296
+
297
+ it('defaults thickness 1, color 0, alpha 1, pixelHinting false, scaleMode normal, caps none, joints round, miterLimit 3', () => {
298
+ const shape = createShape();
299
+ appendShapeLineStyle(shape);
300
+ expect(shape.data.commands).toEqual(['lineStyle', 8, 1, 0, 1, false, 'normal', 'none', 'round', 3]);
301
+ });
302
+ });
303
+
304
+ describe('appendShapeLineTo', () => {
305
+ it('pushes a lineTo command with position', () => {
306
+ const shape = createShape();
307
+ appendShapeLineTo(shape, 100, 200);
308
+ expect(shape.data.commands).toEqual(['lineTo', 2, 100, 200]);
309
+ });
310
+ });
311
+
312
+ describe('appendShapeMoveTo', () => {
313
+ it('pushes a moveTo command with position', () => {
314
+ const shape = createShape();
315
+ appendShapeMoveTo(shape, 10, 20);
316
+ expect(shape.data.commands).toEqual(['moveTo', 2, 10, 20]);
317
+ });
318
+ });
319
+
320
+ describe('appendShapePath', () => {
321
+ it('pushes a drawPath command with commands, data, and winding', () => {
322
+ const shape = createShape();
323
+ const cmds = [PathCommand.MOVE_TO, PathCommand.LINE_TO];
324
+ appendShapePath(shape, cmds, [0, 0, 100, 100], 'nonZero');
325
+ expect(shape.data.commands).toEqual(['drawPath', 3, cmds, [0, 0, 100, 100], 'nonZero']);
326
+ });
327
+
328
+ it('defaults winding to evenOdd', () => {
329
+ const shape = createShape();
330
+ appendShapePath(shape, [], []);
331
+ expect(shape.data.commands).toEqual(['drawPath', 3, [], [], 'evenOdd']);
332
+ });
333
+ });
334
+
335
+ describe('appendShapePolygon', () => {
336
+ it('emits moveTo + lineTo commands and closes back to first vertex', () => {
337
+ const shape = createShape();
338
+ appendShapePolygon(shape, [0, 0, 100, 0, 50, 80]);
339
+ const keys: string[] = [];
340
+ let i = 0;
341
+ while (i < shape.data.commands.length) {
342
+ const key = shape.data.commands[i] as string;
343
+ const argCount = shape.data.commands[i + 1] as number;
344
+ keys.push(key);
345
+ i += argCount + 2;
346
+ }
347
+ // moveTo + 2 lineTo + 1 closing lineTo = 4 entries.
348
+ expect(keys).toEqual(['moveTo', 'lineTo', 'lineTo', 'lineTo']);
349
+ // The last lineTo should return to (0, 0).
350
+ const lastIdx = shape.data.commands.length - 4; // lineTo has argCount=2, so 4 elements from end
351
+ expect(shape.data.commands[lastIdx + 2]).toBe(0);
352
+ expect(shape.data.commands[lastIdx + 3]).toBe(0);
353
+ });
354
+
355
+ it('emits nothing for fewer than 2 points', () => {
356
+ const shape = createShape();
357
+ appendShapePolygon(shape, [0, 0]);
358
+ expect(shape.data.commands).toHaveLength(0);
359
+ });
360
+ });
361
+
362
+ describe('appendShapePolyline', () => {
363
+ it('emits moveTo + lineTo commands without closing', () => {
364
+ const shape = createShape();
365
+ appendShapePolyline(shape, [0, 0, 50, 50, 100, 0]);
366
+ const keys: string[] = [];
367
+ let i = 0;
368
+ while (i < shape.data.commands.length) {
369
+ const key = shape.data.commands[i] as string;
370
+ const argCount = shape.data.commands[i + 1] as number;
371
+ keys.push(key);
372
+ i += argCount + 2;
373
+ }
374
+ // moveTo + 2 lineTo; no closing lineTo.
375
+ expect(keys).toEqual(['moveTo', 'lineTo', 'lineTo']);
376
+ });
377
+
378
+ it('emits nothing for fewer than 2 points', () => {
379
+ const shape = createShape();
380
+ appendShapePolyline(shape, [0, 0]);
381
+ expect(shape.data.commands).toHaveLength(0);
382
+ });
383
+ });
384
+
385
+ describe('appendShapeRectangle', () => {
386
+ it('pushes a drawRectangle command with position and dimensions', () => {
387
+ const shape = createShape();
388
+ appendShapeRectangle(shape, 10, 20, 100, 50);
389
+ expect(shape.data.commands).toEqual(['drawRectangle', 4, 10, 20, 100, 50]);
390
+ });
391
+ });
392
+
393
+ describe('appendShapeRoundRectangle', () => {
394
+ it('pushes a drawRoundRectangle command with position, dimensions, and corner radii', () => {
395
+ const shape = createShape();
396
+ appendShapeRoundRectangle(shape, 0, 0, 100, 50, 10, 8);
397
+ expect(shape.data.commands).toEqual(['drawRoundRectangle', 6, 0, 0, 100, 50, 10, 8]);
398
+ });
399
+ });
400
+
401
+ describe('appendShapeRoundRectangleVarying', () => {
402
+ it('expands to moveTo/lineTo/curveTo commands (no new command type)', () => {
403
+ const shape = createShape();
404
+ appendShapeRoundRectangleVarying(shape, 0, 0, 100, 50, 5, 5, 5, 5);
405
+ const knownPrimitives = ['moveTo', 'lineTo', 'curveTo'];
406
+ const keys: string[] = [];
407
+ let i = 0;
408
+ while (i < shape.data.commands.length) {
409
+ const key = shape.data.commands[i] as string;
410
+ const argCount = shape.data.commands[i + 1] as number;
411
+ keys.push(key);
412
+ i += argCount + 2;
413
+ }
414
+ expect(keys.length).toBeGreaterThan(1);
415
+ expect(keys.every((k) => knownPrimitives.includes(k))).toBe(true);
416
+ });
417
+ });
418
+
419
+ describe('PathCommand', () => {
420
+ it('has expected numeric values', () => {
421
+ expect(PathCommand.NO_OP).toBe(0);
422
+ expect(PathCommand.MOVE_TO).toBe(1);
423
+ expect(PathCommand.LINE_TO).toBe(2);
424
+ expect(PathCommand.CURVE_TO).toBe(3);
425
+ expect(PathCommand.WIDE_MOVE_TO).toBe(4);
426
+ expect(PathCommand.WIDE_LINE_TO).toBe(5);
427
+ expect(PathCommand.CUBIC_CURVE_TO).toBe(6);
428
+ });
429
+ });
@@ -0,0 +1,116 @@
1
+ import { PathCommand } from '@flighthq/types';
2
+
3
+ import { createShape } from './shape';
4
+ import {
5
+ appendShapeBeginFill,
6
+ appendShapeBeginGradientFill,
7
+ appendShapeCircle,
8
+ appendShapeEndFill,
9
+ appendShapeLineStyle,
10
+ appendShapeLineTo,
11
+ appendShapeMoveTo,
12
+ appendShapeRectangle,
13
+ } from './shapeCommands';
14
+ import { getShapeFillRegions, hasNonSolidShapeFill } from './shapeFill';
15
+
16
+ describe('getShapeFillRegions', () => {
17
+ it('resolves a solid rectangle fill into one region with a closed outline', () => {
18
+ const shape = createShape();
19
+ appendShapeBeginFill(shape, 0xff0000, 1);
20
+ appendShapeRectangle(shape, 10, 20, 100, 50);
21
+ appendShapeEndFill(shape);
22
+
23
+ const regions = getShapeFillRegions(shape.data.commands);
24
+
25
+ expect(regions).not.toBeNull();
26
+ expect(regions!.length).toBe(1);
27
+ expect(regions![0].color).toBe(0xff0000);
28
+ expect(regions![0].alpha).toBe(1);
29
+ expect(regions![0].path.commands).toEqual([
30
+ PathCommand.MOVE_TO,
31
+ PathCommand.LINE_TO,
32
+ PathCommand.LINE_TO,
33
+ PathCommand.LINE_TO,
34
+ PathCommand.LINE_TO,
35
+ ]);
36
+ expect(regions![0].path.data.slice(0, 4)).toEqual([10, 20, 110, 20]);
37
+ });
38
+
39
+ it('expands a circle into four cubic curves', () => {
40
+ const shape = createShape();
41
+ appendShapeBeginFill(shape, 0x00ff00);
42
+ appendShapeCircle(shape, 50, 50, 20);
43
+ appendShapeEndFill(shape);
44
+
45
+ const regions = getShapeFillRegions(shape.data.commands)!;
46
+ expect(regions[0].path.commands).toEqual([
47
+ PathCommand.MOVE_TO,
48
+ PathCommand.CUBIC_CURVE_TO,
49
+ PathCommand.CUBIC_CURVE_TO,
50
+ PathCommand.CUBIC_CURVE_TO,
51
+ PathCommand.CUBIC_CURVE_TO,
52
+ ]);
53
+ // Starts at the rightmost point (cx + r, cy).
54
+ expect(regions[0].path.data.slice(0, 2)).toEqual([70, 50]);
55
+ });
56
+
57
+ it('resolves a moveTo/lineTo polygon fill', () => {
58
+ const shape = createShape();
59
+ appendShapeBeginFill(shape, 0x0000ff);
60
+ appendShapeMoveTo(shape, 0, 0);
61
+ appendShapeLineTo(shape, 100, 0);
62
+ appendShapeLineTo(shape, 50, 80);
63
+ appendShapeEndFill(shape);
64
+
65
+ const regions = getShapeFillRegions(shape.data.commands)!;
66
+ expect(regions.length).toBe(1);
67
+ expect(regions[0].path.commands).toEqual([PathCommand.MOVE_TO, PathCommand.LINE_TO, PathCommand.LINE_TO]);
68
+ });
69
+
70
+ it('returns a region per fill span when fills are not explicitly ended', () => {
71
+ const shape = createShape();
72
+ appendShapeBeginFill(shape, 0x111111);
73
+ appendShapeRectangle(shape, 0, 0, 10, 10);
74
+ appendShapeBeginFill(shape, 0x222222);
75
+ appendShapeRectangle(shape, 20, 20, 10, 10);
76
+ appendShapeEndFill(shape);
77
+
78
+ const regions = getShapeFillRegions(shape.data.commands)!;
79
+ expect(regions.map((r) => r.color)).toEqual([0x111111, 0x222222]);
80
+ });
81
+
82
+ it('returns null for a gradient fill (falls back to raster)', () => {
83
+ const shape = createShape();
84
+ appendShapeBeginGradientFill(shape, 'linear', [0xff0000, 0x0000ff], [1, 1], [0, 255]);
85
+ appendShapeRectangle(shape, 0, 0, 10, 10);
86
+ appendShapeEndFill(shape);
87
+
88
+ expect(getShapeFillRegions(shape.data.commands)).toBeNull();
89
+ });
90
+
91
+ it('returns null when a stroke is present', () => {
92
+ const shape = createShape();
93
+ appendShapeLineStyle(shape, 2, 0x000000);
94
+ appendShapeBeginFill(shape, 0xff0000);
95
+ appendShapeRectangle(shape, 0, 0, 10, 10);
96
+ appendShapeEndFill(shape);
97
+
98
+ expect(getShapeFillRegions(shape.data.commands)).toBeNull();
99
+ });
100
+ });
101
+
102
+ describe('hasNonSolidShapeFill', () => {
103
+ it('is false for solid fills only', () => {
104
+ const shape = createShape();
105
+ appendShapeBeginFill(shape, 0xff0000);
106
+ appendShapeRectangle(shape, 0, 0, 10, 10);
107
+ appendShapeEndFill(shape);
108
+ expect(hasNonSolidShapeFill(shape.data.commands)).toBe(false);
109
+ });
110
+
111
+ it('is true when a bitmap or gradient fill or stroke is present', () => {
112
+ const shape = createShape();
113
+ appendShapeLineStyle(shape, 1, 0);
114
+ expect(hasNonSolidShapeFill(shape.data.commands)).toBe(true);
115
+ });
116
+ });
@@ -0,0 +1,77 @@
1
+ import { enableShapeHitTesting } from './shapeHitTestBuiltins';
2
+ import { hitTestShapeCommandPoint } from './shapeHitTestRegistry';
3
+
4
+ // Call once to register built-in handlers for this test module.
5
+ enableShapeHitTesting();
6
+
7
+ describe('enableShapeHitTesting', () => {
8
+ describe('drawCircle', () => {
9
+ it('returns true for a point inside the circle', () => {
10
+ const buf = ['drawCircle', 3, 50, 50, 25];
11
+ expect(hitTestShapeCommandPoint(buf, 0, 50, 50)).toBe(true);
12
+ });
13
+ it('returns true for a point exactly on the edge', () => {
14
+ const buf = ['drawCircle', 3, 0, 0, 10];
15
+ expect(hitTestShapeCommandPoint(buf, 0, 10, 0)).toBe(true);
16
+ });
17
+ it('returns false for a point outside the circle', () => {
18
+ const buf = ['drawCircle', 3, 50, 50, 25];
19
+ expect(hitTestShapeCommandPoint(buf, 0, 100, 100)).toBe(false);
20
+ });
21
+ });
22
+
23
+ describe('drawEllipse', () => {
24
+ it('returns true for a point at the ellipse center', () => {
25
+ // drawEllipse args: x (top-left), y (top-left), width, height
26
+ const buf = ['drawEllipse', 4, 0, 0, 100, 60];
27
+ expect(hitTestShapeCommandPoint(buf, 0, 50, 30)).toBe(true);
28
+ });
29
+ it('returns false for a point outside the ellipse', () => {
30
+ const buf = ['drawEllipse', 4, 0, 0, 100, 60];
31
+ expect(hitTestShapeCommandPoint(buf, 0, 0, 0)).toBe(false);
32
+ });
33
+ });
34
+
35
+ describe('drawRectangle', () => {
36
+ it('returns true for a point inside the rectangle', () => {
37
+ const buf = ['drawRectangle', 4, 10, 10, 80, 60];
38
+ expect(hitTestShapeCommandPoint(buf, 0, 50, 40)).toBe(true);
39
+ });
40
+ it('returns true for a point on the rectangle edge', () => {
41
+ const buf = ['drawRectangle', 4, 10, 10, 80, 60];
42
+ expect(hitTestShapeCommandPoint(buf, 0, 10, 10)).toBe(true);
43
+ });
44
+ it('returns false for a point outside the rectangle', () => {
45
+ const buf = ['drawRectangle', 4, 10, 10, 80, 60];
46
+ expect(hitTestShapeCommandPoint(buf, 0, 0, 0)).toBe(false);
47
+ });
48
+ });
49
+
50
+ describe('drawRoundRectangle', () => {
51
+ it('returns true for a point in the rectangular bounds', () => {
52
+ const buf = ['drawRoundRectangle', 6, 0, 0, 100, 50, 10, 10];
53
+ expect(hitTestShapeCommandPoint(buf, 0, 50, 25)).toBe(true);
54
+ });
55
+ it('returns false for a point outside the rectangular bounds', () => {
56
+ const buf = ['drawRoundRectangle', 6, 0, 0, 100, 50, 10, 10];
57
+ expect(hitTestShapeCommandPoint(buf, 0, 200, 200)).toBe(false);
58
+ });
59
+ it('returns false for a point in the corner cutout region', () => {
60
+ // With ellipseWidth=40, ellipseHeight=40: corner arcs have rx=cy=20.
61
+ // The very corner (0, 0) is outside the rounded shape.
62
+ const buf = ['drawRoundRectangle', 6, 0, 0, 100, 100, 40, 40];
63
+ // (1, 1) is in the corner cutout — distance from corner center (20, 20) is sqrt(361)~19,
64
+ // which is < 20 so it's inside the ellipse. Actually let's test a clearly-outside corner:
65
+ // corner ellipse center is at (20, 20) with rx=ry=20; (2, 2) is dist ~18/20=0.9 < 1 => inside.
66
+ // Use (0, 0) — normalized: (0-20)/20, (0-20)/20 => (-1, -1) => 1+1=2 > 1 => outside.
67
+ expect(hitTestShapeCommandPoint(buf, 0, 0, 0)).toBe(false);
68
+ });
69
+ it('returns true for a point on the rounded corner arc boundary', () => {
70
+ // Corner center at (20, 20) with rx=ry=20; point at (20, 0) is exactly on the top edge.
71
+ const buf = ['drawRoundRectangle', 6, 0, 0, 100, 100, 40, 40];
72
+ // (20, 0): corner check: inLeft=(20<20)=false, inTop=(0<20)=true, inRight=false.
73
+ // Since not both inLeft/inRight AND inTop/inBottom, falls to the cross check → inside.
74
+ expect(hitTestShapeCommandPoint(buf, 0, 20, 0)).toBe(true);
75
+ });
76
+ });
77
+ });
@@ -0,0 +1,45 @@
1
+ import type { ShapeCommandKey } from '@flighthq/types';
2
+
3
+ import { hitTestShapeCommandPoint, registerShapeHitTestCommand } from './shapeHitTestRegistry';
4
+
5
+ describe('hitTestShapeCommandPoint', () => {
6
+ it('returns null for an unregistered command key', () => {
7
+ const buf: unknown[] = ['__unregistered__', 0];
8
+ expect(hitTestShapeCommandPoint(buf, 0, 0, 0)).toBeNull();
9
+ });
10
+
11
+ it('passes x, y, buf, and i+2 to the registered handler', () => {
12
+ const fn = vi.fn().mockReturnValue(false);
13
+ registerShapeHitTestCommand({ key: 'moveTo' as ShapeCommandKey, hitTest: fn });
14
+ const buf: unknown[] = ['moveTo', 2, 10, 20];
15
+ hitTestShapeCommandPoint(buf, 0, 5, 7);
16
+ expect(fn).toHaveBeenCalledWith(5, 7, buf, 2);
17
+ });
18
+
19
+ it('returns the handler return value', () => {
20
+ registerShapeHitTestCommand({ key: 'endFill' as ShapeCommandKey, hitTest: () => true });
21
+ const buf: unknown[] = ['endFill', 0];
22
+ expect(hitTestShapeCommandPoint(buf, 0, 0, 0)).toBe(true);
23
+ });
24
+ });
25
+
26
+ describe('registerShapeHitTestCommand', () => {
27
+ it('registers a handler that hitTestShapeCommandPoint can retrieve', () => {
28
+ const fn = vi.fn().mockReturnValue(true);
29
+ registerShapeHitTestCommand({ key: 'drawRectangle' as ShapeCommandKey, hitTest: fn });
30
+ const buf: unknown[] = ['drawRectangle', 4, 0, 0, 100, 100];
31
+ const result = hitTestShapeCommandPoint(buf, 0, 50, 50);
32
+ expect(result).toBe(true);
33
+ });
34
+
35
+ it('replaces an existing handler when registered again', () => {
36
+ const first = vi.fn().mockReturnValue(false);
37
+ const second = vi.fn().mockReturnValue(true);
38
+ registerShapeHitTestCommand({ key: 'drawCircle' as ShapeCommandKey, hitTest: first });
39
+ registerShapeHitTestCommand({ key: 'drawCircle' as ShapeCommandKey, hitTest: second });
40
+ const buf: unknown[] = ['drawCircle', 3, 50, 50, 25];
41
+ hitTestShapeCommandPoint(buf, 0, 50, 50);
42
+ expect(second).toHaveBeenCalled();
43
+ expect(first).not.toHaveBeenCalled();
44
+ });
45
+ });