@ksp-gonogo/kerbcast 1.9.0 → 1.9.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.1 - 2026-08-06
4
+
5
+ <!-- TODO: fill in. -->
6
+
7
+
8
+ ## 1.9.1-rc.1 - 2026-08-06
9
+
10
+ <!-- TODO: fill in. -->
11
+
12
+
3
13
  ## 1.8.0 - 2026-07-28
4
14
 
5
15
  Client-side camera recording. The capture itself is pure SDK: the recorder taps
package/dist/panZoom.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Headless pan/zoom control state machine extracted from the CameraFeed
3
3
  * component. Manages rate deduplication, analog deadzone, optimistic
4
4
  * accumulators for discrete nudges, debounced FoV slider, and echo-sync
5
- * while idle -- all without any DOM or React dependency.
5
+ * while idle and settled -- all without any DOM or React dependency.
6
6
  *
7
7
  * `KerbcastCameraHandle` satisfies `PanZoomCommandSink` structurally: pass a
8
8
  * camera handle directly as the sink and the controller will drive it.
@@ -74,6 +74,31 @@ export interface PanZoomControllerOptions {
74
74
  * Default: 0.05.
75
75
  */
76
76
  analogDeadzone?: number;
77
+ /**
78
+ * How close an echo has to get to an outstanding setpoint (degrees) before
79
+ * the camera counts as having arrived. Wider than the plugin's own 0.01
80
+ * degree reporting threshold so a rounding difference does not hold the
81
+ * setpoint open.
82
+ * Default: 0.5.
83
+ */
84
+ settleEpsilonDeg?: number;
85
+ /**
86
+ * How many consecutive *moving* echoes may fail to close on an outstanding
87
+ * setpoint before the controller gives up and adopts what the camera
88
+ * reports. Catches something else owning the camera (another operator, or
89
+ * auto-track) without waiting out the full timeout.
90
+ * Default: 2.
91
+ */
92
+ settleStallEchoes?: number;
93
+ /**
94
+ * Hard cap (ms) on how long a setpoint is held against echoes. Backstop for
95
+ * a camera that parks short of the setpoint and then reports the same
96
+ * position forever (a bounds mismatch would do it), which no
97
+ * progress-based rule can detect. Sized for the worst real case: 360
98
+ * degrees of yaw at 90 deg/s plus the plugin's ~1Hz status write.
99
+ * Default: 6000.
100
+ */
101
+ settleTimeoutMs?: number;
77
102
  }
78
103
  /**
79
104
  * Headless pan/zoom state machine. Manages rate deduplication, analog
@@ -87,10 +112,15 @@ export declare class PanZoomController {
87
112
  private readonly fovNudgeDeg;
88
113
  private readonly fovSliderDebounceMs;
89
114
  private readonly analogDeadzone;
115
+ private readonly settleEpsilonDeg;
116
+ private readonly settleStallEchoes;
117
+ private readonly settleTimeoutMs;
90
118
  private sentPanRate;
91
119
  private sentZoomRate;
92
120
  private localPan;
93
121
  private localFov;
122
+ private panSetpoint;
123
+ private fovSetpoint;
94
124
  private _sliderFov;
95
125
  private sliderDragging;
96
126
  private fovDebounceTimer;
@@ -112,6 +142,12 @@ export declare class PanZoomController {
112
142
  * Applies the echoed FoV to the accumulator and slider only when the
113
143
  * controller is zoom-idle (zoom rate 0, slider not dragging, no pending
114
144
  * debounced send). Mirrors CameraFeed.tsx:370-387.
145
+ *
146
+ * An echo is also held off while an absolute setpoint we sent is still
147
+ * outstanding: the plugin slews toward a setpoint and reports its position
148
+ * at ~1Hz, so echoes in that window carry positions the camera has already
149
+ * left, and adopting one would re-base the next nudge behind where the
150
+ * camera is going.
115
151
  */
