three-gamepad-controls 0.24.1 → 0.24.2

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.
@@ -99,8 +99,18 @@ var GamepadTransformControls = class extends GamepadControls {
99
99
  #rotationQuaternion;
100
100
  #rotationQuaternion2;
101
101
  #tempQuaternion;
102
- #isTransforming = false;
103
- #transformStarted = false;
102
+ #segment = null;
103
+ #updating = false;
104
+ #ending = false;
105
+ #disposed = false;
106
+ #interrupted = false;
107
+ #needsNeutral = false;
108
+ #pointerRevision = 0;
109
+ #mouseDownEvent = {
110
+ type: "mouseDown",
111
+ mode: "translate"
112
+ };
113
+ #onNativeMouseDown;
104
114
  #rotationAmount = 0;
105
115
  #freeRotationX = 0;
106
116
  #freeRotationY = 0;
@@ -151,6 +161,10 @@ var GamepadTransformControls = class extends GamepadControls {
151
161
  this.#rotationQuaternion = new Quaternion();
152
162
  this.#rotationQuaternion2 = new Quaternion();
153
163
  this.#tempQuaternion = new Quaternion();
164
+ this.#onNativeMouseDown = (event) => {
165
+ if (event !== this.#mouseDownEvent) this.#pointerRevision += 1;
166
+ };
167
+ controls.addEventListener("mouseDown", this.#onNativeMouseDown);
154
168
  }
155
169
  /**
156
170
  * Maps the current gamepad state to `TransformControls` mode, axis,
@@ -159,9 +173,33 @@ var GamepadTransformControls = class extends GamepadControls {
159
173
  * @param deltaTime - Seconds since the last frame.
160
174
  */
161
175
  onUpdate(deltaTime) {
176
+ if (this.#updating || this.#ending) return;
177
+ this.#updating = true;
178
+ this.#interrupted = false;
179
+ try {
180
+ this.#updateTransform(deltaTime);
181
+ } finally {
182
+ this.#updating = false;
183
+ }
184
+ }
185
+ /**
186
+ * Processes one frame of selection, reset, and movement while respecting
187
+ * segment ownership and the neutral input required before reacquisition.
188
+ *
189
+ * @param deltaTime - Seconds since the last frame.
190
+ */
191
+ #updateTransform(deltaTime) {
162
192
  const controls = this.#controls;
193
+ const { transformStick } = this.#options;
194
+ const transform = this.gamepadInput.stick(transformStick.xAxis, transformStick.yAxis, transformStick.pipeline);
195
+ const neutral = transform.x === 0 && transform.y === 0;
196
+ this.#reconcileSegment();
197
+ if (neutral) this.#needsNeutral = false;
163
198
  if (!this.#canApplyInput()) return;
164
- if (controls.dragging && !this.#isTransforming) return;
199
+ if (controls.dragging && this.#segment === null) {
200
+ this.#needsNeutral = !neutral;
201
+ return;
202
+ }
165
203
  const startedButtons = this.#getStartedButtons();
166
204
  this.#handleModeAndAxisButtons(startedButtons);
167
205
  if (!this.#canApplyInput()) return;
@@ -172,31 +210,32 @@ var GamepadTransformControls = class extends GamepadControls {
172
210
  this.#endTransform(true);
173
211
  return;
174
212
  }
175
- const axis = this.#ensureValidAxis();
176
- if (!this.#canApplyInput()) return;
177
- if (axis === null) {
178
- this.#endTransform(true);
179
- return;
180
- }
181
- const { transformStick } = this.#options;
182
- const transform = this.gamepadInput.stick(transformStick.xAxis, transformStick.yAxis, transformStick.pipeline);
183
- if (transform.x === 0 && transform.y === 0) {
213
+ if (neutral) {
184
214
  this.#endTransform(false);
185
215
  return;
186
216
  }
187
- if (!this.#isTransforming) this.#startTransform(object);
217
+ if (this.#needsNeutral) return;
218
+ const axis = this.#resolveAxis();
219
+ this.#setActiveAxis(axis);
188
220
  if (!this.#canApplyInput()) return;
189
- if (!this.#transformStarted) {
190
- this.#transformStarted = true;
191
- controls.dispatchEvent({
192
- type: "mouseDown",
193
- mode: controls.mode
194
- });
221
+ if (axis === null) return;
222
+ if (this.#segment === null) this.#startTransform(object, axis);
223
+ if (!this.#canApplyInput()) return;
224
+ const segment = this.#segment;
225
+ if (!segment.started) {
226
+ segment.started = true;
227
+ const context = this.#readContext();
228
+ this.#mouseDownEvent.mode = segment.mode;
229
+ controls.dispatchEvent(this.#mouseDownEvent);
230
+ this.#afterCallback(context);
195
231
  }
196
232
  if (!this.#canApplyInput()) return;
197
233
  if (this.#applyCurrentTransform(object, axis, deltaTime, transform.x, transform.y)) {
234
+ const context = this.#readContext();
198
235
  controls.dispatchEvent({ type: "change" });
236
+ if (!this.#afterCallback(context)) return;
199
237
  controls.dispatchEvent({ type: "objectChange" });
238
+ this.#afterCallback(context);
200
239
  }
201
240
  }
202
241
  /**
@@ -204,7 +243,13 @@ var GamepadTransformControls = class extends GamepadControls {
204
243
  */
205
244
  dispose() {
206
245
  super.dispose();
207
- this.#endTransform(true);
246
+ this.#disposed = true;
247
+ if (this.#ending) return;
248
+ try {
249
+ this.#endTransform(true);
250
+ } finally {
251
+ this.#controls.removeEventListener("mouseDown", this.#onNativeMouseDown);
252
+ }
208
253
  }
209
254
  /**
210
255
  * Ends any active transform if the active gamepad disconnects mid-drag.
@@ -215,12 +260,91 @@ var GamepadTransformControls = class extends GamepadControls {
215
260
  this.#endTransform(true);
216
261
  super.onGamepadDisconnected(gamepad);
217
262
  }
263
+ /**
264
+ * Checks whether this update can continue applying gamepad input.
265
+ * Native disable ends the owned segment; a wrapper pause retains it.
266
+ *
267
+ * @returns `true` when input remains enabled, a gamepad is available,
268
+ * and no callback has interrupted this update.
269
+ */
218
270
  #canApplyInput() {
219
271
  if (!this.#controls.enabled) {
220
272
  this.#endTransform(true);
221
273
  return false;
222
274
  }
223
- return this.enabled && this.gamepad !== null;
275
+ return !this.#interrupted && this.enabled && this.gamepad !== null;
276
+ }
277
+ /**
278
+ * Captures the native context and pointer acquisition revision without
279
+ * modifying the control or copying the attached object's transform.
280
+ *
281
+ * @returns A context snapshot for segment and callback validation.
282
+ */
283
+ #readContext() {
284
+ const { object, mode, space, axis, dragging } = this.#controls;
285
+ return {
286
+ object,
287
+ mode,
288
+ space,
289
+ axis,
290
+ dragging,
291
+ pointerRevision: this.#pointerRevision
292
+ };
293
+ }
294
+ /**
295
+ * Compares the current native context and pointer revision with a snapshot.
296
+ *
297
+ * @param context - Expected object, selection, dragging state, and pointer revision.
298
+ * @returns `true` when every captured context field still matches.
299
+ */
300
+ #matchesContext(context) {
301
+ const controls = this.#controls;
302
+ return controls.object === context.object && controls.mode === context.mode && controls.space === context.space && controls.axis === context.axis && controls.dragging === context.dragging && this.#pointerRevision === context.pointerRevision;
303
+ }
304
+ /**
305
+ * Ends the owned segment when its context changes or its axis is disallowed.
306
+ * A change of attached object also requires neutral input before reacquisition.
307
+ */
308
+ #reconcileSegment() {
309
+ const segment = this.#segment;
310
+ if (segment === null) return;
311
+ if (!this.#matchesContext(segment) || !this.#isAxisAllowed(segment.mode, segment.axis)) {
312
+ if (this.#controls.object !== segment.object) this.#needsNeutral = true;
313
+ this.#endTransform(false);
314
+ }
315
+ }
316
+ /**
317
+ * Revalidates context, segment ownership, and permissions after synchronous
318
+ * callbacks, interrupting this update if its context or segment was invalidated.
319
+ *
320
+ * @param context - Context expected after the operation that invoked callbacks.
321
+ * @returns `true` when this update may continue applying gamepad input.
322
+ */
323
+ #afterCallback(context) {
324
+ const segment = this.#segment;
325
+ if (!this.#matchesContext(context)) {
326
+ this.#interrupted = true;
327
+ if (this.#controls.object !== context.object) this.#needsNeutral = true;
328
+ }
329
+ this.#reconcileSegment();
330
+ if (segment !== null && this.#segment !== segment) this.#interrupted = true;
331
+ return this.#canApplyInput();
332
+ }
333
+ /**
334
+ * Writes a native property and revalidates the context after its synchronous
335
+ * notifications, accounting for the intended property change.
336
+ *
337
+ * @param key - Native selection or dragging property to update.
338
+ * @param value - Value to assign to the selected property.
339
+ */
340
+ #writeProperty(key, value) {
341
+ const context = {
342
+ ...this.#readContext(),
343
+ [key]: value
344
+ };
345
+ const controls = this.#controls;
346
+ controls[key] = value;
347
+ this.#afterCallback(context);
224
348
  }
225
349
  /**
226
350
  * Applies mode, space, and axis button transitions from the current frame.
@@ -249,14 +373,27 @@ var GamepadTransformControls = class extends GamepadControls {
249
373
  if (this.#controls.mode === mode) return;
250
374
  this.#endTransform(false);
251
375
  if (!this.#canApplyInput()) return;
376
+ const context = {
377
+ ...this.#readContext(),
378
+ mode
379
+ };
252
380
  this.#controls.setMode(mode);
253
- if (this.#canApplyInput()) this.#ensureValidAxis();
381
+ if (this.#afterCallback(context)) this.#setActiveAxis(this.#resolveAxis(null));
254
382
  }
383
+ /**
384
+ * Ends the owned segment and toggles between local and world transform space
385
+ * if input remains permitted after the end notification.
386
+ */
255
387
  #toggleSpace() {
256
388
  const nextSpace = this.#controls.space === "world" ? "local" : "world";
257
389
  this.#endTransform(false);
258
390
  if (!this.#canApplyInput()) return;
391
+ const context = {
392
+ ...this.#readContext(),
393
+ space: nextSpace
394
+ };
259
395
  this.#controls.setSpace(nextSpace);
396
+ this.#afterCallback(context);
260
397
  }
261
398
  /**
262
399
  * Selects an explicit axis when it is valid for the current mode.
@@ -267,9 +404,11 @@ var GamepadTransformControls = class extends GamepadControls {
267
404
  if (!this.#isAxisAllowed(this.#controls.mode, axis)) return;
268
405
  this.#endTransform(false);
269
406
  if (!this.#canApplyInput()) return;
270
- this.#activeAxisByMode[this.#controls.mode] = axis;
271
- this.#ensureValidAxis();
407
+ this.#setActiveAxis(axis);
272
408
  }
409
+ /**
410
+ * Selects the next visible composite axis available in the current mode.
411
+ */
273
412
  #cycleCompositeAxis() {
274
413
  const validAxes = this.#getVisibleAxes(COMPOSITE_AXES[this.#controls.mode]);
275
414
  this.#cycleThroughAxes(validAxes, 1);
@@ -289,31 +428,30 @@ var GamepadTransformControls = class extends GamepadControls {
289
428
  * @param direction - `1` for next axis, `-1` for previous axis.
290
429
  */
291
430
  #cycleThroughAxes(axes, direction) {
431
+ this.#endTransform(false);
432
+ if (!this.#canApplyInput()) return;
292
433
  if (axes.length === 0) {
293
434
  this.#setActiveAxis(null);
294
435
  return;
295
436
  }
296
- this.#endTransform(false);
297
- if (!this.#canApplyInput()) return;
298
- const current = this.#activeAxisByMode[this.#controls.mode];
299
- const currentIndex = current === null ? -1 : axes.indexOf(current);
437
+ const current = this.#resolveAxis();
438
+ const currentIndex = axes.indexOf(current);
300
439
  const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + axes.length) % axes.length;
301
- this.#activeAxisByMode[this.#controls.mode] = axes[nextIndex];
302
- this.#ensureValidAxis();
440
+ this.#setActiveAxis(axes[nextIndex]);
303
441
  }
304
442
  /**
305
- * Ensures the highlighted TransformControls axis is valid and visible.
443
+ * Resolves selection without writing to the native control or axis memory.
306
444
  *
445
+ * @param nativeAxis - Preferred native axis, defaulting to the current selection.
446
+ * Pass `null` to use mode memory before the first allowed axis.
307
447
  * @returns The active valid axis, or `null` when no axis is available.
308
448
  */
309
- #ensureValidAxis() {
449
+ #resolveAxis(nativeAxis = this.#controls.axis) {
310
450
  const mode = this.#controls.mode;
451
+ if (nativeAxis !== null && this.#isAxisAllowed(mode, nativeAxis)) return nativeAxis;
311
452
  const current = this.#activeAxisByMode[mode];
312
453
  const validAxes = this.#getValidAxes(mode);
313
- const nextAxis = current !== null && validAxes.includes(current) ? current : validAxes[0] ?? null;
314
- this.#activeAxisByMode[mode] = nextAxis;
315
- if (this.#controls.axis !== nextAxis) this.#controls.axis = nextAxis;
316
- return nextAxis;
454
+ return current !== null && validAxes.includes(current) ? current : validAxes[0] ?? null;
317
455
  }
318
456
  /**
319
457
  * Updates both the remembered axis for the current mode and the control axis.
@@ -322,7 +460,7 @@ var GamepadTransformControls = class extends GamepadControls {
322
460
  */
323
461
  #setActiveAxis(axis) {
324
462
  this.#activeAxisByMode[this.#controls.mode] = axis;
325
- if (this.#controls.axis !== axis) this.#controls.axis = axis;
463
+ if (this.#controls.axis !== axis) this.#writeProperty("axis", axis);
326
464
  }
327
465
  /**
328
466
  * Returns all visible axes supported by a TransformControls mode.
@@ -375,41 +513,71 @@ var GamepadTransformControls = class extends GamepadControls {
375
513
  }
376
514
  }
377
515
  /**
378
- * Starts a TransformControls drag interaction for the active object and axis.
516
+ * Captures a segment's transform origin and claims dragging ownership before
517
+ * notifying native property listeners. The update publishes `mouseDown` later.
379
518
  *
380
519
  * @param object - Object attached to TransformControls for this update.
520
+ * @param axis - Valid axis acquired for the new segment.
381
521
  */
382
- #startTransform(object) {
383
- const controls = this.#controls;
522
+ #startTransform(object, axis) {
384
523
  this.#captureTransformStart(object);
385
- this.#isTransforming = true;
386
- controls.dragging = true;
524
+ this.#segment = {
525
+ ...this.#readContext(),
526
+ object,
527
+ axis,
528
+ dragging: true,
529
+ started: false
530
+ };
531
+ this.#writeProperty("dragging", true);
387
532
  }
388
533
  /**
389
- * Ends an active TransformControls drag interaction.
534
+ * Releases the owned segment and ends its published interaction once,
535
+ * preserving pointer ownership and context changes made by callbacks.
390
536
  *
391
- * @param clearAxis - Whether to clear the highlighted axis after ending.
537
+ * @param clearAxis - Whether to clear the highlighted axis if ownership
538
+ * and context still permit it after end notifications.
392
539
  */
393
540
  #endTransform(clearAxis) {
394
- if (!this.#isTransforming) return;
541
+ const segment = this.#segment;
542
+ if (segment === null) return;
395
543
  const controls = this.#controls;
396
- const started = this.#transformStarted;
397
- const mode = controls.mode;
398
- this.#isTransforming = false;
399
- this.#transformStarted = false;
400
- if (started) controls.dispatchEvent({
401
- type: "mouseUp",
402
- mode
403
- });
404
- controls.dragging = false;
405
- if (clearAxis) this.#setActiveAxis(null);
544
+ const context = this.#readContext();
545
+ this.#segment = null;
546
+ this.#ending = true;
547
+ try {
548
+ if (segment.started) controls.dispatchEvent({
549
+ type: "mouseUp",
550
+ mode: segment.mode
551
+ });
552
+ const unchanged = this.#matchesContext(context);
553
+ if (!unchanged) {
554
+ this.#interrupted = true;
555
+ if (controls.object !== context.object) this.#needsNeutral = true;
556
+ }
557
+ const ownsDragging = this.#pointerRevision === segment.pointerRevision;
558
+ if (ownsDragging && controls.dragging === context.dragging) this.#writeProperty("dragging", false);
559
+ if (clearAxis && ownsDragging && unchanged && this.#matchesContext({
560
+ ...context,
561
+ dragging: false
562
+ }) && controls.axis === segment.axis) this.#setActiveAxis(null);
563
+ } finally {
564
+ this.#ending = false;
565
+ if (this.#disposed) controls.removeEventListener("mouseDown", this.#onNativeMouseDown);
566
+ }
406
567
  }
568
+ /**
569
+ * Restores the owned segment's transform origin through native reset and
570
+ * resets its accumulators if callbacks leave the same segment active.
571
+ */
407
572
  #resetActiveTransform() {
408
- const object = this.#controls.object;
409
- if (!this.#isTransforming || object === void 0) return;
573
+ const segment = this.#segment;
574
+ if (segment === null) return;
575
+ const context = this.#readContext();
410
576
  this.#controls.reset();
411
- this.#accumulatedPosition.copy(object.position);
412
- this.#accumulatedScale.copy(object.scale);
577
+ this.#afterCallback(context);
578
+ if (this.#segment !== segment) return;
579
+ this.#accumulatedPosition.copy(segment.object.position);
580
+ this.#accumulatedScale.copy(segment.object.scale);
413
581
  this.#rotationAmount = 0;
414
582
  this.#freeRotationX = 0;
415
583
  this.#freeRotationY = 0;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "three-gamepad-controls",
3
3
  "description": "Gamepad support for Three.js controls.",
4
- "version": "0.24.1",
4
+ "version": "0.24.2",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/luckasnix/three-gamepad-controls#readme",
7
7
  "author": {