@flighthq/gizmo 0.4.1-edge.37.ff47e13
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/LICENSE.md +9 -0
- package/README.md +17 -0
- package/dist/contract.d.ts +3 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +3 -0
- package/dist/contract.js.map +1 -0
- package/dist/gizmoState.d.ts +17 -0
- package/dist/gizmoState.d.ts.map +1 -0
- package/dist/gizmoState.js +587 -0
- package/dist/gizmoState.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/node2dGizmoFeatures.d.ts +3 -0
- package/dist/node2dGizmoFeatures.d.ts.map +1 -0
- package/dist/node2dGizmoFeatures.js +22 -0
- package/dist/node2dGizmoFeatures.js.map +1 -0
- package/package.json +57 -0
- package/src/gizmoState.test.ts +872 -0
- package/src/gizmoTreeShaking.test.ts +35 -0
- package/src/node2dGizmoFeatures.test.ts +51 -0
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
import { createCamera2D, projectCamera2DPoint } from '@flighthq/camera/contract';
|
|
2
|
+
import { createRectangle } from '@flighthq/geometry/contract';
|
|
3
|
+
import { enableInteractionSignals } from '@flighthq/interaction/contract';
|
|
4
|
+
import { createNode, getNodeChildByName, getNodeParent, removeNodeChild } from '@flighthq/node/contract';
|
|
5
|
+
import { createScene2D } from '@flighthq/scene2d/contract';
|
|
6
|
+
import { createSelectionState, selectAllNodes } from '@flighthq/selection/contract';
|
|
7
|
+
import { createShape } from '@flighthq/shape/contract';
|
|
8
|
+
import { connectSignal, emitSignal } from '@flighthq/signals/contract';
|
|
9
|
+
import type {
|
|
10
|
+
GizmoCreateOptions,
|
|
11
|
+
GizmoMode,
|
|
12
|
+
GizmoNode2DFeatures,
|
|
13
|
+
GizmoPivot,
|
|
14
|
+
GizmoSignals,
|
|
15
|
+
GizmoSpace,
|
|
16
|
+
GizmoState,
|
|
17
|
+
HierarchyNodeAny,
|
|
18
|
+
Node2D,
|
|
19
|
+
PointerEventData,
|
|
20
|
+
Rectangle,
|
|
21
|
+
Scene2D,
|
|
22
|
+
SelectionState,
|
|
23
|
+
Shape,
|
|
24
|
+
Vector2Like,
|
|
25
|
+
} from '@flighthq/types/contract';
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
createGizmoState,
|
|
29
|
+
disposeGizmoState,
|
|
30
|
+
getGizmoMode,
|
|
31
|
+
getGizmoSignals,
|
|
32
|
+
getGizmoSpace,
|
|
33
|
+
setGizmoCustomPivot,
|
|
34
|
+
setGizmoMode,
|
|
35
|
+
setGizmoPivot,
|
|
36
|
+
setGizmoSelectionOutlineColor,
|
|
37
|
+
setGizmoSelectionOutlineEnabled,
|
|
38
|
+
setGizmoSnapRotation,
|
|
39
|
+
setGizmoSnapScale,
|
|
40
|
+
setGizmoSnapTranslate,
|
|
41
|
+
setGizmoSpace,
|
|
42
|
+
updateGizmo,
|
|
43
|
+
} from './gizmoState';
|
|
44
|
+
|
|
45
|
+
interface TestNodeFeatures {
|
|
46
|
+
bounds: Rectangle;
|
|
47
|
+
originX: number;
|
|
48
|
+
originY: number;
|
|
49
|
+
rotation: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface TestContext {
|
|
53
|
+
camera: ReturnType<typeof createCamera2D>;
|
|
54
|
+
featuresByNode: Map<HierarchyNodeAny, TestNodeFeatures>;
|
|
55
|
+
overlay: Scene2D;
|
|
56
|
+
selection: SelectionState<HierarchyNodeAny>;
|
|
57
|
+
state: GizmoState<HierarchyNodeAny>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface TestGizmoOptions {
|
|
61
|
+
customPivotX?: number;
|
|
62
|
+
customPivotY?: number;
|
|
63
|
+
mode?: GizmoMode;
|
|
64
|
+
pivot?: GizmoPivot;
|
|
65
|
+
selectionOutlineColor?: number;
|
|
66
|
+
selectionOutlineEnabled?: boolean;
|
|
67
|
+
snapRotation?: number;
|
|
68
|
+
snapScale?: number;
|
|
69
|
+
snapTranslate?: number;
|
|
70
|
+
space?: GizmoSpace;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('createGizmoState', () => {
|
|
74
|
+
it('preserves generic hierarchy selection and creates the fixed semantic handle set', () => {
|
|
75
|
+
const context = createTestContext();
|
|
76
|
+
const root = getGizmoRoot(context.overlay);
|
|
77
|
+
const handleRoot = getNodeChildByName(root, 'GizmoHandleRoot') as Node2D;
|
|
78
|
+
|
|
79
|
+
expectTypeOf(context.state).toMatchTypeOf<GizmoState<HierarchyNodeAny>>();
|
|
80
|
+
expectTypeOf<keyof GizmoCreateOptions<HierarchyNodeAny>>().toEqualTypeOf<
|
|
81
|
+
'camera' | 'features' | 'overlayScene' | 'selection'
|
|
82
|
+
>();
|
|
83
|
+
expectTypeOf<
|
|
84
|
+
Extract<
|
|
85
|
+
keyof GizmoState<HierarchyNodeAny>,
|
|
86
|
+
| 'customPivotX'
|
|
87
|
+
| 'customPivotY'
|
|
88
|
+
| 'mode'
|
|
89
|
+
| 'pivot'
|
|
90
|
+
| 'selectionOutlineColor'
|
|
91
|
+
| 'selectionOutlineEnabled'
|
|
92
|
+
| 'snapRotation'
|
|
93
|
+
| 'snapScale'
|
|
94
|
+
| 'snapTranslate'
|
|
95
|
+
| 'space'
|
|
96
|
+
>
|
|
97
|
+
>().toEqualTypeOf<never>();
|
|
98
|
+
expect(Object.keys(context.state)).toEqual([]);
|
|
99
|
+
expect(getGizmoMode(context.state)).toBe('translate');
|
|
100
|
+
expect(getNodeChildByName(handleRoot, 'GizmoRotateHandle')).not.toBeNull();
|
|
101
|
+
expect(getNodeChildByName(handleRoot, 'GizmoTranslateXHandle')).not.toBeNull();
|
|
102
|
+
expect(getNodeChildByName(handleRoot, 'GizmoTranslateYHandle')).not.toBeNull();
|
|
103
|
+
expect(getNodeChildByName(handleRoot, 'GizmoTranslateXYHandle')).not.toBeNull();
|
|
104
|
+
expect(getNodeChildByName(handleRoot, 'GizmoScaleEastHandle')).not.toBeNull();
|
|
105
|
+
expect(getNodeChildByName(handleRoot, 'GizmoScaleWestHandle')).not.toBeNull();
|
|
106
|
+
expect(getNodeChildByName(handleRoot, 'GizmoScaleNorthHandle')).not.toBeNull();
|
|
107
|
+
expect(getNodeChildByName(handleRoot, 'GizmoScaleSouthHandle')).not.toBeNull();
|
|
108
|
+
expect(getNodeChildByName(handleRoot, 'GizmoScaleNortheastHandle')).not.toBeNull();
|
|
109
|
+
expect(getNodeChildByName(handleRoot, 'GizmoScaleNorthwestHandle')).not.toBeNull();
|
|
110
|
+
expect(getNodeChildByName(handleRoot, 'GizmoScaleSoutheastHandle')).not.toBeNull();
|
|
111
|
+
expect(getNodeChildByName(handleRoot, 'GizmoScaleSouthwestHandle')).not.toBeNull();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('disposeGizmoState', () => {
|
|
116
|
+
it('detaches owned visuals, listeners, and references idempotently', () => {
|
|
117
|
+
const context = createTestContext();
|
|
118
|
+
const root = getGizmoRoot(context.overlay);
|
|
119
|
+
const handle = getGizmoHandle(context.overlay, 'GizmoTranslateXHandle');
|
|
120
|
+
const began = vi.fn();
|
|
121
|
+
connectSignal(getGizmoSignals(context.state).onTransformBegin, began);
|
|
122
|
+
|
|
123
|
+
disposeGizmoState(context.state);
|
|
124
|
+
disposeGizmoState(context.state);
|
|
125
|
+
emitSignal(enableInteractionSignals(handle).onPointerDown, createPointerData(172, 50));
|
|
126
|
+
|
|
127
|
+
expect(getNodeParent(root)).toBeNull();
|
|
128
|
+
expect(began).not.toHaveBeenCalled();
|
|
129
|
+
expect(() => updateGizmo(context.state)).not.toThrow();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('also disposes cleanly after the caller has already detached the overlay root', () => {
|
|
133
|
+
const context = createTestContext();
|
|
134
|
+
const root = getGizmoRoot(context.overlay);
|
|
135
|
+
removeNodeChild(context.overlay.root, root);
|
|
136
|
+
|
|
137
|
+
expect(() => disposeGizmoState(context.state)).not.toThrow();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('getGizmoMode', () => {
|
|
142
|
+
it('returns the configured transform mode', () => {
|
|
143
|
+
expect(getGizmoMode(createTestContext({ mode: 'scale' }).state)).toBe('scale');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('getGizmoSignals', () => {
|
|
148
|
+
it('exposes stable translate, rotate, scale, begin, and end signals', () => {
|
|
149
|
+
const context = createTestContext();
|
|
150
|
+
|
|
151
|
+
expectTypeOf<keyof GizmoSignals>().toEqualTypeOf<
|
|
152
|
+
'onRotate' | 'onScale' | 'onTransformBegin' | 'onTransformEnd' | 'onTranslate'
|
|
153
|
+
>();
|
|
154
|
+
expect(getGizmoSignals(context.state)).toBe(getGizmoSignals(context.state));
|
|
155
|
+
expect(getGizmoSignals(context.state)).toEqual({
|
|
156
|
+
onRotate: expect.any(Object),
|
|
157
|
+
onScale: expect.any(Object),
|
|
158
|
+
onTransformBegin: expect.any(Object),
|
|
159
|
+
onTransformEnd: expect.any(Object),
|
|
160
|
+
onTranslate: expect.any(Object),
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('brackets one active transform command with zero-argument begin and end signals', () => {
|
|
165
|
+
const context = createTestContext();
|
|
166
|
+
const handle = getGizmoHandle(context.overlay, 'GizmoTranslateXHandle');
|
|
167
|
+
const began = vi.fn();
|
|
168
|
+
const ended = vi.fn();
|
|
169
|
+
connectSignal(getGizmoSignals(context.state).onTransformBegin, began);
|
|
170
|
+
connectSignal(getGizmoSignals(context.state).onTransformEnd, ended);
|
|
171
|
+
|
|
172
|
+
emitSignal(enableInteractionSignals(handle).onPointerDown, createPointerData(172, 50));
|
|
173
|
+
emitSignal(enableInteractionSignals(handle).onPointerDown, createPointerData(172, 50));
|
|
174
|
+
emitSignal(enableInteractionSignals(handle).onPointerUp, createPointerData(172, 50, 2));
|
|
175
|
+
expect(began).toHaveBeenCalledOnce();
|
|
176
|
+
expect(began).toHaveBeenCalledWith();
|
|
177
|
+
expect(ended).not.toHaveBeenCalled();
|
|
178
|
+
|
|
179
|
+
emitSignal(enableInteractionSignals(handle).onPointerUp, createPointerData(172, 50));
|
|
180
|
+
emitSignal(enableInteractionSignals(handle).onPointerUp, createPointerData(172, 50));
|
|
181
|
+
expect(ended).toHaveBeenCalledOnce();
|
|
182
|
+
expect(ended).toHaveBeenCalledWith();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('getGizmoSpace', () => {
|
|
187
|
+
it('returns the configured coordinate space', () => {
|
|
188
|
+
expect(getGizmoSpace(createTestContext({ space: 'local' }).state)).toBe('local');
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('overlay and document transform parity', () => {
|
|
193
|
+
const gestures = [
|
|
194
|
+
{
|
|
195
|
+
expected: [4, 3],
|
|
196
|
+
handleName: 'GizmoTranslateXYHandle',
|
|
197
|
+
mode: 'translate',
|
|
198
|
+
worldEnd: { x: 6, y: 0 },
|
|
199
|
+
worldStart: { x: 2, y: -3 },
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
expected: [90],
|
|
203
|
+
handleName: 'GizmoRotateHandle',
|
|
204
|
+
mode: 'rotate',
|
|
205
|
+
worldEnd: { x: 0, y: 10 },
|
|
206
|
+
worldStart: { x: 10, y: 0 },
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
expected: [1.5, 1.5],
|
|
210
|
+
handleName: 'GizmoScaleNortheastHandle',
|
|
211
|
+
mode: 'scale',
|
|
212
|
+
worldEnd: { x: 15, y: 0 },
|
|
213
|
+
worldStart: { x: 10, y: 0 },
|
|
214
|
+
},
|
|
215
|
+
] as const;
|
|
216
|
+
|
|
217
|
+
it.each(gestures)(
|
|
218
|
+
'pins $mode output from one world gesture projected through identity and transformed cameras',
|
|
219
|
+
(gesture) => {
|
|
220
|
+
const identity = runProjectedWorldGesture(gesture, false);
|
|
221
|
+
const transformed = runProjectedWorldGesture(gesture, true);
|
|
222
|
+
|
|
223
|
+
expect(identity.delta).toEqual(gesture.expected);
|
|
224
|
+
expect(transformed.delta).toEqual(gesture.expected);
|
|
225
|
+
expect(transformed.delta).toEqual(identity.delta);
|
|
226
|
+
for (const result of [identity, transformed]) {
|
|
227
|
+
expect(result.began).toHaveBeenCalledOnce();
|
|
228
|
+
expect(result.began).toHaveBeenCalledWith();
|
|
229
|
+
expect(result.ended).toHaveBeenCalledOnce();
|
|
230
|
+
expect(result.ended).toHaveBeenCalledWith();
|
|
231
|
+
expect(result.nodeAfter).toEqual(result.nodeBefore);
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe('setGizmoCustomPivot', () => {
|
|
238
|
+
it('changes the custom world-coordinate pivot', () => {
|
|
239
|
+
const context = createTestContext({ mode: 'scale', pivot: 'custom' });
|
|
240
|
+
setGizmoCustomPivot(context.state, -10, 20);
|
|
241
|
+
updateGizmo(context.state);
|
|
242
|
+
expect(getNodeChildByName(getGizmoRoot(context.overlay), 'GizmoHandleRoot')).toMatchObject({ x: 90, y: 70 });
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('setGizmoMode', () => {
|
|
247
|
+
it('changes the active transform mode', () => {
|
|
248
|
+
const context = createTestContext();
|
|
249
|
+
setGizmoMode(context.state, 'rotate');
|
|
250
|
+
expect(getGizmoMode(context.state)).toBe('rotate');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('leaves an active command bracket intact when the mode is unchanged', () => {
|
|
254
|
+
const context = createTestContext();
|
|
255
|
+
const handle = getGizmoHandle(context.overlay, 'GizmoTranslateXHandle');
|
|
256
|
+
const ended = vi.fn();
|
|
257
|
+
connectSignal(getGizmoSignals(context.state).onTransformEnd, ended);
|
|
258
|
+
emitSignal(enableInteractionSignals(handle).onPointerDown, createPointerData(172, 50));
|
|
259
|
+
|
|
260
|
+
setGizmoMode(context.state, 'translate');
|
|
261
|
+
expect(ended).not.toHaveBeenCalled();
|
|
262
|
+
|
|
263
|
+
emitSignal(enableInteractionSignals(handle).onPointerUp, createPointerData(172, 50));
|
|
264
|
+
expect(ended).toHaveBeenCalledOnce();
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe('setGizmoPivot', () => {
|
|
269
|
+
it('selects the rotate and scale pivot mode', () => {
|
|
270
|
+
const context = createTestContext({ customPivotX: 12, customPivotY: 18, mode: 'rotate' });
|
|
271
|
+
setGizmoPivot(context.state, 'custom');
|
|
272
|
+
updateGizmo(context.state);
|
|
273
|
+
expect(getNodeChildByName(getGizmoRoot(context.overlay), 'GizmoHandleRoot')).toMatchObject({ x: 112, y: 68 });
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe('setGizmoSelectionOutlineColor', () => {
|
|
278
|
+
it('accepts packed RGBA outline color', () => {
|
|
279
|
+
const context = createTestContext();
|
|
280
|
+
setGizmoSelectionOutlineColor(context.state, 0x12345680);
|
|
281
|
+
updateGizmo(context.state);
|
|
282
|
+
const outline = getNodeChildByName(getGizmoRoot(context.overlay), 'GizmoSelectionOutline') as Shape;
|
|
283
|
+
expect(outline.data.commands).toEqual(expect.arrayContaining([0x123456, 0x80 / 0xff]));
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe('setGizmoSelectionOutlineEnabled', () => {
|
|
288
|
+
it('toggles the selection outline independently of the tool mode', () => {
|
|
289
|
+
const context = createTestContext({ mode: 'none' });
|
|
290
|
+
setGizmoSelectionOutlineEnabled(context.state, false);
|
|
291
|
+
updateGizmo(context.state);
|
|
292
|
+
expect(getNodeChildByName(getGizmoRoot(context.overlay), 'GizmoSelectionOutline')).toMatchObject({
|
|
293
|
+
visible: false,
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe('setGizmoSnapRotation', () => {
|
|
299
|
+
it('sets degree snapping and accepts zero as off', () => {
|
|
300
|
+
const context = createTestContext({ mode: 'rotate' });
|
|
301
|
+
setGizmoSnapRotation(context.state, 15);
|
|
302
|
+
expectRotationDelta(context, 22, 15);
|
|
303
|
+
setGizmoSnapRotation(context.state, 0);
|
|
304
|
+
expectRotationDelta(context, 22, 22);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe('setGizmoSnapScale', () => {
|
|
309
|
+
it('sets multiplier snapping and accepts zero as off', () => {
|
|
310
|
+
const context = createTestContext({ mode: 'scale' });
|
|
311
|
+
setGizmoSnapScale(context.state, 0.25);
|
|
312
|
+
expectScaleDelta(context, 1.4, 1.5);
|
|
313
|
+
setGizmoSnapScale(context.state, 0);
|
|
314
|
+
expectScaleDelta(context, 1.4, 1.4);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe('setGizmoSnapTranslate', () => {
|
|
319
|
+
it('sets world-unit translation snapping and accepts zero as off', () => {
|
|
320
|
+
const context = createTestContext();
|
|
321
|
+
setGizmoSnapTranslate(context.state, 10);
|
|
322
|
+
expectTranslationDelta(context, 13, 10);
|
|
323
|
+
setGizmoSnapTranslate(context.state, 0);
|
|
324
|
+
expectTranslationDelta(context, 13, 13);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe('setGizmoSpace', () => {
|
|
329
|
+
it('changes the coordinate space', () => {
|
|
330
|
+
const context = createTestContext();
|
|
331
|
+
setGizmoSpace(context.state, 'local');
|
|
332
|
+
expect(getGizmoSpace(context.state)).toBe('local');
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe('updateGizmo', () => {
|
|
337
|
+
it('combines multi-selection bounds, outlines them, and keeps handles in screen-space units', () => {
|
|
338
|
+
const first = createTestNode();
|
|
339
|
+
const second = createTestNode();
|
|
340
|
+
const context = createTestContext({}, [first, second]);
|
|
341
|
+
context.featuresByNode.set(first, createTestNodeFeatures(0, 0, 10, 10));
|
|
342
|
+
context.featuresByNode.set(second, createTestNodeFeatures(20, 20, 10, 10));
|
|
343
|
+
updateGizmo(context.state);
|
|
344
|
+
const root = getGizmoRoot(context.overlay);
|
|
345
|
+
const handleRoot = getNodeChildByName(root, 'GizmoHandleRoot') as Node2D;
|
|
346
|
+
const outline = getNodeChildByName(root, 'GizmoSelectionOutline') as Shape;
|
|
347
|
+
|
|
348
|
+
expect(handleRoot.x).toBeCloseTo(115, 8);
|
|
349
|
+
expect(handleRoot.y).toBeCloseTo(65, 8);
|
|
350
|
+
expect(outline.data.commands).toEqual(expect.arrayContaining(['moveTo', 2, 100, 50, 'lineTo', 2, 130, 50]));
|
|
351
|
+
context.camera.zoom = 2;
|
|
352
|
+
updateGizmo(context.state);
|
|
353
|
+
expect(handleRoot.scaleX).toBe(1);
|
|
354
|
+
expect(handleRoot.scaleY).toBe(1);
|
|
355
|
+
expect(getGizmoHandle(context.overlay, 'GizmoTranslateXHandle').data.commands).toContain(72);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('places edge and corner scale handles on the combined selection bounds', () => {
|
|
359
|
+
const context = createTestContext({ mode: 'scale' });
|
|
360
|
+
const node = getSelectedTestNode(context);
|
|
361
|
+
context.featuresByNode.set(node, createTestNodeFeatures(-20, -10, 40, 20));
|
|
362
|
+
|
|
363
|
+
updateGizmo(context.state);
|
|
364
|
+
|
|
365
|
+
expect(getGizmoHandle(context.overlay, 'GizmoScaleEastHandle')).toMatchObject({ x: 20, y: 0 });
|
|
366
|
+
expect(getGizmoHandle(context.overlay, 'GizmoScaleWestHandle')).toMatchObject({ x: -20, y: 0 });
|
|
367
|
+
expect(getGizmoHandle(context.overlay, 'GizmoScaleNorthHandle')).toMatchObject({ x: 0, y: -10 });
|
|
368
|
+
expect(getGizmoHandle(context.overlay, 'GizmoScaleSouthHandle')).toMatchObject({ x: 0, y: 10 });
|
|
369
|
+
expect(getGizmoHandle(context.overlay, 'GizmoScaleNortheastHandle')).toMatchObject({ x: 20, y: -10 });
|
|
370
|
+
expect(getGizmoHandle(context.overlay, 'GizmoScaleSouthwestHandle')).toMatchObject({ x: -20, y: 10 });
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('supports center, active-origin, and custom world pivots', () => {
|
|
374
|
+
const first = createTestNode();
|
|
375
|
+
const second = createTestNode();
|
|
376
|
+
const context = createTestContext({}, [first, second]);
|
|
377
|
+
context.featuresByNode.set(first, createTestNodeFeatures(0, 0, 10, 10, 2, 3));
|
|
378
|
+
context.featuresByNode.set(second, createTestNodeFeatures(20, 20, 10, 10, 40, 50));
|
|
379
|
+
const handleRoot = getNodeChildByName(getGizmoRoot(context.overlay), 'GizmoHandleRoot') as Node2D;
|
|
380
|
+
|
|
381
|
+
setGizmoMode(context.state, 'rotate');
|
|
382
|
+
setGizmoPivot(context.state, 'origin');
|
|
383
|
+
updateGizmo(context.state);
|
|
384
|
+
expect(handleRoot.x).toBe(140);
|
|
385
|
+
expect(handleRoot.y).toBe(100);
|
|
386
|
+
setGizmoPivot(context.state, 'custom');
|
|
387
|
+
setGizmoCustomPivot(context.state, -25, 12);
|
|
388
|
+
updateGizmo(context.state);
|
|
389
|
+
expect(handleRoot.x).toBe(75);
|
|
390
|
+
expect(handleRoot.y).toBe(62);
|
|
391
|
+
setGizmoPivot(context.state, 'center');
|
|
392
|
+
updateGizmo(context.state);
|
|
393
|
+
expect(handleRoot.x).toBe(115);
|
|
394
|
+
expect(handleRoot.y).toBe(65);
|
|
395
|
+
|
|
396
|
+
setGizmoMode(context.state, 'translate');
|
|
397
|
+
setGizmoPivot(context.state, 'custom');
|
|
398
|
+
updateGizmo(context.state);
|
|
399
|
+
expect(handleRoot.x).toBe(115);
|
|
400
|
+
expect(handleRoot.y).toBe(65);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('orients world and local handles against camera rotation', () => {
|
|
404
|
+
const node = createTestNode();
|
|
405
|
+
const context = createTestContext({}, [node]);
|
|
406
|
+
context.featuresByNode.set(node, createTestNodeFeatures(0, 0, 10, 10, 0, 0, 30));
|
|
407
|
+
context.camera.rotation = 10 * (Math.PI / 180);
|
|
408
|
+
const handleRoot = getNodeChildByName(getGizmoRoot(context.overlay), 'GizmoHandleRoot') as Node2D;
|
|
409
|
+
|
|
410
|
+
setGizmoSpace(context.state, 'world');
|
|
411
|
+
updateGizmo(context.state);
|
|
412
|
+
expect(handleRoot.rotation).toBe(0);
|
|
413
|
+
setGizmoSpace(context.state, 'local');
|
|
414
|
+
updateGizmo(context.state);
|
|
415
|
+
expect(handleRoot.rotation).toBeCloseTo(20, 8);
|
|
416
|
+
expect(getGizmoSpace(context.state)).toBe('local');
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('keeps world-space handles screen-aligned while emitting document-world deltas', () => {
|
|
420
|
+
const context = createTestContext();
|
|
421
|
+
context.camera.rotation = Math.PI * 0.5;
|
|
422
|
+
updateGizmo(context.state);
|
|
423
|
+
const translated = vi.fn();
|
|
424
|
+
connectSignal(getGizmoSignals(context.state).onTranslate, translated);
|
|
425
|
+
const handle = getGizmoHandle(context.overlay, 'GizmoTranslateXHandle');
|
|
426
|
+
|
|
427
|
+
emitSignal(enableInteractionSignals(handle).onPointerDown, createPointerData(172, 50));
|
|
428
|
+
emitSignal(enableInteractionSignals(handle).onPointerMove, createPointerData(182, 50));
|
|
429
|
+
|
|
430
|
+
expect(translated.mock.calls.at(-1)?.[0]).toBeCloseTo(0, 8);
|
|
431
|
+
expect(translated.mock.calls.at(-1)?.[1]).toBeCloseTo(10, 8);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('keeps the outline available in none mode and cancels the active command bracket', () => {
|
|
435
|
+
const context = createTestContext();
|
|
436
|
+
const root = getGizmoRoot(context.overlay);
|
|
437
|
+
const handleRoot = getNodeChildByName(root, 'GizmoHandleRoot') as Node2D;
|
|
438
|
+
const outline = getNodeChildByName(root, 'GizmoSelectionOutline') as Shape;
|
|
439
|
+
const handle = getGizmoHandle(context.overlay, 'GizmoTranslateXHandle');
|
|
440
|
+
const ended = vi.fn();
|
|
441
|
+
connectSignal(getGizmoSignals(context.state).onTransformEnd, ended);
|
|
442
|
+
emitSignal(enableInteractionSignals(handle).onPointerDown, createPointerData(172, 50));
|
|
443
|
+
setGizmoMode(context.state, 'none');
|
|
444
|
+
|
|
445
|
+
updateGizmo(context.state);
|
|
446
|
+
|
|
447
|
+
expect(root.visible).toBe(true);
|
|
448
|
+
expect(handleRoot.visible).toBe(false);
|
|
449
|
+
expect(outline.visible).toBe(true);
|
|
450
|
+
expect(ended).toHaveBeenCalledOnce();
|
|
451
|
+
expect(ended).toHaveBeenCalledWith();
|
|
452
|
+
setGizmoMode(context.state, 'translate');
|
|
453
|
+
selectAllNodes(context.selection, []);
|
|
454
|
+
updateGizmo(context.state);
|
|
455
|
+
expect(root.visible).toBe(false);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('supports runtime selection outline visibility and packed RGBA color', () => {
|
|
459
|
+
const context = createTestContext();
|
|
460
|
+
const outline = getNodeChildByName(getGizmoRoot(context.overlay), 'GizmoSelectionOutline') as Shape;
|
|
461
|
+
|
|
462
|
+
setGizmoSelectionOutlineEnabled(context.state, false);
|
|
463
|
+
updateGizmo(context.state);
|
|
464
|
+
expect(outline.visible).toBe(false);
|
|
465
|
+
|
|
466
|
+
setGizmoSelectionOutlineColor(context.state, 0x12345680);
|
|
467
|
+
setGizmoSelectionOutlineEnabled(context.state, true);
|
|
468
|
+
updateGizmo(context.state);
|
|
469
|
+
expect(outline.visible).toBe(true);
|
|
470
|
+
expect(outline.data.commands).toEqual(expect.arrayContaining([0x123456, 0x80 / 0xff]));
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('cancels an active bracket when switching directly between transform modes', () => {
|
|
474
|
+
const context = createTestContext();
|
|
475
|
+
const ended = vi.fn();
|
|
476
|
+
connectSignal(getGizmoSignals(context.state).onTransformEnd, ended);
|
|
477
|
+
const handle = getGizmoHandle(context.overlay, 'GizmoTranslateXHandle');
|
|
478
|
+
emitSignal(enableInteractionSignals(handle).onPointerDown, createPointerData(172, 50));
|
|
479
|
+
setGizmoMode(context.state, 'rotate');
|
|
480
|
+
|
|
481
|
+
updateGizmo(context.state);
|
|
482
|
+
|
|
483
|
+
expect(ended).toHaveBeenCalledOnce();
|
|
484
|
+
expect(ended).toHaveBeenCalledWith();
|
|
485
|
+
expect(getGizmoMode(context.state)).toBe('rotate');
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('skips selected nodes whose feature adapter cannot provide bounds', () => {
|
|
489
|
+
const unavailable = createTestNode();
|
|
490
|
+
const available = createTestNode();
|
|
491
|
+
const context = createTestContext({}, [unavailable, available]);
|
|
492
|
+
context.featuresByNode.delete(unavailable);
|
|
493
|
+
context.featuresByNode.set(available, createTestNodeFeatures(20, 30, 10, 20));
|
|
494
|
+
|
|
495
|
+
updateGizmo(context.state);
|
|
496
|
+
|
|
497
|
+
const handleRoot = getNodeChildByName(getGizmoRoot(context.overlay), 'GizmoHandleRoot') as Node2D;
|
|
498
|
+
expect(handleRoot.x).toBe(125);
|
|
499
|
+
expect(handleRoot.y).toBe(90);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('emits snapped translation deltas constrained to world and local axes', () => {
|
|
503
|
+
const node = createTestNode();
|
|
504
|
+
const context = createTestContext({}, [node]);
|
|
505
|
+
context.featuresByNode.set(node, createTestNodeFeatures(0, 0, 0, 0, 0, 0, 90));
|
|
506
|
+
setGizmoSnapTranslate(context.state, 10);
|
|
507
|
+
updateGizmo(context.state);
|
|
508
|
+
const translated = vi.fn();
|
|
509
|
+
connectSignal(getGizmoSignals(context.state).onTranslate, translated);
|
|
510
|
+
const handle = getGizmoHandle(context.overlay, 'GizmoTranslateXHandle');
|
|
511
|
+
|
|
512
|
+
emitSignal(enableInteractionSignals(handle).onPointerDown, createPointerData(172, 50));
|
|
513
|
+
emitSignal(enableInteractionSignals(handle).onPointerMove, createPointerData(185, 74));
|
|
514
|
+
expect(translated).toHaveBeenLastCalledWith(10, 0);
|
|
515
|
+
emitSignal(enableInteractionSignals(handle).onPointerUp, createPointerData(185, 74));
|
|
516
|
+
setGizmoSpace(context.state, 'local');
|
|
517
|
+
updateGizmo(context.state);
|
|
518
|
+
emitSignal(enableInteractionSignals(handle).onPointerDown, createPointerData(100, 122));
|
|
519
|
+
emitSignal(enableInteractionSignals(handle).onPointerMove, createPointerData(100, 135));
|
|
520
|
+
expect(translated.mock.calls.at(-1)?.[0]).toBeCloseTo(0, 8);
|
|
521
|
+
expect(translated.mock.calls.at(-1)?.[1]).toBeCloseTo(10, 8);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it('supports free and y-axis translation without snapping', () => {
|
|
525
|
+
const context = createTestContext();
|
|
526
|
+
setGizmoSnapTranslate(context.state, 0);
|
|
527
|
+
const translated = vi.fn();
|
|
528
|
+
connectSignal(getGizmoSignals(context.state).onTranslate, translated);
|
|
529
|
+
const yHandle = getGizmoHandle(context.overlay, 'GizmoTranslateYHandle');
|
|
530
|
+
const xyHandle = getGizmoHandle(context.overlay, 'GizmoTranslateXYHandle');
|
|
531
|
+
|
|
532
|
+
emitSignal(enableInteractionSignals(yHandle).onPointerDown, createPointerData(100, -22));
|
|
533
|
+
emitSignal(enableInteractionSignals(yHandle).onPointerMove, createPointerData(113, -34));
|
|
534
|
+
expect(translated).toHaveBeenLastCalledWith(0, -12);
|
|
535
|
+
emitSignal(enableInteractionSignals(yHandle).onPointerUp, createPointerData(113, -34));
|
|
536
|
+
emitSignal(enableInteractionSignals(xyHandle).onPointerDown, createPointerData(100, 50));
|
|
537
|
+
emitSignal(enableInteractionSignals(xyHandle).onPointerMove, createPointerData(108, 61));
|
|
538
|
+
expect(translated).toHaveBeenLastCalledWith(8, 11);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('emits rotation deltas in degrees only and applies degree snapping', () => {
|
|
542
|
+
const context = createTestContext({ mode: 'rotate' });
|
|
543
|
+
setGizmoSnapRotation(context.state, 15);
|
|
544
|
+
const rotated = vi.fn();
|
|
545
|
+
connectSignal(getGizmoSignals(context.state).onRotate, rotated);
|
|
546
|
+
const handle = getGizmoHandle(context.overlay, 'GizmoRotateHandle');
|
|
547
|
+
|
|
548
|
+
emitSignal(enableInteractionSignals(handle).onPointerDown, createPointerData(152, 50));
|
|
549
|
+
emitSignal(enableInteractionSignals(handle).onPointerMove, createPointerData(100, 102));
|
|
550
|
+
|
|
551
|
+
expect(rotated).toHaveBeenCalledWith(90);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('normalizes rotation across the negative/positive angle seam before emitting degrees', () => {
|
|
555
|
+
const context = createTestContext({ mode: 'rotate' });
|
|
556
|
+
setGizmoSnapRotation(context.state, 0);
|
|
557
|
+
const rotated = vi.fn();
|
|
558
|
+
connectSignal(getGizmoSignals(context.state).onRotate, rotated);
|
|
559
|
+
const handle = getGizmoHandle(context.overlay, 'GizmoRotateHandle');
|
|
560
|
+
const radius = 52;
|
|
561
|
+
const start = 170 * (Math.PI / 180);
|
|
562
|
+
const current = -170 * (Math.PI / 180);
|
|
563
|
+
|
|
564
|
+
emitSignal(
|
|
565
|
+
enableInteractionSignals(handle).onPointerDown,
|
|
566
|
+
createPointerData(100 + Math.cos(start) * radius, 50 + Math.sin(start) * radius),
|
|
567
|
+
);
|
|
568
|
+
emitSignal(
|
|
569
|
+
enableInteractionSignals(handle).onPointerMove,
|
|
570
|
+
createPointerData(100 + Math.cos(current) * radius, 50 + Math.sin(current) * radius),
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
expect(rotated).toHaveBeenCalledWith(20);
|
|
574
|
+
|
|
575
|
+
emitSignal(enableInteractionSignals(handle).onPointerUp, createPointerData(100, 50));
|
|
576
|
+
emitSignal(
|
|
577
|
+
enableInteractionSignals(handle).onPointerDown,
|
|
578
|
+
createPointerData(100 + Math.cos(current) * radius, 50 + Math.sin(current) * radius),
|
|
579
|
+
);
|
|
580
|
+
emitSignal(
|
|
581
|
+
enableInteractionSignals(handle).onPointerMove,
|
|
582
|
+
createPointerData(100 + Math.cos(start) * radius, 50 + Math.sin(start) * radius),
|
|
583
|
+
);
|
|
584
|
+
expect(rotated).toHaveBeenLastCalledWith(-20);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('emits snapped axis and uniform scale factors from the drag origin', () => {
|
|
588
|
+
const context = createTestContext({ mode: 'scale' });
|
|
589
|
+
setGizmoSnapScale(context.state, 0.25);
|
|
590
|
+
const scaled = vi.fn();
|
|
591
|
+
connectSignal(getGizmoSignals(context.state).onScale, scaled);
|
|
592
|
+
const xHandle = getGizmoHandle(context.overlay, 'GizmoScaleEastHandle');
|
|
593
|
+
const yHandle = getGizmoHandle(context.overlay, 'GizmoScaleNorthHandle');
|
|
594
|
+
const xyHandle = getGizmoHandle(context.overlay, 'GizmoScaleNortheastHandle');
|
|
595
|
+
|
|
596
|
+
emitSignal(enableInteractionSignals(xHandle).onPointerDown, createPointerData(172, 50));
|
|
597
|
+
emitSignal(enableInteractionSignals(xHandle).onPointerMove, createPointerData(208, 50));
|
|
598
|
+
expect(scaled).toHaveBeenLastCalledWith(1.5, 1);
|
|
599
|
+
emitSignal(enableInteractionSignals(xHandle).onPointerUp, createPointerData(208, 50));
|
|
600
|
+
emitSignal(enableInteractionSignals(xHandle).onPointerDown, createPointerData(172, 50));
|
|
601
|
+
emitSignal(enableInteractionSignals(xHandle).onPointerMove, createPointerData(208, 50, 1, true));
|
|
602
|
+
expect(scaled).toHaveBeenLastCalledWith(1.5, 1.5);
|
|
603
|
+
emitSignal(enableInteractionSignals(xHandle).onPointerUp, createPointerData(208, 50));
|
|
604
|
+
emitSignal(enableInteractionSignals(yHandle).onPointerDown, createPointerData(100, -22));
|
|
605
|
+
emitSignal(enableInteractionSignals(yHandle).onPointerMove, createPointerData(100, -58, 1, true));
|
|
606
|
+
expect(scaled).toHaveBeenLastCalledWith(1.5, 1.5);
|
|
607
|
+
emitSignal(enableInteractionSignals(yHandle).onPointerUp, createPointerData(100, -58));
|
|
608
|
+
emitSignal(enableInteractionSignals(xyHandle).onPointerDown, createPointerData(107, 57));
|
|
609
|
+
emitSignal(enableInteractionSignals(xyHandle).onPointerMove, createPointerData(114, 64));
|
|
610
|
+
expect(scaled).toHaveBeenLastCalledWith(2, 2);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('supports y-axis scale and ignores pointer traffic from another identity', () => {
|
|
614
|
+
const context = createTestContext({ mode: 'scale' });
|
|
615
|
+
setGizmoSnapScale(context.state, 0);
|
|
616
|
+
const scaled = vi.fn();
|
|
617
|
+
connectSignal(getGizmoSignals(context.state).onScale, scaled);
|
|
618
|
+
const handle = getGizmoHandle(context.overlay, 'GizmoScaleNorthHandle');
|
|
619
|
+
|
|
620
|
+
emitSignal(enableInteractionSignals(handle).onPointerDown, createPointerData(100, -22));
|
|
621
|
+
emitSignal(enableInteractionSignals(handle).onPointerMove, createPointerData(100, -58, 2));
|
|
622
|
+
expect(scaled).not.toHaveBeenCalled();
|
|
623
|
+
emitSignal(enableInteractionSignals(handle).onPointerMove, createPointerData(100, -58));
|
|
624
|
+
expect(scaled).toHaveBeenCalledWith(1, 1.5);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it('keeps neutral scale factors for degenerate drag origins', () => {
|
|
628
|
+
const context = createTestContext({ mode: 'scale' });
|
|
629
|
+
const scaled = vi.fn();
|
|
630
|
+
connectSignal(getGizmoSignals(context.state).onScale, scaled);
|
|
631
|
+
const xHandle = getGizmoHandle(context.overlay, 'GizmoScaleEastHandle');
|
|
632
|
+
const yHandle = getGizmoHandle(context.overlay, 'GizmoScaleNorthHandle');
|
|
633
|
+
const xyHandle = getGizmoHandle(context.overlay, 'GizmoScaleNortheastHandle');
|
|
634
|
+
|
|
635
|
+
for (const handle of [xHandle, yHandle, xyHandle]) {
|
|
636
|
+
emitSignal(enableInteractionSignals(handle).onPointerDown, createPointerData(100, 50));
|
|
637
|
+
emitSignal(enableInteractionSignals(handle).onPointerMove, createPointerData(120, 70));
|
|
638
|
+
expect(scaled).toHaveBeenLastCalledWith(1, 1);
|
|
639
|
+
emitSignal(enableInteractionSignals(handle).onPointerUp, createPointerData(120, 70));
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
function expectRotationDelta(context: Readonly<TestContext>, degrees: number, expected: number): void {
|
|
645
|
+
const rotated = vi.fn();
|
|
646
|
+
connectSignal(getGizmoSignals(context.state).onRotate, rotated);
|
|
647
|
+
const handle = getGizmoHandle(context.overlay, 'GizmoRotateHandle');
|
|
648
|
+
const radians = degrees * (Math.PI / 180);
|
|
649
|
+
emitSignal(enableInteractionSignals(handle).onPointerDown, createPointerData(152, 50));
|
|
650
|
+
emitSignal(
|
|
651
|
+
enableInteractionSignals(handle).onPointerMove,
|
|
652
|
+
createPointerData(100 + Math.cos(radians) * 52, 50 + Math.sin(radians) * 52),
|
|
653
|
+
);
|
|
654
|
+
emitSignal(enableInteractionSignals(handle).onPointerUp, createPointerData(100, 50));
|
|
655
|
+
expect(rotated.mock.calls.at(-1)?.[0]).toBeCloseTo(expected, 8);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
interface ProjectedWorldGesture {
|
|
659
|
+
readonly expected: readonly number[];
|
|
660
|
+
readonly handleName: string;
|
|
661
|
+
readonly mode: Exclude<GizmoMode, 'none'>;
|
|
662
|
+
readonly worldEnd: Readonly<Vector2Like>;
|
|
663
|
+
readonly worldStart: Readonly<Vector2Like>;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function runProjectedWorldGesture(gesture: ProjectedWorldGesture, transformedCamera: boolean) {
|
|
667
|
+
const node = createShape({
|
|
668
|
+
pivotX: 2,
|
|
669
|
+
pivotY: -3,
|
|
670
|
+
rotation: 17,
|
|
671
|
+
scaleX: 1.25,
|
|
672
|
+
scaleY: 0.75,
|
|
673
|
+
skewX: 4,
|
|
674
|
+
skewY: -6,
|
|
675
|
+
x: 12,
|
|
676
|
+
y: -8,
|
|
677
|
+
});
|
|
678
|
+
const context = createTestContext({ mode: gesture.mode }, [node]);
|
|
679
|
+
if (transformedCamera) {
|
|
680
|
+
context.camera.x = 12;
|
|
681
|
+
context.camera.y = -7;
|
|
682
|
+
context.camera.zoom = 2.25;
|
|
683
|
+
context.camera.rotation = 35 * (Math.PI / 180);
|
|
684
|
+
updateGizmo(context.state);
|
|
685
|
+
}
|
|
686
|
+
const signals = getGizmoSignals(context.state);
|
|
687
|
+
const began = vi.fn();
|
|
688
|
+
const ended = vi.fn();
|
|
689
|
+
const deltas: number[][] = [];
|
|
690
|
+
connectSignal(signals.onTransformBegin, began);
|
|
691
|
+
connectSignal(signals.onTransformEnd, ended);
|
|
692
|
+
if (gesture.mode === 'translate') {
|
|
693
|
+
connectSignal(signals.onTranslate, (x, y) => deltas.push([roundTransformValue(x), roundTransformValue(y)]));
|
|
694
|
+
} else if (gesture.mode === 'rotate') {
|
|
695
|
+
connectSignal(signals.onRotate, (degrees) => deltas.push([roundTransformValue(degrees)]));
|
|
696
|
+
} else {
|
|
697
|
+
connectSignal(signals.onScale, (x, y) => deltas.push([roundTransformValue(x), roundTransformValue(y)]));
|
|
698
|
+
}
|
|
699
|
+
const nodeBefore = snapshotNodeTransform(node);
|
|
700
|
+
const handle = getGizmoHandle(context.overlay, gesture.handleName);
|
|
701
|
+
|
|
702
|
+
emitSignal(
|
|
703
|
+
enableInteractionSignals(handle).onPointerDown,
|
|
704
|
+
createProjectedPointerData(context.camera, gesture.worldStart),
|
|
705
|
+
);
|
|
706
|
+
emitSignal(
|
|
707
|
+
enableInteractionSignals(handle).onPointerMove,
|
|
708
|
+
createProjectedPointerData(context.camera, gesture.worldEnd),
|
|
709
|
+
);
|
|
710
|
+
emitSignal(
|
|
711
|
+
enableInteractionSignals(handle).onPointerUp,
|
|
712
|
+
createProjectedPointerData(context.camera, gesture.worldEnd),
|
|
713
|
+
);
|
|
714
|
+
|
|
715
|
+
const result = {
|
|
716
|
+
began,
|
|
717
|
+
delta: deltas.at(-1),
|
|
718
|
+
ended,
|
|
719
|
+
nodeAfter: snapshotNodeTransform(node),
|
|
720
|
+
nodeBefore,
|
|
721
|
+
};
|
|
722
|
+
disposeGizmoState(context.state);
|
|
723
|
+
return result;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function createProjectedPointerData(
|
|
727
|
+
camera: Readonly<ReturnType<typeof createCamera2D>>,
|
|
728
|
+
world: Readonly<Vector2Like>,
|
|
729
|
+
): PointerEventData {
|
|
730
|
+
const overlay = { x: 0, y: 0 };
|
|
731
|
+
projectCamera2DPoint(camera, world.x, world.y, overlay);
|
|
732
|
+
return { ...createPointerData(overlay.x, overlay.y), worldX: world.x, worldY: world.y };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function snapshotNodeTransform(node: Readonly<Node2D>) {
|
|
736
|
+
return {
|
|
737
|
+
pivotX: node.pivotX,
|
|
738
|
+
pivotY: node.pivotY,
|
|
739
|
+
rotation: node.rotation,
|
|
740
|
+
scaleX: node.scaleX,
|
|
741
|
+
scaleY: node.scaleY,
|
|
742
|
+
skewX: node.skewX,
|
|
743
|
+
skewY: node.skewY,
|
|
744
|
+
x: node.x,
|
|
745
|
+
y: node.y,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function roundTransformValue(value: number): number {
|
|
750
|
+
return Math.round(value * 1e8) / 1e8;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function expectScaleDelta(context: Readonly<TestContext>, scale: number, expected: number): void {
|
|
754
|
+
const scaled = vi.fn();
|
|
755
|
+
connectSignal(getGizmoSignals(context.state).onScale, scaled);
|
|
756
|
+
const handle = getGizmoHandle(context.overlay, 'GizmoScaleEastHandle');
|
|
757
|
+
emitSignal(enableInteractionSignals(handle).onPointerDown, createPointerData(110, 50));
|
|
758
|
+
emitSignal(enableInteractionSignals(handle).onPointerMove, createPointerData(100 + 10 * scale, 50));
|
|
759
|
+
emitSignal(enableInteractionSignals(handle).onPointerUp, createPointerData(100, 50));
|
|
760
|
+
expect(scaled.mock.calls.at(-1)?.[0]).toBeCloseTo(expected, 8);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function expectTranslationDelta(context: Readonly<TestContext>, deltaX: number, expected: number): void {
|
|
764
|
+
const translated = vi.fn();
|
|
765
|
+
connectSignal(getGizmoSignals(context.state).onTranslate, translated);
|
|
766
|
+
const handle = getGizmoHandle(context.overlay, 'GizmoTranslateXHandle');
|
|
767
|
+
emitSignal(enableInteractionSignals(handle).onPointerDown, createPointerData(172, 50));
|
|
768
|
+
emitSignal(enableInteractionSignals(handle).onPointerMove, createPointerData(172 + deltaX, 50));
|
|
769
|
+
emitSignal(enableInteractionSignals(handle).onPointerUp, createPointerData(100, 50));
|
|
770
|
+
expect(translated.mock.calls.at(-1)?.[0]).toBeCloseTo(expected, 8);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function createPointerData(x: number, y: number, pointerId: number = 1, shiftKey: boolean = false): PointerEventData {
|
|
774
|
+
return {
|
|
775
|
+
altKey: false,
|
|
776
|
+
button: 0,
|
|
777
|
+
buttons: 1,
|
|
778
|
+
ctrlKey: false,
|
|
779
|
+
currentTarget: null,
|
|
780
|
+
deltaX: 0,
|
|
781
|
+
deltaY: 0,
|
|
782
|
+
localX: x,
|
|
783
|
+
localY: y,
|
|
784
|
+
metaKey: false,
|
|
785
|
+
pointerId,
|
|
786
|
+
pointerType: 'mouse',
|
|
787
|
+
shiftKey,
|
|
788
|
+
target: null,
|
|
789
|
+
worldX: x,
|
|
790
|
+
worldY: y,
|
|
791
|
+
x,
|
|
792
|
+
y,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function createTestContext(
|
|
797
|
+
options: Readonly<TestGizmoOptions> = {},
|
|
798
|
+
nodes: readonly HierarchyNodeAny[] = [createTestNode()],
|
|
799
|
+
): TestContext {
|
|
800
|
+
const camera = createCamera2D(200, 100);
|
|
801
|
+
const overlay = createScene2D({ scene2dHeight: 100, scene2dWidth: 200 });
|
|
802
|
+
const selection = createSelectionState<HierarchyNodeAny>();
|
|
803
|
+
const featuresByNode = new Map<HierarchyNodeAny, TestNodeFeatures>();
|
|
804
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
805
|
+
featuresByNode.set(nodes[i], createTestNodeFeatures(0, 0, 0, 0));
|
|
806
|
+
}
|
|
807
|
+
selectAllNodes(selection, nodes);
|
|
808
|
+
const features: GizmoNode2DFeatures<HierarchyNodeAny> = {
|
|
809
|
+
getWorldBoundsRectangle: (out: Rectangle, node: Readonly<HierarchyNodeAny>) => {
|
|
810
|
+
const value = featuresByNode.get(node);
|
|
811
|
+
if (value === undefined) return false;
|
|
812
|
+
out.x = value.bounds.x;
|
|
813
|
+
out.y = value.bounds.y;
|
|
814
|
+
out.width = value.bounds.width;
|
|
815
|
+
out.height = value.bounds.height;
|
|
816
|
+
return true;
|
|
817
|
+
},
|
|
818
|
+
getWorldOrigin: (out: Vector2Like, node: Readonly<HierarchyNodeAny>) => {
|
|
819
|
+
const value = featuresByNode.get(node)!;
|
|
820
|
+
out.x = value.originX;
|
|
821
|
+
out.y = value.originY;
|
|
822
|
+
},
|
|
823
|
+
getWorldRotation: (node: Readonly<HierarchyNodeAny>) => featuresByNode.get(node)!.rotation,
|
|
824
|
+
};
|
|
825
|
+
const state = createGizmoState({ camera, features, overlayScene: overlay, selection });
|
|
826
|
+
applyTestGizmoOptions(state, options);
|
|
827
|
+
updateGizmo(state);
|
|
828
|
+
return { camera, featuresByNode, overlay, selection, state };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function applyTestGizmoOptions(state: GizmoState<HierarchyNodeAny>, options: Readonly<TestGizmoOptions>): void {
|
|
832
|
+
if (options.customPivotX !== undefined || options.customPivotY !== undefined)
|
|
833
|
+
setGizmoCustomPivot(state, options.customPivotX ?? 0, options.customPivotY ?? 0);
|
|
834
|
+
if (options.mode !== undefined) setGizmoMode(state, options.mode);
|
|
835
|
+
if (options.pivot !== undefined) setGizmoPivot(state, options.pivot);
|
|
836
|
+
if (options.selectionOutlineColor !== undefined) setGizmoSelectionOutlineColor(state, options.selectionOutlineColor);
|
|
837
|
+
if (options.selectionOutlineEnabled !== undefined)
|
|
838
|
+
setGizmoSelectionOutlineEnabled(state, options.selectionOutlineEnabled);
|
|
839
|
+
if (options.snapRotation !== undefined) setGizmoSnapRotation(state, options.snapRotation);
|
|
840
|
+
if (options.snapScale !== undefined) setGizmoSnapScale(state, options.snapScale);
|
|
841
|
+
if (options.snapTranslate !== undefined) setGizmoSnapTranslate(state, options.snapTranslate);
|
|
842
|
+
if (options.space !== undefined) setGizmoSpace(state, options.space);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function createTestNode(): HierarchyNodeAny {
|
|
846
|
+
return createNode('GizmoTestNode');
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function getSelectedTestNode(context: Readonly<TestContext>): HierarchyNodeAny {
|
|
850
|
+
return [...context.featuresByNode.keys()][0];
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function createTestNodeFeatures(
|
|
854
|
+
x: number,
|
|
855
|
+
y: number,
|
|
856
|
+
width: number,
|
|
857
|
+
height: number,
|
|
858
|
+
originX: number = x + width * 0.5,
|
|
859
|
+
originY: number = y + height * 0.5,
|
|
860
|
+
rotation: number = 0,
|
|
861
|
+
): TestNodeFeatures {
|
|
862
|
+
return { bounds: createRectangle(x, y, width, height), originX, originY, rotation };
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function getGizmoHandle(overlay: Scene2D, name: string): Shape {
|
|
866
|
+
const handleRoot = getNodeChildByName(getGizmoRoot(overlay), 'GizmoHandleRoot') as Node2D;
|
|
867
|
+
return getNodeChildByName(handleRoot, name) as Shape;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function getGizmoRoot(overlay: Scene2D): Node2D {
|
|
871
|
+
return getNodeChildByName(overlay.root, 'GizmoRoot') as Node2D;
|
|
872
|
+
}
|