116
152
  syncFromState(state: {
117
153
  fov: number;
@@ -186,5 +222,8 @@ export declare class PanZoomController {
186
222
  */
187
223
  dispose(): void;
188
224
  private flushFovSlider;
225
+ private panEchoWins;
226
+ private fovEchoWins;
227
+ private settled;
189
228
  private setSliderFovInternal;
190
229
  }
package/dist/panZoom.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Headless pan/zoom control state machine extracted from the CameraFeed
3
3
  * component. Manages rate deduplication, analog deadzone, optimistic
4
4
  * accumulators for discrete nudges, debounced FoV slider, and echo-sync
5
- * while idle -- all without any DOM or React dependency.
5
+ * while idle and settled -- all without any DOM or React dependency.
6
6
  *
7
7
  * `KerbcastCameraHandle` satisfies `PanZoomCommandSink` structurally: pass a
8
8
  * camera handle directly as the sink and the controller will drive it.
@@ -40,6 +40,9 @@ function clamp(v, lo, hi) {
40
40
  function applyDeadzone(v, deadzone) {
41
41
  return Math.abs(v) < deadzone ? 0 : v;
42
42
  }
43
+ function newSettling() {
44
+ return { lastDistance: null, stalls: 0, issuedAt: Date.now() };
45
+ }
43
46
  // ---------------------------------------------------------------------------
44
47
  // PanZoomController
45
48
  // ---------------------------------------------------------------------------
@@ -57,6 +60,14 @@ export class PanZoomController {
57
60
  // Optimistic accumulators for discrete nudges.
58
61
  this.localPan = { yaw: 0, pitch: 0 };
59
62
  this.localFov = 0;
63
+ /*
64
+ Outstanding absolute setpoints. Non-null from the moment a setPan / setFov
65
+ goes out until an echo shows the camera got there (or one of `settled`'s
66
+ give-up rules fires). While one is outstanding the accumulator holds the
67
+ setpoint and echoes do not touch it.
68
+ */
69
+ this.panSetpoint = null;
70
+ this.fovSetpoint = null;
60
71
  // FoV slider state.
61
72
  this._sliderFov = 0;
62
73
  this.sliderDragging = false;
@@ -80,6 +91,9 @@ export class PanZoomController {
80
91
  this.fovNudgeDeg = opts.fovNudgeDeg ?? 5;
81
92
  this.fovSliderDebounceMs = opts.fovSliderDebounceMs ?? 120;
82
93
  this.analogDeadzone = opts.analogDeadzone ?? 0.05;
94
+ this.settleEpsilonDeg = opts.settleEpsilonDeg ?? 0.5;
95
+ this.settleStallEchoes = opts.settleStallEchoes ?? 2;
96
+ this.settleTimeoutMs = opts.settleTimeoutMs ?? 6000;
83
97
  }
84
98
  // ---------------------------------------------------------------------------
85
99
  // Public getters
@@ -102,6 +116,12 @@ export class PanZoomController {
102
116
  * Applies the echoed FoV to the accumulator and slider only when the
103
117
  * controller is zoom-idle (zoom rate 0, slider not dragging, no pending
104
118
  * debounced send). Mirrors CameraFeed.tsx:370-387.
119
+ *
120
+ * An echo is also held off while an absolute setpoint we sent is still
121
+ * outstanding: the plugin slews toward a setpoint and reports its position
122
+ * at ~1Hz, so echoes in that window carry positions the camera has already
123
+ * left, and adopting one would re-base the next nudge behind where the
124
+ * camera is going.
105
125
  */
106
126
  syncFromState(state) {
107
127
  this.bounds = {
@@ -115,13 +135,13 @@ export class PanZoomController {
115
135
  const panIdle = !this.ballDragging &&
116
136
  this.sentPanRate.yaw === 0 &&
117
137
  this.sentPanRate.pitch === 0;
118
- if (panIdle) {
138
+ if (panIdle && this.panEchoWins(state.panYaw, state.panPitch)) {
119
139
  this.localPan = { yaw: state.panYaw, pitch: state.panPitch };
120
140
  }
121
141
  const zoomIdle = this.sentZoomRate === 0 &&
122
142
  !this.sliderDragging &&
123
143
  this.pendingFov === null;
124
- if (zoomIdle) {
144
+ if (zoomIdle && this.fovEchoWins(state.fov)) {
125
145
  this.localFov = state.fov;
126
146
  this.setSliderFovInternal(state.fov);
127
147
  }
@@ -140,6 +160,9 @@ export class PanZoomController {
140
160
  if (y === this.sentPanRate.yaw && p === this.sentPanRate.pitch)
141
161
  return;
142
162
  this.sentPanRate = { yaw: y, pitch: p };
163
+ // A rate supersedes any absolute we were still waiting on, including the
164
+ // zero that ends the hold: the camera is wherever the rate left it.
165
+ this.panSetpoint = null;
143
166
  void this.sink.setPanRate(y, p);
144
167
  }
145
168
  /**
@@ -166,6 +189,7 @@ export class PanZoomController {
166
189
  if (r === this.sentZoomRate)
167
190
  return;
168
191
  this.sentZoomRate = r;
192
+ this.fovSetpoint = null; // same reasoning as setPanRate
169
193
  void this.sink.setZoomRate(r);
170
194
  }
171
195
  // ---------------------------------------------------------------------------
@@ -180,6 +204,11 @@ export class PanZoomController {
180
204
  const b = this.bounds;
181
205
  this.localPan.yaw = clamp(this.localPan.yaw + yawSign * this.panNudgeDeg, b.panYawMin, b.panYawMax);
182
206
  this.localPan.pitch = clamp(this.localPan.pitch + pitchSign * this.panNudgeDeg, b.panPitchMin, b.panPitchMax);
207
+ this.panSetpoint = {
208
+ yaw: this.localPan.yaw,
209
+ pitch: this.localPan.pitch,
210
+ ...newSettling(),
211
+ };
183
212
  void this.sink.setPan(this.localPan.yaw, this.localPan.pitch);
184
213
  }
185
214
  /**
@@ -191,6 +220,7 @@ export class PanZoomController {
191
220
  const b = this.bounds;
192
221
  const next = clamp(this.localFov + deltaSign * this.fovNudgeDeg, b.fovMin, b.fovMax);
193
222
  this.localFov = next;
223
+ this.fovSetpoint = { fov: next, ...newSettling() };
194
224
  void this.sink.setFov(next);
195
225
  }
196
226
  // ---------------------------------------------------------------------------
@@ -244,6 +274,9 @@ export class PanZoomController {
244
274
  */
245
275
  setBallDragging(dragging) {
246
276
  this.ballDragging = dragging;
277
+ // Taking the ball abandons any absolute we were waiting on.
278
+ if (dragging)
279
+ this.panSetpoint = null;
247
280
  }
248
281
  // ---------------------------------------------------------------------------
249
282
  // Lifecycle
@@ -265,6 +298,9 @@ export class PanZoomController {
265
298
  }
266
299
  this.ballDragging = false;
267
300
  this.sliderDragging = false;
301
+ // Nothing is being commanded any more, so echoes win again.
302
+ this.panSetpoint = null;
303
+ this.fovSetpoint = null;
268
304
  if (this.fovDebounceTimer !== null) {
269
305
  clearTimeout(this.fovDebounceTimer);
270
306
  this.fovDebounceTimer = null;
@@ -291,9 +327,60 @@ export class PanZoomController {
291
327
  const fov = clamp(this.pendingFov, this.bounds.fovMin, this.bounds.fovMax);
292
328
  this.localFov = fov;
293
329
  this.pendingFov = null;
330
+ this.fovSetpoint = { fov, ...newSettling() };
294
331
  void this.sink.setFov(fov);
295
332
  }
296
333
  }
334
+ /*
335
+ Whether an echoed pan should be adopted into the accumulator. True when
336
+ nothing is outstanding, or when the outstanding setpoint has been settled
337
+ one way or another (in which case it is cleared and the camera's own
338
+ reading wins from here).
339
+ */
340
+ panEchoWins(yaw, pitch) {
341
+ const sp = this.panSetpoint;
342
+ if (sp === null)
343
+ return true;
344
+ const distance = Math.max(Math.abs(yaw - sp.yaw), Math.abs(pitch - sp.pitch));
345
+ if (!this.settled(sp, distance))
346
+ return false;
347
+ this.panSetpoint = null;
348
+ return true;
349
+ }
350
+ /* `panEchoWins` for FoV. */
351
+ fovEchoWins(fov) {
352
+ const sp = this.fovSetpoint;
353
+ if (sp === null)
354
+ return true;
355
+ if (!this.settled(sp, Math.abs(fov - sp.fov)))
356
+ return false;
357
+ this.fovSetpoint = null;
358
+ return true;
359
+ }
360
+ /*
361
+ Give up on an outstanding setpoint when the camera arrived, when it is
362
+ moving but no longer closing on it (something else owns the camera: another
363
+ operator, or auto-track), or when the timeout backstop expires (it parked
364
+ short and is reporting the same position forever, which no progress-based
365
+ rule can see). Mutates the settling bookkeeping.
366
+ */
367
+ settled(sp, distance) {
368
+ const eps = this.settleEpsilonDeg;
369
+ if (distance <= eps)
370
+ return true;
371
+ if (Date.now() - sp.issuedAt >= this.settleTimeoutMs)
372
+ return true;
373
+ const prev = sp.lastDistance;
374
+ sp.lastDistance = distance;
375
+ if (prev === null)
376
+ return false;
377
+ const moved = Math.abs(prev - distance) > eps;
378
+ const closing = distance < prev - eps;
379
+ if (!moved)
380
+ return false;
381
+ sp.stalls = closing ? 0 : sp.stalls + 1;
382
+ return sp.stalls >= this.settleStallEchoes;
383
+ }
297
384
  setSliderFovInternal(fov) {
298
385
  this._sliderFov = fov;
299
386
  for (const cb of this.sliderFovListeners)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ksp-gonogo/kerbcast",
3
- "version": "1.9.0",
3
+ "version": "1.9.1",
4
4
  "description": "TypeScript client for the kerbcast KSP camera-streaming sidecar. KerbcastClient wraps the WebRTC peer + control channel; wire-format types are typeshare-generated from the sidecar's Rust protocol module.",
5
5
  "license": "MIT",
6
6
  "author": "Jon Pepler",
@@ -386,6 +386,217 @@ describe("PanZoomController: nudge after echo-sync", () => {
386
386
  });
387
387
  });
388
388
 
389
+ // ---------------------------------------------------------------------------
390
+ // Outstanding setpoints: an echo captured mid-slew must not re-base the
391
+ // accumulator behind where the camera is going. The plugin slews toward an
392
+ // absolute at 90 deg/s and reports position at ~1Hz, so the first echo after
393
+ // a nudge routinely carries the pre-command value.
394
+ // ---------------------------------------------------------------------------
395
+
396
+ describe("PanZoomController: outstanding setpoint", () => {
397
+ const WIDE = { ...DEFAULT_BOUNDS, panYawMin: -180, panYawMax: 180 };
398
+
399
+ it("ignores an echo carrying the pre-command position", () => {
400
+ const { sink, setPan } = makeSink();
401
+ const ctrl = new PanZoomController(sink, { panNudgeDeg: 5 });
402
+ ctrl.syncFromState({ ...WIDE, panYaw: 0 });
403
+ ctrl.nudgePan(1, 0);
404
+ expect(setPan).toHaveBeenLastCalledWith(5, 0);
405
+
406
+ // The measured case: an echo lands ~9ms later still reporting the old
407
+ // position. Adopting it would make the next nudge repeat this one.
408
+ ctrl.syncFromState({ ...WIDE, panYaw: 0 });
409
+ ctrl.nudgePan(1, 0);
410
+ expect(setPan).toHaveBeenLastCalledWith(10, 0);
411
+ });
412
+
413
+ it("releases the setpoint once an echo agrees with it", () => {
414
+ const { sink, setPan } = makeSink();
415
+ const ctrl = new PanZoomController(sink, { panNudgeDeg: 5 });
416
+ ctrl.syncFromState({ ...WIDE, panYaw: 0 });
417
+ ctrl.nudgePan(1, 0); // setpoint 5
418
+ ctrl.syncFromState({ ...WIDE, panYaw: 5 }); // arrived -- setpoint released
419
+ ctrl.syncFromState({ ...WIDE, panYaw: 20 }); // someone else moved it
420
+ ctrl.nudgePan(1, 0);
421
+ expect(setPan).toHaveBeenLastCalledWith(25, 0);
422
+ });
423
+
424
+ it("counts an echo within the settle epsilon as arrival", () => {
425
+ const { sink, setPan } = makeSink();
426
+ const ctrl = new PanZoomController(sink, {
427
+ panNudgeDeg: 5,
428
+ settleEpsilonDeg: 0.5,
429
+ });
430
+ ctrl.syncFromState({ ...WIDE, panYaw: 0 });
431
+ ctrl.nudgePan(1, 0); // setpoint 5
432
+ ctrl.syncFromState({ ...WIDE, panYaw: 4.7 }); // within 0.5 -- adopted
433
+ ctrl.nudgePan(1, 0);
434
+ expect(setPan).toHaveBeenLastCalledWith(9.7, 0);
435
+ });
436
+
437
+ it("holds until BOTH axes arrive", () => {
438
+ const { sink, setPan } = makeSink();
439
+ const ctrl = new PanZoomController(sink, { panNudgeDeg: 30 });
440
+ const bounds = { ...WIDE, panPitchMin: -90, panPitchMax: 90 };
441
+ ctrl.syncFromState({ ...bounds, panYaw: 0, panPitch: 0 });
442
+ ctrl.nudgePan(1, 1); // setpoint (30, 30)
443
+ ctrl.syncFromState({ ...bounds, panYaw: 30, panPitch: 0 }); // pitch mid-slew
444
+ ctrl.nudgePan(1, 1);
445
+ expect(setPan).toHaveBeenLastCalledWith(60, 60);
446
+ });
447
+
448
+ it("gives up when the camera moves but stops closing on the setpoint", () => {
449
+ const { sink, setPan } = makeSink();
450
+ const ctrl = new PanZoomController(sink, {
451
+ panNudgeDeg: 30,
452
+ settleStallEchoes: 2,
453
+ });
454
+ ctrl.syncFromState({ ...WIDE, panYaw: 0 });
455
+ ctrl.nudgePan(1, 0); // setpoint 30
456
+
457
+ // Something else owns the camera (another operator, or auto-track): it is
458
+ // moving, but away from our setpoint.
459
+ ctrl.syncFromState({ ...WIDE, panYaw: 10 }); // closing, no verdict yet
460
+ ctrl.syncFromState({ ...WIDE, panYaw: 5 }); // stall 1
461
+ ctrl.syncFromState({ ...WIDE, panYaw: 0 }); // stall 2 -- adopt reality
462
+ ctrl.nudgePan(1, 0);
463
+ expect(setPan).toHaveBeenLastCalledWith(30, 0);
464
+ });
465
+
466
+ it("does not count repeated identical echoes toward the stall", () => {
467
+ // The sidecar re-broadcasts camera state on unrelated changes (bitrate,
468
+ // degrade), each carrying the same ~1Hz plugin pan reading. Those are not
469
+ // evidence that the camera stopped closing.
470
+ const { sink, setPan } = makeSink();
471
+ const ctrl = new PanZoomController(sink, {
472
+ panNudgeDeg: 30,
473
+ settleStallEchoes: 2,
474
+ });
475
+ ctrl.syncFromState({ ...WIDE, panYaw: 0 });
476
+ ctrl.nudgePan(1, 0); // setpoint 30
477
+ for (let i = 0; i < 5; i++) ctrl.syncFromState({ ...WIDE, panYaw: 10 });
478
+ ctrl.nudgePan(1, 0);
479
+ expect(setPan).toHaveBeenLastCalledWith(60, 0);
480
+ });
481
+
482
+ it("releases the setpoint after the timeout backstop", async () => {
483
+ vi.useFakeTimers();
484
+ try {
485
+ const { sink, setPan } = makeSink();
486
+ const ctrl = new PanZoomController(sink, {
487
+ panNudgeDeg: 30,
488
+ settleTimeoutMs: 6000,
489
+ });
490
+ ctrl.syncFromState({ ...WIDE, panYaw: 0 });
491
+ ctrl.nudgePan(1, 0); // setpoint 30
492
+
493
+ // Camera parks short and reports the same position forever, which the
494
+ // stall rule cannot see. The timeout is the way out.
495
+ ctrl.syncFromState({ ...WIDE, panYaw: 10 });
496
+ await vi.advanceTimersByTimeAsync(6000);
497
+ ctrl.syncFromState({ ...WIDE, panYaw: 10 });
498
+ ctrl.nudgePan(1, 0);
499
+ expect(setPan).toHaveBeenLastCalledWith(40, 0);
500
+ } finally {
501
+ vi.useRealTimers();
502
+ }
503
+ });
504
+
505
+ it("drops the setpoint when a pan rate takes over", () => {
506
+ const { sink, setPan } = makeSink();
507
+ const ctrl = new PanZoomController(sink, { panNudgeDeg: 30 });
508
+ ctrl.syncFromState({ ...WIDE, panYaw: 0 });
509
+ ctrl.nudgePan(1, 0); // setpoint 30
510
+ ctrl.setPanRate(0.5, 0);
511
+ ctrl.setPanRate(0, 0); // hold over; camera is wherever the rate left it
512
+ ctrl.syncFromState({ ...WIDE, panYaw: 10 });
513
+ ctrl.nudgePan(1, 0);
514
+ expect(setPan).toHaveBeenLastCalledWith(40, 0);
515
+ });
516
+
517
+ it("drops the setpoint when the ball is grabbed", () => {
518
+ const { sink, setPan } = makeSink();
519
+ const ctrl = new PanZoomController(sink, { panNudgeDeg: 30 });
520
+ ctrl.syncFromState({ ...WIDE, panYaw: 0 });
521
+ ctrl.nudgePan(1, 0); // setpoint 30
522
+ ctrl.setBallDragging(true);
523
+ ctrl.setBallDragging(false);
524
+ ctrl.syncFromState({ ...WIDE, panYaw: 10 });
525
+ ctrl.nudgePan(1, 0);
526
+ expect(setPan).toHaveBeenLastCalledWith(40, 0);
527
+ });
528
+
529
+ it("drops the setpoint on stop", () => {
530
+ const { sink, setPan } = makeSink();
531
+ const ctrl = new PanZoomController(sink, { panNudgeDeg: 30 });
532
+ ctrl.syncFromState({ ...WIDE, panYaw: 0 });
533
+ ctrl.nudgePan(1, 0); // setpoint 30
534
+ ctrl.stop();
535
+ ctrl.syncFromState({ ...WIDE, panYaw: 10 });
536
+ ctrl.nudgePan(1, 0);
537
+ expect(setPan).toHaveBeenLastCalledWith(40, 0);
538
+ });
539
+
540
+ it("keeps applying echoed bounds while a setpoint is outstanding", () => {
541
+ // Bounds are camera metadata, not a position: holding them back would
542
+ // clamp later nudges against a stale travel limit.
543
+ const { sink, setPan } = makeSink();
544
+ const ctrl = new PanZoomController(sink, { panNudgeDeg: 30 });
545
+ ctrl.syncFromState({ ...WIDE, panYaw: 0 });
546
+ ctrl.nudgePan(1, 0); // setpoint 30
547
+ ctrl.syncFromState({ ...WIDE, panYaw: 0, panYawMax: 45 });
548
+ ctrl.nudgePan(1, 0);
549
+ expect(setPan).toHaveBeenLastCalledWith(45, 0);
550
+ });
551
+
552
+ it("does not let an in-transit fov echo drag the slider back", async () => {
553
+ vi.useFakeTimers();
554
+ try {
555
+ const { sink, setFov } = makeSink();
556
+ const ctrl = new PanZoomController(sink, {
557
+ fovNudgeDeg: 5,
558
+ fovSliderDebounceMs: 120,
559
+ });
560
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, fov: 60 });
561
+ ctrl.fovSliderInput(30);
562
+ await vi.advanceTimersByTimeAsync(120);
563
+ expect(setFov).toHaveBeenLastCalledWith(30);
564
+
565
+ // Plugin is still slewing 60 -> 30 and reports 60. Adopting it would
566
+ // visibly snap the slider back and re-base the next zoom nudge.
567
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, fov: 60 });
568
+ expect(ctrl.sliderFov).toBe(30);
569
+ ctrl.nudgeZoom(-1);
570
+ expect(setFov).toHaveBeenLastCalledWith(25);
571
+ } finally {
572
+ vi.useRealTimers();
573
+ }
574
+ });
575
+
576
+ it("releases the fov setpoint once the echo agrees", () => {
577
+ const { sink, setFov } = makeSink();
578
+ const ctrl = new PanZoomController(sink, { fovNudgeDeg: 5 });
579
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, fov: 60 });
580
+ ctrl.nudgeZoom(-1); // setpoint 55
581
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, fov: 55 }); // arrived
582
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, fov: 40 }); // someone else zoomed
583
+ ctrl.nudgeZoom(-1);
584
+ expect(setFov).toHaveBeenLastCalledWith(35);
585
+ });
586
+
587
+ it("drops the fov setpoint when a zoom rate takes over", () => {
588
+ const { sink, setFov } = makeSink();
589
+ const ctrl = new PanZoomController(sink, { fovNudgeDeg: 5 });
590
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, fov: 60 });
591
+ ctrl.nudgeZoom(-1); // setpoint 55
592
+ ctrl.setZoomRate(1);
593
+ ctrl.setZoomRate(0);
594
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, fov: 40 });
595
+ ctrl.nudgeZoom(-1);
596
+ expect(setFov).toHaveBeenLastCalledWith(35);
597
+ });
598
+ });
599
+
389
600
  // ---------------------------------------------------------------------------
