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.
@@ -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
- this.q = qIdentity();
76
- this.R = mat3FromQuat(this.q);
77
- this.Rt = mat3Transpose(this.R);
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
- this._attachPointerHandlers();
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",
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.36",
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.135",
42
+ "version": "0.0.136",
43
43
  "scripts": {
44
44
  "cli": "node cli.js",
45
45
  "serve": "node cli.js serve",
@@ -81,7 +81,7 @@ class Local_Server_Info extends Resource {
81
81
  callback(null, true);
82
82
  }
83
83
 
84
- } else if (o_status == 'on') {
84
+ } else if (this.status === 'on') {
85
85
  callback(null, true);
86
86
  }
87
87
  }