@pluot/react 0.1.16 → 0.1.18

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,590 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ clampToBrushRegion,
4
+ describeWedgePath,
5
+ getBrushGeometry,
6
+ getClearButtonCenter,
7
+ getEdgeDragCorners,
8
+ getEdgeLine,
9
+ getEditableEdges,
10
+ getVerticesBoundingBox,
11
+ isDegenerateBrush,
12
+ isPointInBrush,
13
+ pixelsFromVertex,
14
+ rectVerticesFromCorners,
15
+ reprojectBrushState,
16
+ reprojectVertex,
17
+ vertexFromPixels,
18
+ type BrushGeometryParams,
19
+ } from './brush.js';
20
+ import type { BrushState } from './types.js';
21
+
22
+ function identityCamera(): Float32Array {
23
+ return new Float32Array([
24
+ 1, 0, 0, 0,
25
+ 0, 1, 0, 0,
26
+ 0, 0, 1, 0,
27
+ 0, 0, 0, 1,
28
+ ]);
29
+ }
30
+
31
+ function zoomCamera(zoom: number): Float32Array {
32
+ return new Float32Array([
33
+ zoom, 0, 0, 0,
34
+ 0, zoom, 0, 0,
35
+ 0, 0, 1, 0,
36
+ 0, 0, 0, 1,
37
+ ]);
38
+ }
39
+
40
+ // A 400x400 container with 50px margins all around, giving a square 300x300
41
+ // layer region, so that "Contain" mode introduces no aspect-ratio adjustment.
42
+ function baseParams(overrides: Partial<BrushGeometryParams> = {}): BrushGeometryParams {
43
+ return {
44
+ width: 400,
45
+ height: 400,
46
+ marginTop: 50,
47
+ marginRight: 50,
48
+ marginBottom: 50,
49
+ marginLeft: 50,
50
+ brushMarginTop: undefined,
51
+ brushMarginRight: undefined,
52
+ brushMarginBottom: undefined,
53
+ brushMarginLeft: undefined,
54
+ brushUnitsModeX: "Data",
55
+ brushUnitsModeY: "Data",
56
+ aspectRatioMode: "Contain",
57
+ aspectRatioAlignmentMode: "Center",
58
+ cameraMatrix: identityCamera(),
59
+ ...overrides,
60
+ };
61
+ }
62
+
63
+ describe('getBrushGeometry', () => {
64
+ it('defaults the brushable region to the layer region', () => {
65
+ const geom = getBrushGeometry(baseParams({ brushUnitsModeX: "Pixels", brushUnitsModeY: "Pixels" }));
66
+ expect(geom.brushLeft).toBe(50);
67
+ expect(geom.brushTop).toBe(50);
68
+ expect(geom.brushRight).toBe(350);
69
+ expect(geom.brushBottom).toBe(350);
70
+ });
71
+
72
+ it('applies the brush margins when the units mode is not Data', () => {
73
+ const geom = getBrushGeometry(baseParams({
74
+ brushUnitsModeX: "Pixels",
75
+ brushUnitsModeY: "Normalized",
76
+ brushMarginLeft: 0,
77
+ brushMarginRight: 100,
78
+ brushMarginTop: 10,
79
+ brushMarginBottom: 20,
80
+ }));
81
+ expect(geom.brushLeft).toBe(0);
82
+ expect(geom.brushRight).toBe(300);
83
+ expect(geom.brushTop).toBe(10);
84
+ expect(geom.brushBottom).toBe(380);
85
+ // The layer region is unaffected by the brush margins.
86
+ expect(geom.layerLeft).toBe(50);
87
+ expect(geom.layerWidth).toBe(300);
88
+ });
89
+
90
+ it('ignores the brush margins for an axis in Data units mode', () => {
91
+ const geom = getBrushGeometry(baseParams({
92
+ brushUnitsModeX: "Data",
93
+ brushUnitsModeY: "Pixels",
94
+ brushMarginLeft: 0,
95
+ brushMarginRight: 0,
96
+ brushMarginTop: 0,
97
+ brushMarginBottom: 0,
98
+ }));
99
+ // X falls back to the layer (camera) bounds...
100
+ expect(geom.brushLeft).toBe(50);
101
+ expect(geom.brushRight).toBe(350);
102
+ // ...while Y honours the brush margins.
103
+ expect(geom.brushTop).toBe(0);
104
+ expect(geom.brushBottom).toBe(400);
105
+ });
106
+ });
107
+
108
+ describe('vertexFromPixels', () => {
109
+ it('maps the layer corners onto the data bounds, with Y flipped', () => {
110
+ const geom = getBrushGeometry(baseParams());
111
+ const { xMin, xMax, yMin, yMax } = geom.dataBounds;
112
+
113
+ // Top-left in pixels is (xMin, yMax) in data, since data Y increases upwards.
114
+ const topLeft = vertexFromPixels(50, 50, geom);
115
+ expect(topLeft.x_data).toBeCloseTo(xMin, 6);
116
+ expect(topLeft.y_data).toBeCloseTo(yMax, 6);
117
+
118
+ const bottomRight = vertexFromPixels(350, 350, geom);
119
+ expect(bottomRight.x_data).toBeCloseTo(xMax, 6);
120
+ expect(bottomRight.y_data).toBeCloseTo(yMin, 6);
121
+ });
122
+
123
+ it('maps the brushable corners onto normalized 0-to-1, with Y flipped', () => {
124
+ const geom = getBrushGeometry(baseParams({
125
+ brushUnitsModeX: "Normalized",
126
+ brushUnitsModeY: "Normalized",
127
+ brushMarginLeft: 100,
128
+ brushMarginRight: 100,
129
+ brushMarginTop: 100,
130
+ brushMarginBottom: 100,
131
+ }));
132
+ const topLeft = vertexFromPixels(100, 100, geom);
133
+ expect(topLeft.x_normalized).toBeCloseTo(0, 6);
134
+ expect(topLeft.y_normalized).toBeCloseTo(1, 6);
135
+
136
+ const center = vertexFromPixels(200, 200, geom);
137
+ expect(center.x_normalized).toBeCloseTo(0.5, 6);
138
+ expect(center.y_normalized).toBeCloseTo(0.5, 6);
139
+ });
140
+
141
+ it('returns 0 rather than NaN for a degenerate region', () => {
142
+ const geom = getBrushGeometry(baseParams({ width: 100, marginLeft: 50, marginRight: 50 }));
143
+ expect(geom.layerWidth).toBe(0);
144
+ expect(vertexFromPixels(50, 200, geom).x_normalized).toBe(0);
145
+ expect(Number.isNaN(vertexFromPixels(50, 200, geom).x_data)).toBe(false);
146
+ });
147
+ });
148
+
149
+ describe('pixelsFromVertex', () => {
150
+ it('round-trips through every units mode', () => {
151
+ const geom = getBrushGeometry(baseParams({
152
+ brushUnitsModeX: "Pixels",
153
+ brushUnitsModeY: "Pixels",
154
+ brushMarginLeft: 20,
155
+ brushMarginRight: 30,
156
+ brushMarginTop: 40,
157
+ brushMarginBottom: 10,
158
+ }));
159
+ const vertex = vertexFromPixels(123, 234, geom);
160
+
161
+ for (const modeX of ["Pixels", "Data", "Normalized"] as const) {
162
+ for (const modeY of ["Pixels", "Data", "Normalized"] as const) {
163
+ const [x, y] = pixelsFromVertex(vertex, geom, modeX, modeY);
164
+ expect(x).toBeCloseTo(123, 6);
165
+ expect(y).toBeCloseTo(234, 6);
166
+ }
167
+ }
168
+ });
169
+ });
170
+
171
+ describe('reprojectVertex', () => {
172
+ it('keeps a Data-mode vertex pinned to the data when the camera zooms', () => {
173
+ const before = getBrushGeometry(baseParams());
174
+ const vertex = vertexFromPixels(200, 200, before);
175
+
176
+ // Zooming 2x about the origin halves the visible data range.
177
+ const after = getBrushGeometry(baseParams({ cameraMatrix: zoomCamera(2) }));
178
+ const reprojected = reprojectVertex(vertex, after, "Data", "Data");
179
+
180
+ expect(reprojected.x_data).toBeCloseTo(vertex.x_data, 5);
181
+ expect(reprojected.y_data).toBeCloseTo(vertex.y_data, 5);
182
+ // The center of the data stays at the center of the layer under this camera,
183
+ // but a point off-center would move; check a corner to prove the pixels track.
184
+ const corner = vertexFromPixels(50, 50, before);
185
+ const reprojectedCorner = reprojectVertex(corner, after, "Data", "Data");
186
+ expect(reprojectedCorner.x_pixels).not.toBeCloseTo(corner.x_pixels, 1);
187
+ expect(reprojectedCorner.x_data).toBeCloseTo(corner.x_data, 5);
188
+ });
189
+
190
+ it('keeps a Pixels-mode vertex fixed on screen when the camera zooms', () => {
191
+ const before = getBrushGeometry(baseParams({ brushUnitsModeX: "Pixels", brushUnitsModeY: "Pixels" }));
192
+ const vertex = vertexFromPixels(80, 300, before);
193
+
194
+ const after = getBrushGeometry(baseParams({
195
+ brushUnitsModeX: "Pixels",
196
+ brushUnitsModeY: "Pixels",
197
+ cameraMatrix: zoomCamera(2),
198
+ }));
199
+ const reprojected = reprojectVertex(vertex, after, "Pixels", "Pixels");
200
+
201
+ expect(reprojected.x_pixels).toBeCloseTo(80, 6);
202
+ expect(reprojected.y_pixels).toBeCloseTo(300, 6);
203
+ // The derived data coordinate does change, since less data is now visible.
204
+ expect(reprojected.x_data).not.toBeCloseTo(vertex.x_data, 3);
205
+ });
206
+
207
+ it('can pin one axis to the data and the other to the screen', () => {
208
+ const params = { brushUnitsModeX: "Data", brushUnitsModeY: "Pixels" } as const;
209
+ const before = getBrushGeometry(baseParams(params));
210
+ const vertex = vertexFromPixels(80, 300, before);
211
+
212
+ const after = getBrushGeometry(baseParams({ ...params, cameraMatrix: zoomCamera(2) }));
213
+ const reprojected = reprojectVertex(vertex, after, "Data", "Pixels");
214
+
215
+ expect(reprojected.y_pixels).toBeCloseTo(300, 6);
216
+ expect(reprojected.x_data).toBeCloseTo(vertex.x_data, 5);
217
+ expect(reprojected.x_pixels).not.toBeCloseTo(vertex.x_pixels, 1);
218
+ });
219
+ });
220
+
221
+ describe('getClearButtonCenter', () => {
222
+ // Brushable region spans 50..350 on both axes.
223
+ const geom = getBrushGeometry(baseParams({ brushUnitsModeX: "Pixels", brushUnitsModeY: "Pixels" }));
224
+ const radius = 9;
225
+ const offset = radius + 3;
226
+
227
+ function makeVertices(points: number[][]) {
228
+ return points.map(([x, y]) => vertexFromPixels(x!, y!, geom));
229
+ }
230
+
231
+ it('returns null for an empty brush', () => {
232
+ expect(getClearButtonCenter([], radius, geom)).toBeNull();
233
+ });
234
+
235
+ it("sits adjacent to a rect's first vertex, outside the rect", () => {
236
+ // rectVerticesFromCorners orders corners clockwise from the top-left, so the
237
+ // first vertex is the top-left one and the button goes up and to the left.
238
+ const vertices = rectVerticesFromCorners(100, 120, 200, 220, geom);
239
+ const center = getClearButtonCenter(vertices, radius, geom)!;
240
+ expect(center[0]).toBeCloseTo(100 - offset * Math.SQRT1_2, 4);
241
+ expect(center[1]).toBeCloseTo(120 - offset * Math.SQRT1_2, 4);
242
+ });
243
+
244
+ it("sits adjacent to a lasso's first vertex, where the drag started", () => {
245
+ // Centroid is (200, 200); the first vertex is straight above it.
246
+ const center = getClearButtonCenter(makeVertices([[200, 100], [100, 250], [300, 250]]), radius, geom)!;
247
+ expect(center[0]).toBeCloseTo(200, 4);
248
+ expect(center[1]).toBeCloseTo(100 - offset, 4);
249
+ });
250
+
251
+ it('stays pinned to the start as a lasso grows, rather than trailing the cursor', () => {
252
+ // The centroid shifts as vertices are appended, so the outward direction
253
+ // rotates, but the button never leaves the neighbourhood of the first vertex.
254
+ const start: number[][] = [[200, 100], [100, 250]];
255
+ for (const points of [start, [...start, [300, 250]], [...start, [300, 250], [260, 180]]]) {
256
+ const center = getClearButtonCenter(makeVertices(points), radius, geom)!;
257
+ expect(Math.hypot(center[0] - 200, center[1] - 100)).toBeCloseTo(offset, 4);
258
+ }
259
+ });
260
+
261
+ it('falls back to a fixed diagonal when there is no interior to move away from', () => {
262
+ const center = getClearButtonCenter(makeVertices([[200, 200], [200, 200]]), radius, geom)!;
263
+ expect(center[0]).toBeCloseTo(200 + offset * Math.SQRT1_2, 4);
264
+ expect(center[1]).toBeCloseTo(200 - offset * Math.SQRT1_2, 4);
265
+ });
266
+
267
+ it('stays inside the brushable region when the brush reaches its edges', () => {
268
+ // Unclamped this would land beyond the region, where the clipped overlay
269
+ // would make it both invisible and unclickable.
270
+ const center = getClearButtonCenter(makeVertices([[200, 200], [350, 350]]), radius, geom)!;
271
+ expect(center[0]).toBeLessThanOrEqual(geom.brushRight - radius);
272
+ expect(center[1]).toBeLessThanOrEqual(geom.brushBottom - radius);
273
+ });
274
+
275
+ it('clamps a brush that has scrolled out of the region entirely', () => {
276
+ const center = getClearButtonCenter(makeVertices([[-800, -800], [-900, -900]]), radius, geom)!;
277
+ expect(center[0]).toBeGreaterThanOrEqual(geom.brushLeft + radius);
278
+ expect(center[1]).toBeGreaterThanOrEqual(geom.brushTop + radius);
279
+ });
280
+ });
281
+
282
+ describe('getEditableEdges', () => {
283
+ it('offers all four sides of a Rect', () => {
284
+ expect(getEditableEdges('Rect')).toEqual(['Top', 'Right', 'Bottom', 'Left']);
285
+ });
286
+
287
+ it('offers only the sides a range brush can actually move', () => {
288
+ expect(getEditableEdges('RangeX')).toEqual(['Left', 'Right']);
289
+ expect(getEditableEdges('RangeY')).toEqual(['Top', 'Bottom']);
290
+ });
291
+
292
+ it('offers no sides for a lasso', () => {
293
+ expect(getEditableEdges('Polygon')).toEqual([]);
294
+ });
295
+ });
296
+
297
+ describe('getEdgeLine', () => {
298
+ const boundingBox = { left: 100, top: 120, right: 300, bottom: 340 };
299
+
300
+ it('spans each side of the bounding box', () => {
301
+ expect(getEdgeLine('Top', boundingBox)).toEqual([100, 120, 300, 120]);
302
+ expect(getEdgeLine('Bottom', boundingBox)).toEqual([100, 340, 300, 340]);
303
+ expect(getEdgeLine('Left', boundingBox)).toEqual([100, 120, 100, 340]);
304
+ expect(getEdgeLine('Right', boundingBox)).toEqual([300, 120, 300, 340]);
305
+ });
306
+ });
307
+
308
+ describe('getEdgeDragCorners', () => {
309
+ const geom = getBrushGeometry(baseParams({ brushUnitsModeX: "Pixels", brushUnitsModeY: "Pixels" }));
310
+ const boundingBox = { left: 100, top: 120, right: 300, bottom: 340 };
311
+
312
+ // Replays what `updateRect` does with the corners this returns.
313
+ function dragEdge(edge: Parameters<typeof getEdgeDragCorners>[0], cursorX: number, cursorY: number) {
314
+ const { axis, fixedX, fixedY, movingX, movingY } = getEdgeDragCorners(edge, boundingBox);
315
+ return getVerticesBoundingBox(rectVerticesFromCorners(
316
+ fixedX, fixedY,
317
+ axis === "Y" ? movingX : cursorX,
318
+ axis === "X" ? movingY : cursorY,
319
+ geom,
320
+ ))!;
321
+ }
322
+
323
+ it('moves only the dragged side, ignoring cursor movement along the other axis', () => {
324
+ // The cursor wanders far off in Y, but dragging the left side must not change
325
+ // the top or the bottom.
326
+ expect(dragEdge('Left', 160, 999)).toEqual({ left: 160, top: 120, right: 300, bottom: 340 });
327
+ expect(dragEdge('Right', 260, -999)).toEqual({ left: 100, top: 120, right: 260, bottom: 340 });
328
+ expect(dragEdge('Top', 999, 200)).toEqual({ left: 100, top: 200, right: 300, bottom: 340 });
329
+ expect(dragEdge('Bottom', -999, 300)).toEqual({ left: 100, top: 120, right: 300, bottom: 300 });
330
+ });
331
+
332
+ it('flips the brush when a side is dragged past its opposite', () => {
333
+ // Dragging the left side to the right of the right side yields a rect that
334
+ // extends from the old right edge, rather than an inside-out one.
335
+ expect(dragEdge('Left', 400, 0)).toEqual({ left: 300, top: 120, right: 400, bottom: 340 });
336
+ expect(dragEdge('Bottom', 0, 50)).toEqual({ left: 100, top: 50, right: 300, bottom: 120 });
337
+ });
338
+
339
+ it('keeps the opposite side fixed', () => {
340
+ for (const [edge, key] of [['Left', 'right'], ['Right', 'left'], ['Top', 'bottom'], ['Bottom', 'top']] as const) {
341
+ const dragged = dragEdge(edge, 175, 175);
342
+ expect(dragged[key]).toBe(boundingBox[key]);
343
+ }
344
+ });
345
+ });
346
+
347
+ describe('isDegenerateBrush', () => {
348
+ const geom = getBrushGeometry(baseParams({ brushUnitsModeX: "Pixels", brushUnitsModeY: "Pixels" }));
349
+
350
+ function state(shape: BrushState['shape'], vertices: BrushState['vertices']): BrushState {
351
+ return { status: 'Drawing', shape, vertices };
352
+ }
353
+
354
+ it('rejects the zero-area rect that a long-click with no drag produces', () => {
355
+ expect(isDegenerateBrush(state('Rect', rectVerticesFromCorners(200, 200, 200, 200, geom)))).toBe(true);
356
+ });
357
+
358
+ it('rejects a rect that collapses along either axis', () => {
359
+ expect(isDegenerateBrush(state('Rect', rectVerticesFromCorners(100, 200, 300, 200, geom)))).toBe(true);
360
+ expect(isDegenerateBrush(state('Rect', rectVerticesFromCorners(200, 100, 200, 300, geom)))).toBe(true);
361
+ });
362
+
363
+ it('accepts a rect with extent on both axes', () => {
364
+ expect(isDegenerateBrush(state('Rect', rectVerticesFromCorners(100, 100, 300, 300, geom)))).toBe(false);
365
+ });
366
+
367
+ it('judges a range brush only on the axis it selects', () => {
368
+ // Full brush height, but no width: nothing was selected.
369
+ expect(isDegenerateBrush(state('RangeX', rectVerticesFromCorners(200, 0, 200, 0, geom, 'RangeX')))).toBe(true);
370
+ expect(isDegenerateBrush(state('RangeX', rectVerticesFromCorners(100, 0, 300, 0, geom, 'RangeX')))).toBe(false);
371
+ expect(isDegenerateBrush(state('RangeY', rectVerticesFromCorners(0, 200, 0, 200, geom, 'RangeY')))).toBe(true);
372
+ expect(isDegenerateBrush(state('RangeY', rectVerticesFromCorners(0, 100, 0, 300, geom, 'RangeY')))).toBe(false);
373
+ });
374
+
375
+ it('requires a lasso to have at least three vertices', () => {
376
+ const points = [[100, 100], [200, 100], [200, 200]].map(([x, y]) => vertexFromPixels(x!, y!, geom));
377
+ expect(isDegenerateBrush(state('Polygon', []))).toBe(true);
378
+ expect(isDegenerateBrush(state('Polygon', points.slice(0, 2)))).toBe(true);
379
+ expect(isDegenerateBrush(state('Polygon', points))).toBe(false);
380
+ });
381
+ });
382
+
383
+ describe('reprojectBrushState', () => {
384
+ function completeState(shape: BrushState['shape'], vertices: BrushState['vertices']): BrushState {
385
+ return { status: 'Complete', shape, vertices };
386
+ }
387
+
388
+ // Reprojection round-trips through the Float32Array camera matrix, so the
389
+ // recovered pixel positions carry float32-sized error.
390
+ function expectBoundingBoxCloseTo(
391
+ vertices: BrushState['vertices'],
392
+ expected: { left: number, top: number, right: number, bottom: number },
393
+ ) {
394
+ const actual = getVerticesBoundingBox(vertices)!;
395
+ expect(actual.left).toBeCloseTo(expected.left, 4);
396
+ expect(actual.top).toBeCloseTo(expected.top, 4);
397
+ expect(actual.right).toBeCloseTo(expected.right, 4);
398
+ expect(actual.bottom).toBeCloseTo(expected.bottom, 4);
399
+ }
400
+
401
+ it('re-pins the unselected axis of a RangeX brush when the margins change', () => {
402
+ // "Ignore" keeps the data bounds at 0..1 regardless of aspect ratio, so the
403
+ // only thing moving in this test is the brushable extent.
404
+ const params = { aspectRatioMode: "Ignore" } as const;
405
+ const before = getBrushGeometry(baseParams(params));
406
+ const state = completeState('RangeX', rectVerticesFromCorners(100, 0, 200, 0, before, 'RangeX'));
407
+ expect(getVerticesBoundingBox(state.vertices)).toEqual({ left: 100, top: 50, right: 200, bottom: 350 });
408
+
409
+ const after = getBrushGeometry(baseParams({ ...params, marginBottom: 100 }));
410
+ const reprojected = reprojectBrushState(state, after, "Data", "Data");
411
+
412
+ // The selected X extent is untouched, while Y shrinks to the new full height.
413
+ expectBoundingBoxCloseTo(reprojected.vertices, { left: 100, top: 50, right: 200, bottom: 300 });
414
+ });
415
+
416
+ it('lets a Data-mode RangeX brush scroll with the camera while staying full-height', () => {
417
+ const before = getBrushGeometry(baseParams());
418
+ const state = completeState('RangeX', rectVerticesFromCorners(100, 0, 200, 0, before, 'RangeX'));
419
+
420
+ const after = getBrushGeometry(baseParams({ cameraMatrix: zoomCamera(2) }));
421
+ const reprojected = reprojectBrushState(state, after, "Data", "Data");
422
+ const boundingBox = getVerticesBoundingBox(reprojected.vertices)!;
423
+
424
+ // Zooming in spreads the selected range out across (and past) the viewport...
425
+ expect(boundingBox.left).toBeCloseTo(0, 4);
426
+ expect(boundingBox.right).toBeCloseTo(200, 4);
427
+ // ...but the unselected axis still spans the full brush height.
428
+ expect(boundingBox.top).toBe(50);
429
+ expect(boundingBox.bottom).toBe(350);
430
+ });
431
+
432
+ it('re-pins the unselected axis of a RangeY brush when the container is resized', () => {
433
+ // Both axes are screen-anchored, so the resize is the only thing in play.
434
+ const params = {
435
+ brushUnitsModeX: "Pixels", brushUnitsModeY: "Pixels",
436
+ brushMarginLeft: 0, brushMarginRight: 0,
437
+ } as const;
438
+ const before = getBrushGeometry(baseParams(params));
439
+ const state = completeState('RangeY', rectVerticesFromCorners(0, 100, 0, 200, before, 'RangeY'));
440
+ expect(getVerticesBoundingBox(state.vertices)).toEqual({ left: 0, top: 100, right: 400, bottom: 200 });
441
+
442
+ const after = getBrushGeometry(baseParams({ ...params, width: 200 }));
443
+ const reprojected = reprojectBrushState(state, after, "Pixels", "Pixels");
444
+
445
+ // The selected Y extent is untouched, while X shrinks to the new full width.
446
+ expectBoundingBoxCloseTo(reprojected.vertices, { left: 0, top: 100, right: 200, bottom: 200 });
447
+ });
448
+
449
+ it('leaves a Rect unchanged when nothing about the geometry changed', () => {
450
+ const geom = getBrushGeometry(baseParams());
451
+ const state = completeState('Rect', rectVerticesFromCorners(100, 120, 300, 340, geom));
452
+ const reprojected = reprojectBrushState(state, geom, "Data", "Data");
453
+ expectBoundingBoxCloseTo(reprojected.vertices, { left: 100, top: 120, right: 300, bottom: 340 });
454
+ });
455
+
456
+ it('does not re-pin a Polygon to its bounding box', () => {
457
+ const geom = getBrushGeometry(baseParams());
458
+ const vertices = [[100, 100], [300, 120], [180, 300]].map(([x, y]) => vertexFromPixels(x!, y!, geom));
459
+ const reprojected = reprojectBrushState(completeState('Polygon', vertices), geom, "Data", "Data");
460
+ expect(reprojected.vertices).toHaveLength(3);
461
+ // A rebuild from the bounding box would have moved every vertex to a corner.
462
+ reprojected.vertices.forEach((vertex, i) => {
463
+ expect(vertex.x_pixels).toBeCloseTo(vertices[i]!.x_pixels, 4);
464
+ expect(vertex.y_pixels).toBeCloseTo(vertices[i]!.y_pixels, 4);
465
+ });
466
+ });
467
+ });
468
+
469
+ describe('clampToBrushRegion', () => {
470
+ it('restricts positions to the brushable region', () => {
471
+ const geom = getBrushGeometry(baseParams({ brushUnitsModeX: "Pixels", brushUnitsModeY: "Pixels" }));
472
+ expect(clampToBrushRegion(-10, 500, geom)).toEqual([50, 350]);
473
+ expect(clampToBrushRegion(200, 200, geom)).toEqual([200, 200]);
474
+ });
475
+ });
476
+
477
+ describe('rectVerticesFromCorners', () => {
478
+ it('orders corners clockwise from the top-left, regardless of drag direction', () => {
479
+ const geom = getBrushGeometry(baseParams());
480
+ // Drag from bottom-right to top-left.
481
+ const vertices = rectVerticesFromCorners(300, 300, 100, 100, geom);
482
+ expect(vertices.map(v => [v.x_pixels, v.y_pixels])).toEqual([
483
+ [100, 100],
484
+ [300, 100],
485
+ [300, 300],
486
+ [100, 300],
487
+ ]);
488
+ });
489
+
490
+ it('pins RangeX to the full brush height, keeping the dragged X extent', () => {
491
+ const geom = getBrushGeometry(baseParams({
492
+ brushUnitsModeY: "Pixels",
493
+ brushMarginTop: 10,
494
+ brushMarginBottom: 20,
495
+ }));
496
+ const vertices = rectVerticesFromCorners(120, 200, 260, 210, geom, "RangeX");
497
+ expect(vertices.map(v => [v.x_pixels, v.y_pixels])).toEqual([
498
+ [120, 10],
499
+ [260, 10],
500
+ [260, 380],
501
+ [120, 380],
502
+ ]);
503
+ });
504
+
505
+ it('pins RangeY to the full brush width, keeping the dragged Y extent', () => {
506
+ const geom = getBrushGeometry(baseParams({
507
+ brushUnitsModeX: "Pixels",
508
+ brushMarginLeft: 30,
509
+ brushMarginRight: 40,
510
+ }));
511
+ const vertices = rectVerticesFromCorners(200, 260, 210, 120, geom, "RangeY");
512
+ expect(vertices.map(v => [v.x_pixels, v.y_pixels])).toEqual([
513
+ [30, 120],
514
+ [360, 120],
515
+ [360, 260],
516
+ [30, 260],
517
+ ]);
518
+ });
519
+
520
+ it('puts each corner diagonally opposite the one two steps away', () => {
521
+ const geom = getBrushGeometry(baseParams());
522
+ const vertices = rectVerticesFromCorners(100, 120, 300, 340, geom);
523
+ for (let i = 0; i < 4; i++) {
524
+ const opposite = vertices[(i + 2) % 4]!;
525
+ expect(opposite.x_pixels).not.toBe(vertices[i]!.x_pixels);
526
+ expect(opposite.y_pixels).not.toBe(vertices[i]!.y_pixels);
527
+ }
528
+ });
529
+ });
530
+
531
+ describe('isPointInBrush', () => {
532
+ const geom = getBrushGeometry(baseParams());
533
+ const square = rectVerticesFromCorners(100, 100, 300, 300, geom);
534
+
535
+ it('detects points inside and outside a rect', () => {
536
+ expect(isPointInBrush(200, 200, square)).toBe(true);
537
+ expect(isPointInBrush(99, 200, square)).toBe(false);
538
+ expect(isPointInBrush(200, 301, square)).toBe(false);
539
+ });
540
+
541
+ it('detects points inside a concave polygon', () => {
542
+ // An L shape occupying the top-left, bottom-left and bottom-right quadrants.
543
+ const lShape = [
544
+ [100, 100], [200, 100], [200, 200], [300, 200], [300, 300], [100, 300],
545
+ ].map(([x, y]) => vertexFromPixels(x!, y!, geom));
546
+ expect(isPointInBrush(150, 150, lShape)).toBe(true);
547
+ expect(isPointInBrush(150, 250, lShape)).toBe(true);
548
+ expect(isPointInBrush(250, 250, lShape)).toBe(true);
549
+ // Inside the bounding box, but in the notch that the L excludes.
550
+ expect(isPointInBrush(250, 150, lShape)).toBe(false);
551
+ });
552
+
553
+ it('rejects degenerate brushes', () => {
554
+ expect(isPointInBrush(200, 200, [])).toBe(false);
555
+ expect(isPointInBrush(200, 200, square.slice(0, 2))).toBe(false);
556
+ });
557
+ });
558
+
559
+ describe('getVerticesBoundingBox', () => {
560
+ it('returns null for an empty brush', () => {
561
+ expect(getVerticesBoundingBox([])).toBeNull();
562
+ });
563
+
564
+ it('spans the extreme vertices', () => {
565
+ const geom = getBrushGeometry(baseParams());
566
+ const vertices = [[120, 340], [300, 90], [200, 200]].map(([x, y]) => vertexFromPixels(x!, y!, geom));
567
+ expect(getVerticesBoundingBox(vertices)).toEqual({ left: 120, top: 90, right: 300, bottom: 340 });
568
+ });
569
+ });
570
+
571
+ describe('describeWedgePath', () => {
572
+ it('draws nothing but a degenerate wedge at zero progress', () => {
573
+ expect(describeWedgePath(10, 10, 5, 0)).toContain('M 10 10');
574
+ });
575
+
576
+ it('uses the large-arc flag only past the halfway point', () => {
577
+ expect(describeWedgePath(0, 0, 10, 0.25)).toContain('A 10 10 0 0 1');
578
+ expect(describeWedgePath(0, 0, 10, 0.75)).toContain('A 10 10 0 1 1');
579
+ });
580
+
581
+ it('closes a full circle with two arcs, since one arc cannot express it', () => {
582
+ const path = describeWedgePath(0, 0, 10, 1);
583
+ expect(path.match(/A /g)).toHaveLength(2);
584
+ });
585
+
586
+ it('clamps out-of-range fractions', () => {
587
+ expect(describeWedgePath(0, 0, 10, 2)).toBe(describeWedgePath(0, 0, 10, 1));
588
+ expect(describeWedgePath(0, 0, 10, -1)).toBe(describeWedgePath(0, 0, 10, 0));
589
+ });
590
+ });