390
601
  // onSliderFov subscription management
391
602
  // ---------------------------------------------------------------------------
package/src/panZoom.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Headless pan/zoom control state machine extracted from the CameraFeed
3
3
  * component. Manages rate deduplication, analog deadzone, optimistic
4
4
  * accumulators for discrete nudges, debounced FoV slider, and echo-sync
5
- * while idle -- all without any DOM or React dependency.
5
+ * while idle and settled -- all without any DOM or React dependency.
6
6
  *
7
7
  * `KerbcastCameraHandle` satisfies `PanZoomCommandSink` structurally: pass a
8
8
  * camera handle directly as the sink and the controller will drive it.
@@ -81,6 +81,31 @@ export interface PanZoomControllerOptions {
81
81
  * Default: 0.05.
82
82
  */
83
83
  analogDeadzone?: number;
84
+ /**
85
+ * How close an echo has to get to an outstanding setpoint (degrees) before
86
+ * the camera counts as having arrived. Wider than the plugin's own 0.01
87
+ * degree reporting threshold so a rounding difference does not hold the
88
+ * setpoint open.
89
+ * Default: 0.5.
90
+ */
91
+ settleEpsilonDeg?: number;
92
+ /**
93
+ * How many consecutive *moving* echoes may fail to close on an outstanding
94
+ * setpoint before the controller gives up and adopts what the camera
95
+ * reports. Catches something else owning the camera (another operator, or
96
+ * auto-track) without waiting out the full timeout.
97
+ * Default: 2.
98
+ */
99
+ settleStallEchoes?: number;
100
+ /**
101
+ * Hard cap (ms) on how long a setpoint is held against echoes. Backstop for
102
+ * a camera that parks short of the setpoint and then reports the same
103
+ * position forever (a bounds mismatch would do it), which no
104
+ * progress-based rule can detect. Sized for the worst real case: 360
105
+ * degrees of yaw at 90 deg/s plus the plugin's ~1Hz status write.
106
+ * Default: 6000.
107
+ */
108
+ settleTimeoutMs?: number;
84
109
  }
