jsgui3-server 0.0.135 → 0.0.136
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/examples/controls/14d) window, canvas globe/ARCBALL-README.md +146 -0
- package/examples/controls/14d) window, canvas globe/EarthGlobeRenderer.js +37 -113
- package/examples/controls/14d) window, canvas globe/arcball-drag-behaviour.js +250 -0
- package/examples/controls/14d) window, canvas globe/arcball-drag-behaviour.test.js +141 -0
- package/examples/controls/14d) window, canvas globe/drag-behaviour-base.js +70 -0
- package/examples/controls/14d) window, canvas globe/drag-controller.js +181 -0
- package/examples/controls/14d) window, canvas globe/math.test.js +281 -0
- package/package.json +3 -3
- package/resources/local-server-info-resource.js +1 -1
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Arcball Drag Behavior - Modularized Implementation
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
This directory contains a **working, tested implementation** of arcball drag behavior that has been modularized into reusable components. The implementation is isolated from UI concerns and thoroughly tested with pure mathematical tests.
|
|
6
|
+
|
|
7
|
+
## Architecture
|
|
8
|
+
|
|
9
|
+
### Core Components
|
|
10
|
+
|
|
11
|
+
1. **`drag-behaviour-base.js`** - Base class for drag behaviors
|
|
12
|
+
- Handles common drag state (dragging flag, timing)
|
|
13
|
+
- Provides lifecycle hooks: `on_drag_start()`, `on_drag_move()`, `on_drag_end()`
|
|
14
|
+
- Foundation for any drag behavior, not specific to arcball
|
|
15
|
+
|
|
16
|
+
2. **`arcball-drag-behaviour.js`** - Arcball-specific drag behavior
|
|
17
|
+
- Extends `DragBehaviourBase`
|
|
18
|
+
- Implements the sophisticated arcball math:
|
|
19
|
+
- `screen_to_arcball()` - Projects 2D screen coords to 3D sphere
|
|
20
|
+
- Quaternion-based rotation composition
|
|
21
|
+
- Angular velocity calculation
|
|
22
|
+
- Exponential inertia decay
|
|
23
|
+
- **This contains the core math that makes arcball rotation work correctly**
|
|
24
|
+
|
|
25
|
+
3. **`drag-controller.js`** - UI event wiring
|
|
26
|
+
- Handles DOM pointer events (down, move, up, cancel)
|
|
27
|
+
- Manages pointer capture
|
|
28
|
+
- Transforms screen coordinates to normalized space
|
|
29
|
+
- Delegates to behavior instance
|
|
30
|
+
- Completely separate from the math
|
|
31
|
+
|
|
32
|
+
4. **`math.js`** - Quaternion and matrix utilities
|
|
33
|
+
- Pure math functions for 3D rotations
|
|
34
|
+
- Used by arcball behavior
|
|
35
|
+
- Independently tested
|
|
36
|
+
|
|
37
|
+
### Integration
|
|
38
|
+
|
|
39
|
+
**`EarthGlobeRenderer.js`** uses these components:
|
|
40
|
+
```javascript
|
|
41
|
+
this.drag_behaviour = new ArcballDragBehaviour({...});
|
|
42
|
+
this.drag_controller = new DragController(canvas, this.drag_behaviour, {...});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Tests
|
|
46
|
+
|
|
47
|
+
### Pure Mathematical Tests (No Browser APIs)
|
|
48
|
+
|
|
49
|
+
**`math.test.js`** (21 tests) - Tests quaternion & matrix math:
|
|
50
|
+
- Quaternion operations (identity, normalize, multiply, from axis-angle, from vectors)
|
|
51
|
+
- Matrix operations (from quaternion, transpose)
|
|
52
|
+
- Integration tests (vector rotation, composition)
|
|
53
|
+
|
|
54
|
+
**`arcball-drag-behaviour.test.js`** (15 tests) - Tests arcball behavior:
|
|
55
|
+
- Screen-to-arcball projection
|
|
56
|
+
- Drag rotation mathematics
|
|
57
|
+
- Vector length preservation
|
|
58
|
+
- Quaternion normalization
|
|
59
|
+
- Matrix orthogonality
|
|
60
|
+
- Edge cases (zero drag, tiny movements, large movements)
|
|
61
|
+
- Angular velocity and inertia decay
|
|
62
|
+
|
|
63
|
+
### Running Tests
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
cd "examples/controls/14d) window, canvas globe"
|
|
67
|
+
npx mocha "*.test.js"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
All 36 tests pass ✓
|
|
71
|
+
|
|
72
|
+
## Key Arcball Math
|
|
73
|
+
|
|
74
|
+
### 1. Screen to Arcball Projection
|
|
75
|
+
|
|
76
|
+
Maps 2D normalized coordinates `(x, y)` to a point on a unit sphere:
|
|
77
|
+
|
|
78
|
+
- **Inside unit circle**: `z = sqrt(1 - x² - y²)` (sphere equation)
|
|
79
|
+
- **Outside unit circle**: Project onto edge with `z = 0`
|
|
80
|
+
- Result is always a unit vector on the sphere surface
|
|
81
|
+
|
|
82
|
+
### 2. Rotation Composition
|
|
83
|
+
|
|
84
|
+
When dragging from point `v0` to `v1` on the arcball:
|
|
85
|
+
|
|
86
|
+
1. Compute quaternion `dq` that rotates `v0` to `v1`
|
|
87
|
+
2. Compose with existing rotation: `q = dq * q`
|
|
88
|
+
3. Normalize to maintain unit quaternion
|
|
89
|
+
4. Update rotation matrices from quaternion
|
|
90
|
+
|
|
91
|
+
### 3. Inertia
|
|
92
|
+
|
|
93
|
+
- Calculate angular velocity `ω` from drag movement
|
|
94
|
+
- Extract rotation axis from quaternion
|
|
95
|
+
- Apply exponential decay: `ω(t) = ω₀ * e^(-friction * t)`
|
|
96
|
+
|
|
97
|
+
## Integration into Other Projects
|
|
98
|
+
|
|
99
|
+
To use this arcball implementation in another project:
|
|
100
|
+
|
|
101
|
+
### Option 1: Copy the Behavior Classes
|
|
102
|
+
|
|
103
|
+
Copy these files:
|
|
104
|
+
- `drag-behaviour-base.js`
|
|
105
|
+
- `arcball-drag-behaviour.js`
|
|
106
|
+
- `math.js`
|
|
107
|
+
|
|
108
|
+
Then wire up UI events yourself, or adapt `drag-controller.js`.
|
|
109
|
+
|
|
110
|
+
### Option 2: Use as Reference
|
|
111
|
+
|
|
112
|
+
The **tests** (`*.test.js`) define the expected behavior mathematically. You can:
|
|
113
|
+
1. Copy the test files
|
|
114
|
+
2. Run them against your own implementation
|
|
115
|
+
3. Fix any failures to match the correct behavior
|
|
116
|
+
|
|
117
|
+
### Validation
|
|
118
|
+
|
|
119
|
+
The tests verify:
|
|
120
|
+
- ✓ Proper sphere projection for all input coordinates
|
|
121
|
+
- ✓ Unit quaternions maintained through all operations
|
|
122
|
+
- ✓ Orthogonal rotation matrices
|
|
123
|
+
- ✓ Vector lengths preserved after rotation
|
|
124
|
+
- ✓ Correct rotation axes for horizontal/vertical drags
|
|
125
|
+
- ✓ Stability with edge cases (zero movement, tiny movements, huge movements)
|
|
126
|
+
- ✓ No NaN values under any conditions
|
|
127
|
+
|
|
128
|
+
## Notes
|
|
129
|
+
|
|
130
|
+
- All tests are **pure math** - no `requestAnimationFrame`, no DOM, no browser APIs
|
|
131
|
+
- Tests can run in Node.js
|
|
132
|
+
- The math is isolated and portable
|
|
133
|
+
- Following `snake_case` convention per AGENTS.md guidelines
|
|
134
|
+
|
|
135
|
+
## Success Criteria
|
|
136
|
+
|
|
137
|
+
A correct arcball implementation should:
|
|
138
|
+
1. Pass all 36 tests
|
|
139
|
+
2. Feel smooth and intuitive when dragging
|
|
140
|
+
3. Never produce NaN or invalid rotations
|
|
141
|
+
4. Handle edge cases gracefully
|
|
142
|
+
5. Maintain mathematical invariants (unit quaternions, orthogonal matrices)
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
**This implementation works correctly.** The tests validate the math. Use these tests to verify implementations in other projects.
|
|
@@ -1,12 +1,4 @@
|
|
|
1
|
-
const {
|
|
2
|
-
qIdentity,
|
|
3
|
-
qNormalize,
|
|
4
|
-
qMul,
|
|
5
|
-
qFromAxisAngle,
|
|
6
|
-
qFromVectors,
|
|
7
|
-
mat3FromQuat,
|
|
8
|
-
mat3Transpose
|
|
9
|
-
} = require('./math');
|
|
1
|
+
const { mat3FromQuat, mat3Transpose } = require('./math');
|
|
10
2
|
const {
|
|
11
3
|
RenderingPipeline,
|
|
12
4
|
TransformStage,
|
|
@@ -17,6 +9,8 @@ const {
|
|
|
17
9
|
ComposeStage,
|
|
18
10
|
HUDStage
|
|
19
11
|
} = require('./RenderingPipeline');
|
|
12
|
+
const ArcballDragBehaviour = require('./arcball-drag-behaviour');
|
|
13
|
+
const DragController = require('./drag-controller');
|
|
20
14
|
|
|
21
15
|
// Approximate simplified continent outlines (lat, lon in degrees)
|
|
22
16
|
const CONTINENT_OUTLINES = {
|
|
@@ -72,17 +66,28 @@ class EarthGlobeRenderer {
|
|
|
72
66
|
this.ctx = canvas.getContext('2d');
|
|
73
67
|
this.opts = JSON.parse(JSON.stringify(DEFAULT_OPTIONS));
|
|
74
68
|
this.setOptions(opts);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
69
|
+
|
|
70
|
+
// Initialize arcball drag behavior
|
|
71
|
+
this.drag_behaviour = new ArcballDragBehaviour({
|
|
72
|
+
inertia_friction: this.opts.inertiaFriction,
|
|
73
|
+
inertia_min_speed: this.opts.inertiaMinSpeed,
|
|
74
|
+
on_rotation_change: (q, R, Rt) => {
|
|
75
|
+
this.q = q;
|
|
76
|
+
this.R = R;
|
|
77
|
+
this.Rt = Rt;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Access quaternion and matrices from behavior
|
|
82
|
+
this.q = this.drag_behaviour.q;
|
|
83
|
+
this.R = this.drag_behaviour.R;
|
|
84
|
+
this.Rt = this.drag_behaviour.Rt;
|
|
85
|
+
|
|
78
86
|
this.sun = this._normVec([1,0,0.25]);
|
|
79
87
|
this.pipelineEnabled = true;
|
|
80
88
|
this._fps = 0;
|
|
81
89
|
this._fpsFrames = 0;
|
|
82
90
|
this._fpsLastUpdate = performance.now();
|
|
83
|
-
this._axis = [0,1,0];
|
|
84
|
-
this._omega = 0;
|
|
85
|
-
this._v0 = [0,0,1];
|
|
86
91
|
this.tex = { albedo:null, water:null, ice:null };
|
|
87
92
|
this._samp = { r:0,g:0,b:0 };
|
|
88
93
|
this._initLUTs();
|
|
@@ -99,7 +104,23 @@ class EarthGlobeRenderer {
|
|
|
99
104
|
new HUDStage(this)
|
|
100
105
|
]
|
|
101
106
|
);
|
|
102
|
-
|
|
107
|
+
|
|
108
|
+
// Initialize drag controller
|
|
109
|
+
this.drag_controller = new DragController(
|
|
110
|
+
this.canvas,
|
|
111
|
+
this.drag_behaviour,
|
|
112
|
+
{
|
|
113
|
+
padding: this.opts.padding,
|
|
114
|
+
on_interactive_frame: () => this.render(true),
|
|
115
|
+
on_drag_end: () => {
|
|
116
|
+
// Delay final high-quality render to allow inertia to start
|
|
117
|
+
setTimeout(() => {
|
|
118
|
+
if (!this.drag_behaviour.omega) this.render(false);
|
|
119
|
+
}, 120);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
);
|
|
123
|
+
|
|
103
124
|
this.resize();
|
|
104
125
|
this.render(false);
|
|
105
126
|
}
|
|
@@ -203,99 +224,6 @@ class EarthGlobeRenderer {
|
|
|
203
224
|
}
|
|
204
225
|
}
|
|
205
226
|
|
|
206
|
-
// -------------- Interaction (arcball + inertia) --------------
|
|
207
|
-
_attachPointerHandlers() {
|
|
208
|
-
const el = this.canvas;
|
|
209
|
-
if (getComputedStyle(el).touchAction !== 'none') el.style.touchAction='none';
|
|
210
|
-
const onDown = (ev) => {
|
|
211
|
-
this._dragging = true;
|
|
212
|
-
this._v0 = this._screenToArcball(ev.clientX, ev.clientY);
|
|
213
|
-
this._omega = 0;
|
|
214
|
-
this._lastTime = performance.now();
|
|
215
|
-
try { el.setPointerCapture(ev.pointerId); } catch {}
|
|
216
|
-
};
|
|
217
|
-
const onMove = (ev) => {
|
|
218
|
-
if (!this._dragging) return;
|
|
219
|
-
const v1 = this._screenToArcball(ev.clientX, ev.clientY);
|
|
220
|
-
const dq = qFromVectors(this._v0, v1);
|
|
221
|
-
this.q = qNormalize(qMul(dq, this.q));
|
|
222
|
-
this._v0 = v1;
|
|
223
|
-
const now = performance.now();
|
|
224
|
-
const dt = Math.max(1, now - this._lastTime) / 1000;
|
|
225
|
-
this._lastTime = now;
|
|
226
|
-
let angle = 2 * Math.acos(Math.max(-1, Math.min(1, dq[3])));
|
|
227
|
-
if (angle > Math.PI) angle = 2 * Math.PI - angle;
|
|
228
|
-
const s = Math.sqrt(1 - dq[3] * dq[3]);
|
|
229
|
-
if (s < 1e-6) {
|
|
230
|
-
this._axis[0] = 0; this._axis[1] = 1; this._axis[2] = 0;
|
|
231
|
-
} else {
|
|
232
|
-
this._axis[0] = dq[0] / s;
|
|
233
|
-
this._axis[1] = dq[1] / s;
|
|
234
|
-
this._axis[2] = dq[2] / s;
|
|
235
|
-
}
|
|
236
|
-
this._omega = angle / dt;
|
|
237
|
-
this._updateRot();
|
|
238
|
-
this._requestInteractiveFrame();
|
|
239
|
-
};
|
|
240
|
-
const onUp = (ev) => {
|
|
241
|
-
this._dragging = false;
|
|
242
|
-
try { el.releasePointerCapture(ev.pointerId); } catch {}
|
|
243
|
-
this._startInertia();
|
|
244
|
-
setTimeout(() => { if (!this._omega) this.render(false); }, 120);
|
|
245
|
-
};
|
|
246
|
-
el.addEventListener('pointerdown', onDown);
|
|
247
|
-
el.addEventListener('pointermove', onMove);
|
|
248
|
-
try { el.addEventListener('pointerrawupdate', onMove); } catch {}
|
|
249
|
-
el.addEventListener('pointerup', onUp);
|
|
250
|
-
el.addEventListener('pointercancel', onUp);
|
|
251
|
-
this._ptr={onDown,onMove,onUp};
|
|
252
|
-
}
|
|
253
|
-
_startInertia() {
|
|
254
|
-
if (this._omega <= this.opts.inertiaMinSpeed) return;
|
|
255
|
-
const friction = this.opts.inertiaFriction; let last=performance.now();
|
|
256
|
-
const step = (now) => {
|
|
257
|
-
const dt = Math.max(1, now - last) / 1000;
|
|
258
|
-
last = now;
|
|
259
|
-
this._omega *= Math.exp(-friction * dt);
|
|
260
|
-
if (this._omega <= this.opts.inertiaMinSpeed) {
|
|
261
|
-
this._omega = 0; this._raf = 0; return;
|
|
262
|
-
}
|
|
263
|
-
const dq = qFromAxisAngle(
|
|
264
|
-
this._axis[0],
|
|
265
|
-
this._axis[1],
|
|
266
|
-
this._axis[2],
|
|
267
|
-
this._omega * dt
|
|
268
|
-
);
|
|
269
|
-
this.q = qNormalize(qMul(dq, this.q));
|
|
270
|
-
this._updateRot();
|
|
271
|
-
this._requestInteractiveFrame();
|
|
272
|
-
this._raf = requestAnimationFrame(step);
|
|
273
|
-
};
|
|
274
|
-
if (this._raf) cancelAnimationFrame(this._raf); this._raf=requestAnimationFrame(step);
|
|
275
|
-
}
|
|
276
|
-
_requestInteractiveFrame() {
|
|
277
|
-
if (this._pendingInteractive) return;
|
|
278
|
-
this._pendingInteractive = true;
|
|
279
|
-
requestAnimationFrame(() => {
|
|
280
|
-
this._pendingInteractive = false;
|
|
281
|
-
this.render(true);
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
_screenToArcball(clientX, clientY) {
|
|
286
|
-
const rect = this.canvas.getBoundingClientRect();
|
|
287
|
-
const cx = rect.left + rect.width / 2;
|
|
288
|
-
const cy = rect.top + rect.height / 2;
|
|
289
|
-
const pad = this.opts.padding;
|
|
290
|
-
const r = Math.max(1, Math.min(rect.width, rect.height) / 2 - pad);
|
|
291
|
-
const x = (clientX - cx) / r;
|
|
292
|
-
const y = (cy - clientY) / r;
|
|
293
|
-
const d2 = x * x + y * y;
|
|
294
|
-
if (d2 <= 1) return [x, y, Math.sqrt(1 - d2)];
|
|
295
|
-
const inv = 1 / Math.sqrt(d2);
|
|
296
|
-
return [x * inv, y * inv, 0];
|
|
297
|
-
}
|
|
298
|
-
|
|
299
227
|
// -------------- LUTs & shading utilities --------------
|
|
300
228
|
_initLUTs() {
|
|
301
229
|
if (this._lutsInit) return;
|
|
@@ -398,10 +326,6 @@ class EarthGlobeRenderer {
|
|
|
398
326
|
return img;
|
|
399
327
|
}
|
|
400
328
|
|
|
401
|
-
_updateRot() {
|
|
402
|
-
this.R = mat3FromQuat(this.q);
|
|
403
|
-
this.Rt = mat3Transpose(this.R);
|
|
404
|
-
}
|
|
405
329
|
_normVec(v) {
|
|
406
330
|
const n = Math.hypot(v[0], v[1], v[2]) || 1;
|
|
407
331
|
return [v[0]/n, v[1]/n, v[2]/n];
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DragBehaviourBase = require('./drag-behaviour-base');
|
|
4
|
+
const {
|
|
5
|
+
qIdentity,
|
|
6
|
+
qNormalize,
|
|
7
|
+
qMul,
|
|
8
|
+
qFromAxisAngle,
|
|
9
|
+
qFromVectors,
|
|
10
|
+
mat3FromQuat,
|
|
11
|
+
mat3Transpose
|
|
12
|
+
} = require('./math');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* ArcballDragBehaviour - Implements virtual trackball/arcball rotation
|
|
16
|
+
*
|
|
17
|
+
* This class handles the sophisticated math for arcball-style rotation:
|
|
18
|
+
* - Converting 2D screen coordinates to 3D arcball surface positions
|
|
19
|
+
* - Computing quaternion rotations from drag movements
|
|
20
|
+
* - Managing inertia with axis and angular velocity
|
|
21
|
+
*
|
|
22
|
+
* The arcball technique allows intuitive 3D rotation by mapping 2D mouse
|
|
23
|
+
* movements to rotations on a virtual sphere.
|
|
24
|
+
*/
|
|
25
|
+
class ArcballDragBehaviour extends DragBehaviourBase {
|
|
26
|
+
constructor(options = {}) {
|
|
27
|
+
super();
|
|
28
|
+
|
|
29
|
+
// Quaternion representing current rotation
|
|
30
|
+
this.q = qIdentity();
|
|
31
|
+
|
|
32
|
+
// Rotation matrices (derived from quaternion)
|
|
33
|
+
this.R = mat3FromQuat(this.q);
|
|
34
|
+
this.Rt = mat3Transpose(this.R);
|
|
35
|
+
|
|
36
|
+
// Previous arcball position (3D vector on unit sphere)
|
|
37
|
+
this.v0 = [0, 0, 1];
|
|
38
|
+
|
|
39
|
+
// Inertia state
|
|
40
|
+
this.omega = 0; // Angular velocity (rad/s)
|
|
41
|
+
this.axis = [0, 1, 0]; // Rotation axis
|
|
42
|
+
this.inertia_raf = 0; // RequestAnimationFrame handle
|
|
43
|
+
|
|
44
|
+
// Options
|
|
45
|
+
this.inertia_friction = options.inertia_friction || 1.7;
|
|
46
|
+
this.inertia_min_speed = options.inertia_min_speed || 0.25;
|
|
47
|
+
|
|
48
|
+
// Callbacks
|
|
49
|
+
this.on_rotation_change = options.on_rotation_change || null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Convert 2D screen position to 3D point on arcball sphere
|
|
54
|
+
*
|
|
55
|
+
* Maps (x, y) in normalized coordinates [-1, 1] to a point on or inside
|
|
56
|
+
* a unit sphere. Points outside the sphere are projected onto it.
|
|
57
|
+
*
|
|
58
|
+
* @param {number} x - Normalized X coordinate [-1, 1]
|
|
59
|
+
* @param {number} y - Normalized Y coordinate [-1, 1]
|
|
60
|
+
* @returns {Array<number>} [x, y, z] point on unit sphere
|
|
61
|
+
*/
|
|
62
|
+
screen_to_arcball(x, y) {
|
|
63
|
+
const d2 = x * x + y * y;
|
|
64
|
+
|
|
65
|
+
if (d2 <= 1) {
|
|
66
|
+
// Inside sphere: compute Z using sphere equation x² + y² + z² = 1
|
|
67
|
+
return [x, y, Math.sqrt(1 - d2)];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Outside sphere: normalize to edge
|
|
71
|
+
const inv = 1 / Math.sqrt(d2);
|
|
72
|
+
return [x * inv, y * inv, 0];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Start drag operation
|
|
77
|
+
* @param {Object} position - {x, y} normalized coordinates
|
|
78
|
+
*/
|
|
79
|
+
on_drag_start(position) {
|
|
80
|
+
super.on_drag_start(position);
|
|
81
|
+
this.v0 = this.screen_to_arcball(position.x, position.y);
|
|
82
|
+
this.omega = 0;
|
|
83
|
+
this.stop_inertia();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Update rotation during drag
|
|
88
|
+
* @param {Object} position - {x, y} normalized coordinates
|
|
89
|
+
*/
|
|
90
|
+
on_drag_move(position) {
|
|
91
|
+
const delta_time = super.on_drag_move(position);
|
|
92
|
+
if (!this.is_dragging) return;
|
|
93
|
+
|
|
94
|
+
// Current arcball position
|
|
95
|
+
const v1 = this.screen_to_arcball(position.x, position.y);
|
|
96
|
+
|
|
97
|
+
// Compute rotation quaternion from v0 to v1
|
|
98
|
+
const dq = qFromVectors(this.v0, v1);
|
|
99
|
+
|
|
100
|
+
// Compose with existing rotation: q = dq * q
|
|
101
|
+
this.q = qNormalize(qMul(dq, this.q));
|
|
102
|
+
|
|
103
|
+
// Update for next frame
|
|
104
|
+
this.v0 = v1;
|
|
105
|
+
|
|
106
|
+
// Calculate angular velocity for inertia
|
|
107
|
+
let angle = 2 * Math.acos(Math.max(-1, Math.min(1, dq[3])));
|
|
108
|
+
if (angle > Math.PI) angle = 2 * Math.PI - angle;
|
|
109
|
+
|
|
110
|
+
// Extract rotation axis from quaternion
|
|
111
|
+
const s = Math.sqrt(1 - dq[3] * dq[3]);
|
|
112
|
+
if (s < 1e-6) {
|
|
113
|
+
this.axis[0] = 0;
|
|
114
|
+
this.axis[1] = 1;
|
|
115
|
+
this.axis[2] = 0;
|
|
116
|
+
} else {
|
|
117
|
+
this.axis[0] = dq[0] / s;
|
|
118
|
+
this.axis[1] = dq[1] / s;
|
|
119
|
+
this.axis[2] = dq[2] / s;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
this.omega = angle / delta_time;
|
|
123
|
+
|
|
124
|
+
// Update rotation matrices
|
|
125
|
+
this._update_rotation_matrices();
|
|
126
|
+
|
|
127
|
+
// Notify callback
|
|
128
|
+
if (this.on_rotation_change) {
|
|
129
|
+
this.on_rotation_change(this.q, this.R, this.Rt);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* End drag operation and potentially start inertia
|
|
135
|
+
*/
|
|
136
|
+
on_drag_end() {
|
|
137
|
+
super.on_drag_end();
|
|
138
|
+
this.start_inertia();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Start inertia animation with exponential decay
|
|
143
|
+
* @returns {boolean} True if inertia was started
|
|
144
|
+
*/
|
|
145
|
+
start_inertia() {
|
|
146
|
+
if (this.omega <= this.inertia_min_speed) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Check if requestAnimationFrame is available (not in Node.js)
|
|
151
|
+
if (typeof requestAnimationFrame === 'undefined') {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const friction = this.inertia_friction;
|
|
156
|
+
let last = performance.now();
|
|
157
|
+
|
|
158
|
+
const step = (now) => {
|
|
159
|
+
const dt = Math.max(1, now - last) / 1000;
|
|
160
|
+
last = now;
|
|
161
|
+
|
|
162
|
+
// Exponential decay
|
|
163
|
+
this.omega *= Math.exp(-friction * dt);
|
|
164
|
+
|
|
165
|
+
if (this.omega <= this.inertia_min_speed) {
|
|
166
|
+
this.omega = 0;
|
|
167
|
+
this.inertia_raf = 0;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Apply incremental rotation
|
|
172
|
+
const dq = qFromAxisAngle(
|
|
173
|
+
this.axis[0],
|
|
174
|
+
this.axis[1],
|
|
175
|
+
this.axis[2],
|
|
176
|
+
this.omega * dt
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
this.q = qNormalize(qMul(dq, this.q));
|
|
180
|
+
this._update_rotation_matrices();
|
|
181
|
+
|
|
182
|
+
// Notify callback
|
|
183
|
+
if (this.on_rotation_change) {
|
|
184
|
+
this.on_rotation_change(this.q, this.R, this.Rt);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
this.inertia_raf = requestAnimationFrame(step);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
if (this.inertia_raf) cancelAnimationFrame(this.inertia_raf);
|
|
191
|
+
this.inertia_raf = requestAnimationFrame(step);
|
|
192
|
+
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Stop inertia animation
|
|
198
|
+
*/
|
|
199
|
+
stop_inertia() {
|
|
200
|
+
if (this.inertia_raf) {
|
|
201
|
+
cancelAnimationFrame(this.inertia_raf);
|
|
202
|
+
this.inertia_raf = 0;
|
|
203
|
+
}
|
|
204
|
+
this.omega = 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Update rotation matrices from quaternion
|
|
209
|
+
* @private
|
|
210
|
+
*/
|
|
211
|
+
_update_rotation_matrices() {
|
|
212
|
+
this.R = mat3FromQuat(this.q);
|
|
213
|
+
this.Rt = mat3Transpose(this.R);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Set quaternion directly (useful for initialization)
|
|
218
|
+
* @param {Array<number>} q - Quaternion [x, y, z, w]
|
|
219
|
+
*/
|
|
220
|
+
set_quaternion(q) {
|
|
221
|
+
this.q = q.slice(); // Copy
|
|
222
|
+
this._update_rotation_matrices();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Get current quaternion
|
|
227
|
+
* @returns {Array<number>} Quaternion [x, y, z, w]
|
|
228
|
+
*/
|
|
229
|
+
get_quaternion() {
|
|
230
|
+
return this.q.slice();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Get rotation matrix
|
|
235
|
+
* @returns {Array<number>} 3x3 matrix (column-major)
|
|
236
|
+
*/
|
|
237
|
+
get_rotation_matrix() {
|
|
238
|
+
return this.R.slice();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Get transposed rotation matrix
|
|
243
|
+
* @returns {Array<number>} 3x3 matrix (column-major)
|
|
244
|
+
*/
|
|
245
|
+
get_rotation_matrix_transpose() {
|
|
246
|
+
return this.Rt.slice();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
module.exports = ArcballDragBehaviour;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const assert = require('assert');
|
|
3
|
+
const ArcballDragBehaviour = require('./arcball-drag-behaviour');
|
|
4
|
+
|
|
5
|
+
describe('Arcball Core Math', function() {
|
|
6
|
+
const EPS = 1e-9;
|
|
7
|
+
const approx = (a, b, e=EPS) => Math.abs(a - b) < e;
|
|
8
|
+
const approx_arr = (a, b, e=EPS) => a.length === b.length && a.every((v, i) => approx(v, b[i], e));
|
|
9
|
+
const apply_mat = (m, v) => [m[0]*v[0]+m[3]*v[1]+m[6]*v[2], m[1]*v[0]+m[4]*v[1]+m[7]*v[2], m[2]*v[0]+m[5]*v[1]+m[8]*v[2]];
|
|
10
|
+
const vec_len = (v) => Math.sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2]);
|
|
11
|
+
const q_len = (q) => Math.sqrt(q[0]*q[0] + q[1]*q[1] + q[2]*q[2] + q[3]*q[3]);
|
|
12
|
+
|
|
13
|
+
describe('screen_to_arcball', function() {
|
|
14
|
+
let b;
|
|
15
|
+
beforeEach(() => b = new ArcballDragBehaviour());
|
|
16
|
+
|
|
17
|
+
it('center to front [0,0,1]', () => {
|
|
18
|
+
assert(approx_arr(b.screen_to_arcball(0, 0), [0, 0, 1]));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('all points on unit sphere', () => {
|
|
22
|
+
[[0,0], [0.5,0], [0,0.5], [0.7,0.7], [1,0], [2,0], [5,5]].forEach(([x,y]) => {
|
|
23
|
+
assert(approx(vec_len(b.screen_to_arcball(x, y)), 1.0));
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('inside point satisfies sphere equation', () => {
|
|
28
|
+
const p = b.screen_to_arcball(0.5, 0.5);
|
|
29
|
+
assert(approx(p[0], 0.5) && approx(p[1], 0.5) && approx(p[2], Math.sqrt(0.5)));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('outside points have z0', () => {
|
|
33
|
+
const p = b.screen_to_arcball(3, 4);
|
|
34
|
+
assert(approx(p[2], 0, 1e-6));
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('drag rotation', function() {
|
|
39
|
+
let b;
|
|
40
|
+
beforeEach(() => b = new ArcballDragBehaviour());
|
|
41
|
+
|
|
42
|
+
it('horizontal drag preserves Y', () => {
|
|
43
|
+
b.on_drag_start({x:0, y:0});
|
|
44
|
+
b.on_drag_move({x:0.5, y:0});
|
|
45
|
+
const rot = apply_mat(b.get_rotation_matrix(), [1,0,0]);
|
|
46
|
+
assert(approx(rot[1], 0, 1e-6));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('vertical drag preserves X', () => {
|
|
50
|
+
b.on_drag_start({x:0, y:0});
|
|
51
|
+
b.on_drag_move({x:0, y:0.5});
|
|
52
|
+
const rot = apply_mat(b.get_rotation_matrix(), [0,1,0]);
|
|
53
|
+
assert(approx(rot[0], 0, 1e-6));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('preserves vector lengths', () => {
|
|
57
|
+
b.on_drag_start({x:0, y:0});
|
|
58
|
+
b.on_drag_move({x:0.6, y:0.4});
|
|
59
|
+
const R = b.get_rotation_matrix();
|
|
60
|
+
[[1,0,0], [0,1,0], [0,0,1]].forEach(v => {
|
|
61
|
+
assert(approx(vec_len(v), vec_len(apply_mat(R, v))));
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('maintains unit quaternion after multiple drags', () => {
|
|
66
|
+
b.on_drag_start({x:0, y:0});
|
|
67
|
+
b.on_drag_move({x:0.2, y:0});
|
|
68
|
+
assert(approx(q_len(b.get_quaternion()), 1.0), 'After 1st drag');
|
|
69
|
+
b.on_drag_move({x:0.4, y:0.1});
|
|
70
|
+
assert(approx(q_len(b.get_quaternion()), 1.0), 'After 2nd drag');
|
|
71
|
+
b.on_drag_move({x:0.6, y:0.2});
|
|
72
|
+
assert(approx(q_len(b.get_quaternion()), 1.0), 'After 3rd drag');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('produces orthogonal matrices', () => {
|
|
76
|
+
b.on_drag_start({x:0, y:0});
|
|
77
|
+
b.on_drag_move({x:0.7, y:0.5});
|
|
78
|
+
const R = b.get_rotation_matrix();
|
|
79
|
+
const Rt = b.get_rotation_matrix_transpose();
|
|
80
|
+
const I = [];
|
|
81
|
+
for (let i=0; i<3; i++) for (let j=0; j<3; j++) {
|
|
82
|
+
let sum = 0;
|
|
83
|
+
for (let k=0; k<3; k++) sum += R[k*3+i] * Rt[j*3+k];
|
|
84
|
+
I.push(sum);
|
|
85
|
+
}
|
|
86
|
+
assert(approx_arr(I, [1,0,0,0,1,0,0,0,1]));
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('edge cases', function() {
|
|
91
|
+
let b;
|
|
92
|
+
beforeEach(() => b = new ArcballDragBehaviour());
|
|
93
|
+
|
|
94
|
+
it('zero drag no rotation', () => {
|
|
95
|
+
b.on_drag_start({x:0.3, y:0.3});
|
|
96
|
+
b.on_drag_move({x:0.3, y:0.3});
|
|
97
|
+
assert(approx_arr(b.get_quaternion(), [0,0,0,1], 1e-6));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('tiny drag no NaN', () => {
|
|
101
|
+
b.on_drag_start({x:0, y:0});
|
|
102
|
+
b.on_drag_move({x:1e-8, y:0});
|
|
103
|
+
const q = b.get_quaternion();
|
|
104
|
+
assert(!q.some(isNaN), 'No NaN values');
|
|
105
|
+
assert(approx(q_len(q), 1.0), 'Unit quaternion');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('large drag stable', () => {
|
|
109
|
+
b.on_drag_start({x:0, y:0});
|
|
110
|
+
b.on_drag_move({x:10, y:10});
|
|
111
|
+
const q = b.get_quaternion();
|
|
112
|
+
assert(!q.some(isNaN), 'No NaN values');
|
|
113
|
+
assert(approx(q_len(q), 1.0), 'Unit quaternion');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('rapid direction changes', () => {
|
|
117
|
+
b.on_drag_start({x:0, y:0});
|
|
118
|
+
[{ x:0.5, y:0}, {x:0, y:0.5}, {x:-0.5, y:0}, {x:0, y:-0.5}].forEach(pos => {
|
|
119
|
+
b.on_drag_move(pos);
|
|
120
|
+
const q = b.get_quaternion();
|
|
121
|
+
assert(!q.some(isNaN) && approx(q_len(q), 1.0));
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('angular velocity', () => {
|
|
127
|
+
it('drag produces omega', () => {
|
|
128
|
+
const b = new ArcballDragBehaviour();
|
|
129
|
+
b.on_drag_start({x:0, y:0});
|
|
130
|
+
b.on_drag_move({x:0.5, y:0});
|
|
131
|
+
assert(b.omega > 0 && b.axis.length === 3);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('exponential decay math', () => {
|
|
135
|
+
let omega = 2.0;
|
|
136
|
+
const dt = 0.016, friction = 1.7;
|
|
137
|
+
for (let i=0; i<10; i++) omega *= Math.exp(-friction * dt);
|
|
138
|
+
assert(omega < 2.0 && omega > 0 && approx(omega, 2.0 * Math.exp(-friction * dt * 10)));
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DragBehaviourBase - Base class for drag behaviors
|
|
5
|
+
*
|
|
6
|
+
* Provides common functionality for drag interactions including:
|
|
7
|
+
* - Drag state tracking (is_dragging flag)
|
|
8
|
+
* - Timing for velocity calculations
|
|
9
|
+
* - Lifecycle hooks for subclasses to implement
|
|
10
|
+
*
|
|
11
|
+
* Subclasses should override:
|
|
12
|
+
* - on_drag_start(position) - called when drag begins
|
|
13
|
+
* - on_drag_move(position, delta_time) - called during drag
|
|
14
|
+
* - on_drag_end() - called when drag ends
|
|
15
|
+
* - start_inertia() - called to initiate inertia animation (optional)
|
|
16
|
+
*/
|
|
17
|
+
class DragBehaviourBase {
|
|
18
|
+
constructor() {
|
|
19
|
+
this.is_dragging = false;
|
|
20
|
+
this.last_time = 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Called when drag operation starts
|
|
25
|
+
* @param {Object} position - Position info (e.g., screen coords, normalized coords)
|
|
26
|
+
*/
|
|
27
|
+
on_drag_start(position) {
|
|
28
|
+
this.is_dragging = true;
|
|
29
|
+
this.last_time = performance.now();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Called during drag operation
|
|
34
|
+
* @param {Object} position - Current position
|
|
35
|
+
* @returns {number} Delta time in seconds since last move
|
|
36
|
+
*/
|
|
37
|
+
on_drag_move(position) {
|
|
38
|
+
if (!this.is_dragging) return 0;
|
|
39
|
+
|
|
40
|
+
const now = performance.now();
|
|
41
|
+
const delta_time = Math.max(1, now - this.last_time) / 1000;
|
|
42
|
+
this.last_time = now;
|
|
43
|
+
|
|
44
|
+
return delta_time;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Called when drag operation ends
|
|
49
|
+
*/
|
|
50
|
+
on_drag_end() {
|
|
51
|
+
this.is_dragging = false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Override to implement inertia/momentum animation
|
|
56
|
+
* @returns {boolean} True if inertia was started
|
|
57
|
+
*/
|
|
58
|
+
start_inertia() {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Override to stop any ongoing inertia animation
|
|
64
|
+
*/
|
|
65
|
+
stop_inertia() {
|
|
66
|
+
// Base implementation does nothing
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = DragBehaviourBase;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DragController - Handles UI wiring for drag interactions
|
|
5
|
+
*
|
|
6
|
+
* This class manages:
|
|
7
|
+
* - DOM event attachment (pointerdown, pointermove, pointerup, etc.)
|
|
8
|
+
* - Pointer capture for reliable tracking
|
|
9
|
+
* - Coordinate transformation (screen to normalized)
|
|
10
|
+
* - Delegation to a DragBehaviour instance
|
|
11
|
+
*
|
|
12
|
+
* Separates UI concerns from the mathematical drag behavior logic.
|
|
13
|
+
*/
|
|
14
|
+
class DragController {
|
|
15
|
+
/**
|
|
16
|
+
* @param {HTMLElement} element - The element to attach pointer events to
|
|
17
|
+
* @param {DragBehaviourBase} behaviour - The drag behavior instance
|
|
18
|
+
* @param {Object} options - Configuration options
|
|
19
|
+
* @param {number} options.padding - Padding around the arcball area
|
|
20
|
+
* @param {Function} options.on_interactive_frame - Called to request render during interaction
|
|
21
|
+
* @param {Function} options.on_drag_end - Called after drag ends (optional)
|
|
22
|
+
*/
|
|
23
|
+
constructor(element, behaviour, options = {}) {
|
|
24
|
+
this.element = element;
|
|
25
|
+
this.behaviour = behaviour;
|
|
26
|
+
this.padding = options.padding || 8;
|
|
27
|
+
this.on_interactive_frame = options.on_interactive_frame || null;
|
|
28
|
+
this.on_drag_end = options.on_drag_end || null;
|
|
29
|
+
|
|
30
|
+
this.dragging = false;
|
|
31
|
+
this.pending_interactive = false;
|
|
32
|
+
|
|
33
|
+
// Bind event handlers
|
|
34
|
+
this._on_pointer_down = this._handle_pointer_down.bind(this);
|
|
35
|
+
this._on_pointer_move = this._handle_pointer_move.bind(this);
|
|
36
|
+
this._on_pointer_up = this._handle_pointer_up.bind(this);
|
|
37
|
+
|
|
38
|
+
this._attach_handlers();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Attach pointer event handlers to the element
|
|
43
|
+
* @private
|
|
44
|
+
*/
|
|
45
|
+
_attach_handlers() {
|
|
46
|
+
const el = this.element;
|
|
47
|
+
|
|
48
|
+
// Ensure touch-action is set for proper touch handling
|
|
49
|
+
if (getComputedStyle(el).touchAction !== 'none') {
|
|
50
|
+
el.style.touchAction = 'none';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
el.addEventListener('pointerdown', this._on_pointer_down);
|
|
54
|
+
el.addEventListener('pointermove', this._on_pointer_move);
|
|
55
|
+
|
|
56
|
+
// Try to use pointerrawupdate for higher frequency updates
|
|
57
|
+
try {
|
|
58
|
+
el.addEventListener('pointerrawupdate', this._on_pointer_move);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
// pointerrawupdate not supported, fall back to pointermove
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
el.addEventListener('pointerup', this._on_pointer_up);
|
|
64
|
+
el.addEventListener('pointercancel', this._on_pointer_up);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Handle pointer down event
|
|
69
|
+
* @private
|
|
70
|
+
*/
|
|
71
|
+
_handle_pointer_down(ev) {
|
|
72
|
+
this.dragging = true;
|
|
73
|
+
|
|
74
|
+
const pos = this._screen_to_normalized(ev.clientX, ev.clientY);
|
|
75
|
+
this.behaviour.on_drag_start(pos);
|
|
76
|
+
|
|
77
|
+
// Capture the pointer for reliable tracking
|
|
78
|
+
try {
|
|
79
|
+
this.element.setPointerCapture(ev.pointerId);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
// Pointer capture may fail in some contexts
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Handle pointer move event
|
|
87
|
+
* @private
|
|
88
|
+
*/
|
|
89
|
+
_handle_pointer_move(ev) {
|
|
90
|
+
if (!this.dragging) return;
|
|
91
|
+
|
|
92
|
+
const pos = this._screen_to_normalized(ev.clientX, ev.clientY);
|
|
93
|
+
this.behaviour.on_drag_move(pos);
|
|
94
|
+
|
|
95
|
+
this._request_interactive_frame();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Handle pointer up/cancel event
|
|
100
|
+
* @private
|
|
101
|
+
*/
|
|
102
|
+
_handle_pointer_up(ev) {
|
|
103
|
+
if (!this.dragging) return;
|
|
104
|
+
|
|
105
|
+
this.dragging = false;
|
|
106
|
+
|
|
107
|
+
// Release pointer capture
|
|
108
|
+
try {
|
|
109
|
+
this.element.releasePointerCapture(ev.pointerId);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
// May fail if capture was not set
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
this.behaviour.on_drag_end();
|
|
115
|
+
|
|
116
|
+
// Call optional callback
|
|
117
|
+
if (this.on_drag_end) {
|
|
118
|
+
this.on_drag_end();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Convert screen coordinates to normalized arcball coordinates
|
|
124
|
+
* @param {number} clientX - Screen X coordinate
|
|
125
|
+
* @param {number} clientY - Screen Y coordinate
|
|
126
|
+
* @returns {Object} {x, y} in normalized space [-1, 1]
|
|
127
|
+
* @private
|
|
128
|
+
*/
|
|
129
|
+
_screen_to_normalized(clientX, clientY) {
|
|
130
|
+
const rect = this.element.getBoundingClientRect();
|
|
131
|
+
const cx = rect.left + rect.width / 2;
|
|
132
|
+
const cy = rect.top + rect.height / 2;
|
|
133
|
+
const r = Math.max(1, Math.min(rect.width, rect.height) / 2 - this.padding);
|
|
134
|
+
|
|
135
|
+
const x = (clientX - cx) / r;
|
|
136
|
+
const y = (cy - clientY) / r; // Invert Y for standard 3D coordinates
|
|
137
|
+
|
|
138
|
+
return { x, y };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Request an interactive frame render
|
|
143
|
+
* @private
|
|
144
|
+
*/
|
|
145
|
+
_request_interactive_frame() {
|
|
146
|
+
if (this.pending_interactive) return;
|
|
147
|
+
if (!this.on_interactive_frame) return;
|
|
148
|
+
|
|
149
|
+
this.pending_interactive = true;
|
|
150
|
+
requestAnimationFrame(() => {
|
|
151
|
+
this.pending_interactive = false;
|
|
152
|
+
if (this.on_interactive_frame) {
|
|
153
|
+
this.on_interactive_frame();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Detach all event handlers
|
|
160
|
+
*/
|
|
161
|
+
destroy() {
|
|
162
|
+
const el = this.element;
|
|
163
|
+
el.removeEventListener('pointerdown', this._on_pointer_down);
|
|
164
|
+
el.removeEventListener('pointermove', this._on_pointer_move);
|
|
165
|
+
el.removeEventListener('pointerrawupdate', this._on_pointer_move);
|
|
166
|
+
el.removeEventListener('pointerup', this._on_pointer_up);
|
|
167
|
+
el.removeEventListener('pointercancel', this._on_pointer_up);
|
|
168
|
+
|
|
169
|
+
this.behaviour.stop_inertia();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Update padding (e.g., when canvas resizes)
|
|
174
|
+
* @param {number} padding - New padding value
|
|
175
|
+
*/
|
|
176
|
+
set_padding(padding) {
|
|
177
|
+
this.padding = padding;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = DragController;
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const {
|
|
5
|
+
qIdentity,
|
|
6
|
+
qNormalize,
|
|
7
|
+
qMul,
|
|
8
|
+
qFromAxisAngle,
|
|
9
|
+
qFromVectors,
|
|
10
|
+
mat3FromQuat,
|
|
11
|
+
mat3Transpose
|
|
12
|
+
} = require('./math');
|
|
13
|
+
|
|
14
|
+
describe('Quaternion Math Functions', function() {
|
|
15
|
+
|
|
16
|
+
// Helper to check if two numbers are approximately equal
|
|
17
|
+
function approx_equal(a, b, epsilon = 1e-10) {
|
|
18
|
+
return Math.abs(a - b) < epsilon;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Helper to check if two arrays are approximately equal
|
|
22
|
+
function approx_equal_array(a, b, epsilon = 1e-10) {
|
|
23
|
+
if (a.length !== b.length) return false;
|
|
24
|
+
for (let i = 0; i < a.length; i++) {
|
|
25
|
+
if (!approx_equal(a[i], b[i], epsilon)) return false;
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Helper to compute quaternion magnitude
|
|
31
|
+
function q_magnitude(q) {
|
|
32
|
+
return Math.sqrt(q[0]*q[0] + q[1]*q[1] + q[2]*q[2] + q[3]*q[3]);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('qIdentity', function() {
|
|
36
|
+
it('should return identity quaternion [0, 0, 0, 1]', function() {
|
|
37
|
+
const q = qIdentity();
|
|
38
|
+
assert.deepStrictEqual(q, [0, 0, 0, 1]);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('qNormalize', function() {
|
|
43
|
+
it('should normalize a quaternion to unit length', function() {
|
|
44
|
+
const q = [1, 2, 3, 4];
|
|
45
|
+
const normalized = qNormalize(q);
|
|
46
|
+
const mag = q_magnitude(normalized);
|
|
47
|
+
assert(approx_equal(mag, 1.0), `Magnitude should be 1.0, got ${mag}`);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should handle zero quaternion gracefully', function() {
|
|
51
|
+
const q = [0, 0, 0, 0];
|
|
52
|
+
const normalized = qNormalize(q);
|
|
53
|
+
// Should normalize to [0,0,0,0] since magnitude is 0
|
|
54
|
+
assert.deepStrictEqual(normalized, [0, 0, 0, 0]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should preserve already normalized quaternions', function() {
|
|
58
|
+
const q = qIdentity();
|
|
59
|
+
const normalized = qNormalize(q);
|
|
60
|
+
assert(approx_equal_array(normalized, [0, 0, 0, 1]));
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('qMul', function() {
|
|
65
|
+
it('should multiply two quaternions correctly', function() {
|
|
66
|
+
// Identity * anything = anything
|
|
67
|
+
const q1 = qIdentity();
|
|
68
|
+
const q2 = [1, 2, 3, 4];
|
|
69
|
+
const result = qMul(q1, q2);
|
|
70
|
+
assert(approx_equal_array(result, q2, 1e-9));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should be non-commutative (q1*q2 != q2*q1 in general)', function() {
|
|
74
|
+
const q1 = qFromAxisAngle(1, 0, 0, Math.PI / 4);
|
|
75
|
+
const q2 = qFromAxisAngle(0, 1, 0, Math.PI / 4);
|
|
76
|
+
const r1 = qMul(q1, q2);
|
|
77
|
+
const r2 = qMul(q2, q1);
|
|
78
|
+
|
|
79
|
+
// These should not be equal
|
|
80
|
+
const are_equal = approx_equal_array(r1, r2, 1e-6);
|
|
81
|
+
assert(!are_equal, 'Quaternion multiplication should be non-commutative');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should compose rotations correctly', function() {
|
|
85
|
+
// 90° rotation around Z-axis
|
|
86
|
+
const q1 = qFromAxisAngle(0, 0, 1, Math.PI / 2);
|
|
87
|
+
// Another 90° rotation around Z-axis
|
|
88
|
+
const q2 = qFromAxisAngle(0, 0, 1, Math.PI / 2);
|
|
89
|
+
// Combined should be 180° around Z-axis
|
|
90
|
+
const result = qNormalize(qMul(q1, q2));
|
|
91
|
+
const expected = qFromAxisAngle(0, 0, 1, Math.PI);
|
|
92
|
+
|
|
93
|
+
// Allow for both q and -q (they represent same rotation)
|
|
94
|
+
const match = approx_equal_array(result, expected, 1e-9) ||
|
|
95
|
+
approx_equal_array(result, [-expected[0], -expected[1], -expected[2], -expected[3]], 1e-9);
|
|
96
|
+
assert(match, `Expected ${expected}, got ${result}`);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('qFromAxisAngle', function() {
|
|
101
|
+
it('should create quaternion from axis-angle', function() {
|
|
102
|
+
// 180° rotation around X-axis
|
|
103
|
+
const q = qFromAxisAngle(1, 0, 0, Math.PI);
|
|
104
|
+
const mag = q_magnitude(q);
|
|
105
|
+
assert(approx_equal(mag, 1.0), 'Quaternion should be unit length');
|
|
106
|
+
assert(approx_equal(q[0], 1.0, 1e-9), 'X component should be ~1');
|
|
107
|
+
assert(approx_equal(q[3], 0.0, 1e-9), 'W component should be ~0');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should handle zero angle (identity)', function() {
|
|
111
|
+
const q = qFromAxisAngle(1, 0, 0, 0);
|
|
112
|
+
assert(approx_equal_array(q, [0, 0, 0, 1], 1e-9));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should normalize axis internally', function() {
|
|
116
|
+
// Non-unit axis
|
|
117
|
+
const q = qFromAxisAngle(3, 4, 0, Math.PI / 2);
|
|
118
|
+
const mag = q_magnitude(q);
|
|
119
|
+
assert(approx_equal(mag, 1.0), 'Result should be unit quaternion');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('qFromVectors', function() {
|
|
124
|
+
it('should create quaternion rotating u to v', function() {
|
|
125
|
+
const u = [1, 0, 0];
|
|
126
|
+
const v = [0, 1, 0];
|
|
127
|
+
const q = qFromVectors(u, v);
|
|
128
|
+
|
|
129
|
+
// Verify it's a unit quaternion
|
|
130
|
+
const mag = q_magnitude(q);
|
|
131
|
+
assert(approx_equal(mag, 1.0), 'Should be unit quaternion');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should handle identical vectors (no rotation)', function() {
|
|
135
|
+
const u = [1, 0, 0];
|
|
136
|
+
const v = [1, 0, 0];
|
|
137
|
+
const q = qFromVectors(u, v);
|
|
138
|
+
|
|
139
|
+
// Should be identity or very close
|
|
140
|
+
assert(approx_equal_array(q, [0, 0, 0, 1], 1e-9));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should handle opposite vectors (180° rotation)', function() {
|
|
144
|
+
const u = [1, 0, 0];
|
|
145
|
+
const v = [-1, 0, 0];
|
|
146
|
+
const q = qFromVectors(u, v);
|
|
147
|
+
|
|
148
|
+
// Should be a 180° rotation
|
|
149
|
+
const mag = q_magnitude(q);
|
|
150
|
+
assert(approx_equal(mag, 1.0), 'Should be unit quaternion');
|
|
151
|
+
// W component should be ~0 for 180° rotation
|
|
152
|
+
assert(approx_equal(Math.abs(q[3]), 0.0, 1e-9));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should handle small angle rotations robustly', function() {
|
|
156
|
+
const u = [1, 0, 0];
|
|
157
|
+
const v = [0.99999, 0.00001, 0];
|
|
158
|
+
// Normalize v
|
|
159
|
+
const vn = Math.sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2]);
|
|
160
|
+
v[0] /= vn; v[1] /= vn; v[2] /= vn;
|
|
161
|
+
|
|
162
|
+
const q = qFromVectors(u, v);
|
|
163
|
+
const mag = q_magnitude(q);
|
|
164
|
+
assert(approx_equal(mag, 1.0), 'Should be unit quaternion');
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('mat3FromQuat', function() {
|
|
169
|
+
it('should create rotation matrix from quaternion', function() {
|
|
170
|
+
const q = qIdentity();
|
|
171
|
+
const m = mat3FromQuat(q);
|
|
172
|
+
|
|
173
|
+
// Identity quaternion should give identity matrix
|
|
174
|
+
const identity = [
|
|
175
|
+
1, 0, 0,
|
|
176
|
+
0, 1, 0,
|
|
177
|
+
0, 0, 1
|
|
178
|
+
];
|
|
179
|
+
assert(approx_equal_array(m, identity, 1e-9));
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should create correct matrix for 90° Z rotation', function() {
|
|
183
|
+
const q = qFromAxisAngle(0, 0, 1, Math.PI / 2);
|
|
184
|
+
const m = mat3FromQuat(q);
|
|
185
|
+
|
|
186
|
+
// 90° Z rotation matrix (column-major)
|
|
187
|
+
const expected = [
|
|
188
|
+
0, 1, 0,
|
|
189
|
+
-1, 0, 0,
|
|
190
|
+
0, 0, 1
|
|
191
|
+
];
|
|
192
|
+
assert(approx_equal_array(m, expected, 1e-9));
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should produce orthogonal matrix', function() {
|
|
196
|
+
const q = qNormalize([1, 2, 3, 4]);
|
|
197
|
+
const m = mat3FromQuat(q);
|
|
198
|
+
|
|
199
|
+
// Check if M * M^T = I (orthogonality test)
|
|
200
|
+
const mt = mat3Transpose(m);
|
|
201
|
+
|
|
202
|
+
// Multiply M * M^T (both are column-major)
|
|
203
|
+
const result = [];
|
|
204
|
+
for (let row = 0; row < 3; row++) {
|
|
205
|
+
for (let col = 0; col < 3; col++) {
|
|
206
|
+
let sum = 0;
|
|
207
|
+
for (let k = 0; k < 3; k++) {
|
|
208
|
+
// m is column-major: element at (row, k) is m[k*3 + row]
|
|
209
|
+
// mt is column-major: element at (k, col) is mt[col*3 + k]
|
|
210
|
+
sum += m[k * 3 + row] * mt[col * 3 + k];
|
|
211
|
+
}
|
|
212
|
+
result.push(sum);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const identity = [1, 0, 0, 0, 1, 0, 0, 0, 1];
|
|
217
|
+
assert(approx_equal_array(result, identity, 1e-9));
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('mat3Transpose', function() {
|
|
222
|
+
it('should transpose a 3x3 matrix', function() {
|
|
223
|
+
const m = [
|
|
224
|
+
1, 2, 3,
|
|
225
|
+
4, 5, 6,
|
|
226
|
+
7, 8, 9
|
|
227
|
+
];
|
|
228
|
+
const mt = mat3Transpose(m);
|
|
229
|
+
const expected = [
|
|
230
|
+
1, 4, 7,
|
|
231
|
+
2, 5, 8,
|
|
232
|
+
3, 6, 9
|
|
233
|
+
];
|
|
234
|
+
assert.deepStrictEqual(mt, expected);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('should be self-inverse (transpose twice = original)', function() {
|
|
238
|
+
const m = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
239
|
+
const mt = mat3Transpose(m);
|
|
240
|
+
const mtt = mat3Transpose(mt);
|
|
241
|
+
assert.deepStrictEqual(mtt, m);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('Integration Tests', function() {
|
|
246
|
+
it('should rotate a vector correctly using quaternion', function() {
|
|
247
|
+
// Rotate point [1,0,0] by 90° around Z-axis
|
|
248
|
+
// Should get [0,1,0]
|
|
249
|
+
const q = qFromAxisAngle(0, 0, 1, Math.PI / 2);
|
|
250
|
+
const m = mat3FromQuat(q);
|
|
251
|
+
|
|
252
|
+
const v = [1, 0, 0];
|
|
253
|
+
const result = [
|
|
254
|
+
m[0] * v[0] + m[3] * v[1] + m[6] * v[2],
|
|
255
|
+
m[1] * v[0] + m[4] * v[1] + m[7] * v[2],
|
|
256
|
+
m[2] * v[0] + m[5] * v[1] + m[8] * v[2]
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
assert(approx_equal_array(result, [0, 1, 0], 1e-9));
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('should compose multiple rotations correctly', function() {
|
|
263
|
+
// Rotate 90° around X, then 90° around Y
|
|
264
|
+
const qx = qFromAxisAngle(1, 0, 0, Math.PI / 2);
|
|
265
|
+
const qy = qFromAxisAngle(0, 1, 0, Math.PI / 2);
|
|
266
|
+
const qCombined = qNormalize(qMul(qy, qx));
|
|
267
|
+
|
|
268
|
+
// Apply to point [1, 0, 0]
|
|
269
|
+
const m = mat3FromQuat(qCombined);
|
|
270
|
+
const v = [1, 0, 0];
|
|
271
|
+
const result = [
|
|
272
|
+
m[0] * v[0] + m[3] * v[1] + m[6] * v[2],
|
|
273
|
+
m[1] * v[0] + m[4] * v[1] + m[7] * v[2],
|
|
274
|
+
m[2] * v[0] + m[5] * v[1] + m[8] * v[2]
|
|
275
|
+
];
|
|
276
|
+
|
|
277
|
+
// Should get [0, 0, -1] (or close to it)
|
|
278
|
+
assert(approx_equal_array(result, [0, 0, -1], 1e-9));
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
});
|
package/package.json
CHANGED
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
"@babel/generator": "^7.28.3",
|
|
8
8
|
"@babel/parser": "^7.28.4",
|
|
9
9
|
"cookies": "^0.9.1",
|
|
10
|
-
"esbuild": "^0.25.
|
|
10
|
+
"esbuild": "^0.25.11",
|
|
11
11
|
"fnl": "^0.0.36",
|
|
12
12
|
"fnlfs": "^0.0.33",
|
|
13
13
|
"jsgui3-client": "^0.0.120",
|
|
14
14
|
"jsgui3-html": "^0.0.168",
|
|
15
15
|
"jsgui3-webpage": "^0.0.8",
|
|
16
16
|
"jsgui3-website": "^0.0.8",
|
|
17
|
-
"lang-tools": "^0.0.
|
|
17
|
+
"lang-tools": "^0.0.39",
|
|
18
18
|
"mocha": "^11.7.4",
|
|
19
19
|
"multiparty": "^4.2.3",
|
|
20
20
|
"ncp": "^2.0.0",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"type": "git",
|
|
40
40
|
"url": "https://github.com/metabench/jsgui3-server.git"
|
|
41
41
|
},
|
|
42
|
-
"version": "0.0.
|
|
42
|
+
"version": "0.0.136",
|
|
43
43
|
"scripts": {
|
|
44
44
|
"cli": "node cli.js",
|
|
45
45
|
"serve": "node cli.js serve",
|