@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 +10 -0
- package/dist/panZoom.d.ts +40 -1
- package/dist/panZoom.js +90 -3
- package/package.json +1 -1
- package/src/panZoom.test.ts +211 -0
- package/src/panZoom.ts +135 -3
package/CHANGELOG.md
CHANGED
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.
|
|
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",
|
package/src/panZoom.test.ts
CHANGED
|
@@ -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);
|