85
110
 
86
111
  // ---------------------------------------------------------------------------
@@ -95,6 +120,27 @@ function applyDeadzone(v: number, deadzone: number): number {
95
120
  return Math.abs(v) < deadzone ? 0 : v;
96
121
  }
97
122
 
123
+ /*
124
+ Bookkeeping for an absolute setpoint the camera has been asked for but has
125
+ not been observed to reach. The plugin slews toward a setpoint at a fixed
126
+ rate (90 deg/s of yaw, 60 deg/s of FoV) and reports its position at ~1Hz, so
127
+ echoes arriving during the slew carry positions the camera has already left.
128
+ Feeding those to the accumulator would re-base the next nudge on a stale
129
+ position and lose travel.
130
+ */
131
+ interface Settling {
132
+ /* Remaining distance measured at the previous echo; null before the first. */
133
+ lastDistance: number | null;
134
+ /* Consecutive moving echoes that failed to close on the setpoint. */
135
+ stalls: number;
136
+ /* Wall clock at issue, for the timeout backstop. */
137
+ issuedAt: number;
138
+ }
139
+
140
+ function newSettling(): Settling {
141
+ return { lastDistance: null, stalls: 0, issuedAt: Date.now() };
142
+ }
143
+
98
144
  // ---------------------------------------------------------------------------
99
145
  // PanZoomController
100
146
  // ---------------------------------------------------------------------------
@@ -111,6 +157,9 @@ export class PanZoomController {
111
157
  private readonly fovNudgeDeg: number;
112
158
  private readonly fovSliderDebounceMs: number;
113
159
  private readonly analogDeadzone: number;
160
+ private readonly settleEpsilonDeg: number;
161
+ private readonly settleStallEchoes: number;
162
+ private readonly settleTimeoutMs: number;
114
163
 
115
164
  // Last-sent rates (dedupe against re-sending the same value).
116
165
  private sentPanRate = { yaw: 0, pitch: 0 };
@@ -120,6 +169,16 @@ export class PanZoomController {
120
169
  private localPan = { yaw: 0, pitch: 0 };
121
170
  private localFov = 0;
122
171
 
172
+ /*
173
+ Outstanding absolute setpoints. Non-null from the moment a setPan / setFov
174
+ goes out until an echo shows the camera got there (or one of `settled`'s
175
+ give-up rules fires). While one is outstanding the accumulator holds the
176
+ setpoint and echoes do not touch it.
177
+ */
178
+ private panSetpoint: (Settling & { yaw: number; pitch: number }) | null =
179
+ null;
180
+ private fovSetpoint: (Settling & { fov: number }) | null = null;
181
+
123
182
  // FoV slider state.
124
183
  private _sliderFov = 0;
125
184
  private sliderDragging = false;
@@ -148,6 +207,9 @@ export class PanZoomController {
148
207
  this.fovNudgeDeg = opts.fovNudgeDeg ?? 5;
149
208
  this.fovSliderDebounceMs = opts.fovSliderDebounceMs ?? 120;
150
209
  this.analogDeadzone = opts.analogDeadzone ?? 0.05;
210
+ this.settleEpsilonDeg = opts.settleEpsilonDeg ?? 0.5;
211
+ this.settleStallEchoes = opts.settleStallEchoes ?? 2;
212
+ this.settleTimeoutMs = opts.settleTimeoutMs ?? 6000;
151
213
  }
152
214
 
153
215
  // ---------------------------------------------------------------------------
@@ -174,6 +236,12 @@ export class PanZoomController {
174
236
  * Applies the echoed FoV to the accumulator and slider only when the
175
237
  * controller is zoom-idle (zoom rate 0, slider not dragging, no pending
176
238
  * debounced send). Mirrors CameraFeed.tsx:370-387.
239
+ *
240
+ * An echo is also held off while an absolute setpoint we sent is still
241
+ * outstanding: the plugin slews toward a setpoint and reports its position
242
+ * at ~1Hz, so echoes in that window carry positions the camera has already
243
+ * left, and adopting one would re-base the next nudge behind where the
244
+ * camera is going.
177
245
  */
178
246
  syncFromState(
179
247
  state: { fov: number; panYaw: number; panPitch: number } & PanZoomBounds,
@@ -191,7 +259,7 @@ export class PanZoomController {
191
259
  !this.ballDragging &&
192
260
  this.sentPanRate.yaw === 0 &&
193
261
  this.sentPanRate.pitch === 0;
194
- if (panIdle) {
262
+ if (panIdle && this.panEchoWins(state.panYaw, state.panPitch)) {
195
263
  this.localPan = { yaw: state.panYaw, pitch: state.panPitch };
196
264
  }
197
265
 
@@ -199,7 +267,7 @@ export class PanZoomController {
199
267
  this.sentZoomRate === 0 &&
200
268
  !this.sliderDragging &&
201
269
  this.pendingFov === null;
202
- if (zoomIdle) {
270
+ if (zoomIdle && this.fovEchoWins(state.fov)) {
203
271
  this.localFov = state.fov;
204
272
  this.setSliderFovInternal(state.fov);
205
273
  }
@@ -219,6 +287,9 @@ export class PanZoomController {
219
287
  const p = applyDeadzone(clamp(pitch, -1, 1), this.analogDeadzone);
220
288
  if (y === this.sentPanRate.yaw && p === this.sentPanRate.pitch) return;
221
289
  this.sentPanRate = { yaw: y, pitch: p };
290
+ // A rate supersedes any absolute we were still waiting on, including the
291
+ // zero that ends the hold: the camera is wherever the rate left it.
292
+ this.panSetpoint = null;
222
293
  void this.sink.setPanRate(y, p);
223
294
  }
224
295
 
@@ -245,6 +316,7 @@ export class PanZoomController {
245
316
  const r = applyDeadzone(clamp(rate, -1, 1), this.analogDeadzone);
246
317
  if (r === this.sentZoomRate) return;
247
318
  this.sentZoomRate = r;
319
+ this.fovSetpoint = null; // same reasoning as setPanRate
248
320
  void this.sink.setZoomRate(r);
249
321
  }
250
322
 
@@ -269,6 +341,11 @@ export class PanZoomController {
269
341
  b.panPitchMin,
270
342
  b.panPitchMax,
271
343
  );
344
+ this.panSetpoint = {
345
+ yaw: this.localPan.yaw,
346
+ pitch: this.localPan.pitch,
347
+ ...newSettling(),
348
+ };
272
349
  void this.sink.setPan(this.localPan.yaw, this.localPan.pitch);
273
350
  }
274
351
 
@@ -285,6 +362,7 @@ export class PanZoomController {
285
362
  b.fovMax,
286
363
  );
287
364
  this.localFov = next;
365
+ this.fovSetpoint = { fov: next, ...newSettling() };
288
366
  void this.sink.setFov(next);
289
367
  }
290
368
 
@@ -342,6 +420,8 @@ export class PanZoomController {
342
420
  */
343
421
  setBallDragging(dragging: boolean): void {
344
422
  this.ballDragging = dragging;
423
+ // Taking the ball abandons any absolute we were waiting on.
424
+ if (dragging) this.panSetpoint = null;
345
425
  }
346
426
 
347
427
  // ---------------------------------------------------------------------------
@@ -365,6 +445,9 @@ export class PanZoomController {
365
445
  }
366
446
  this.ballDragging = false;
367
447
  this.sliderDragging = false;
448
+ // Nothing is being commanded any more, so echoes win again.
449
+ this.panSetpoint = null;
450
+ this.fovSetpoint = null;
368
451
  if (this.fovDebounceTimer !== null) {
369
452
  clearTimeout(this.fovDebounceTimer);
370
453
  this.fovDebounceTimer = null;
@@ -394,10 +477,59 @@ export class PanZoomController {
394
477
  const fov = clamp(this.pendingFov, this.bounds.fovMin, this.bounds.fovMax);
395
478
  this.localFov = fov;
396
479
  this.pendingFov = null;
480
+ this.fovSetpoint = { fov, ...newSettling() };
397
481
  void this.sink.setFov(fov);
398
482
  }
399
483
  }
400
484
 
485
+ /*
486
+ Whether an echoed pan should be adopted into the accumulator. True when
487
+ nothing is outstanding, or when the outstanding setpoint has been settled
488
+ one way or another (in which case it is cleared and the camera's own
489
+ reading wins from here).
490
+ */
491
+ private panEchoWins(yaw: number, pitch: number): boolean {
492
+ const sp = this.panSetpoint;
493
+ if (sp === null) return true;
494
+ const distance = Math.max(
495
+ Math.abs(yaw - sp.yaw),
496
+ Math.abs(pitch - sp.pitch),
497
+ );
498
+ if (!this.settled(sp, distance)) return false;
499
+ this.panSetpoint = null;
500
+ return true;
501
+ }
502
+
503
+ /* `panEchoWins` for FoV. */
504
+ private fovEchoWins(fov: number): boolean {
505
+ const sp = this.fovSetpoint;
506
+ if (sp === null) return true;
507
+ if (!this.settled(sp, Math.abs(fov - sp.fov))) return false;
508
+ this.fovSetpoint = null;
509
+ return true;
510
+ }
511
+
512
+ /*
513
+ Give up on an outstanding setpoint when the camera arrived, when it is
514
+ moving but no longer closing on it (something else owns the camera: another
515
+ operator, or auto-track), or when the timeout backstop expires (it parked
516
+ short and is reporting the same position forever, which no progress-based
517
+ rule can see). Mutates the settling bookkeeping.
518
+ */
519
+ private settled(sp: Settling, distance: number): boolean {
520
+ const eps = this.settleEpsilonDeg;
521
+ if (distance <= eps) return true;
522
+ if (Date.now() - sp.issuedAt >= this.settleTimeoutMs) return true;
523
+ const prev = sp.lastDistance;
524
+ sp.lastDistance = distance;
525
+ if (prev === null) return false;
526
+ const moved = Math.abs(prev - distance) > eps;
527
+ const closing = distance < prev - eps;
528
+ if (!moved) return false;
529
+ sp.stalls = closing ? 0 : sp.stalls + 1;
530
+ return sp.stalls >= this.settleStallEchoes;
531
+ }
532
+
401
533
  private setSliderFovInternal(fov: number): void {
402
534
  this._sliderFov = fov;
403
535
  for (const cb of this.sliderFovListeners) cb(fov);