uzor-graph 1.5.1

Reusable force-directed graph visualization engine for uzor — generic node/edge model, Barnes-Hut force simulation, camera, native drag/pick interaction, and an agent-api blackbox surface.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
//! `FlyController` — generic WASD/arrow-key + captured-mouse fly
//! navigation for any [`crate::camera3d::Camera3D`] consumer.
//!
//! Lifted from `foxhound-app-shell-native`'s own game-style navigation
//! loop (`nemo/foxhound/crates/foxhound-app-shell-native/src/main.rs`'s
//! `MovementKeys` + `foxhound-app/src/lib.rs`'s `tick_navigation`/
//! `set_navigation_input`/`free_look_view` — see
//! `nemo/docs/uzor-engines/research_foxhound_lift_candidates.md` §2)
//! per the LIFT-WITH-REWORK verdict there: the key-state tracker, the
//! exponential ease-in/ease-out inertia model, and the keyboard/mouse
//! sensitivity scalars are 100% generic — zero Foxhound-specific types —
//! and are reproduced here unchanged in their math. What did NOT come
//! along is Foxhound's `NavigationFrame::Axes` semantic (an axis-locked
//! time/entity-lane pan interpretation that is domain-specific to that
//! app's own forensic projection): this controller's only built-in pan
//! style is the source app's `NavigationFrame::Viewport` behavior —
//! [`Camera3D::translate_local`] — which was already 100% generic. A
//! caller that wants a different semantic mapping (e.g. an axis-locked
//! pan) should read [`FlyController::velocity`] after calling
//! [`FlyController::tick`] and apply its own dispatch instead of calling
//! `tick`'s camera-mutating form.
//!
//! Generic API: feed [`uzor::input::PlatformEvent`] key/pointer-delta
//! events via [`FlyController::on_event`] (or the finer-grained
//! [`FlyController::set_key`]/[`FlyController::apply_look_delta`]), then
//! call [`FlyController::tick`] once per frame to advance inertia and
//! move a `&mut Camera3D`.

use uzor::input::{KeyCode, PlatformEvent};

use crate::camera3d::Camera3D;

/// Legacy-arrow-step convention, matching the source app's own comment:
/// velocity is expressed as "steps per second" so the resulting distance
/// math (which multiplies by `camera.distance`, not a fixed world unit)
/// keeps working across wildly different scene scales. Mirrored into
/// [`FlyControlConfig`]'s own `Default` below (graph-strengthening arc
/// Wave G2b — the 3D audit's own configurability inventory flagged every
/// one of these as having no caller-facing override).
const NAVIGATION_BASE_STEPS_PER_SECOND: f32 = 4.0;
const NAVIGATION_ACCEL_RESPONSE: f32 = 18.0;
const NAVIGATION_DECEL_RESPONSE: f32 = 22.0;
const NAVIGATION_STOP_EPSILON: f32 = 0.005;
/// `tick`'s own `dt` clamp — guards against a huge inertia jump after a
/// stall (e.g. a debugger breakpoint or a dropped frame), same value the
/// source app used.
const MAX_TICK_DT: f32 = 0.05;

/// Strafe (local-right) distance scale, applied to `velocity[0] * dt *
/// camera.distance` — the source app's own `VIEWPORT_STRAFE_DISTANCE`.
const STRAFE_DISTANCE_SCALE: f32 = 0.12;
/// Forward/back (local-forward) distance scale — the source app's own
/// `VIEWPORT_ARROW_FORWARD_DISTANCE`.
const FORWARD_DISTANCE_SCALE: f32 = 0.08;

pub const KEYBOARD_SENSITIVITY_MIN: f32 = 0.25;
pub const KEYBOARD_SENSITIVITY_MAX: f32 = 2.5;
pub const MOUSE_SENSITIVITY_MIN: f32 = 0.2;
pub const MOUSE_SENSITIVITY_MAX: f32 = 2.5;

/// Every fly-navigation "feel" constant this controller owns, bundled
/// into one caller-configurable struct (graph-strengthening arc Wave
/// G2b) — mirrors [`crate::engine::GraphInteractionConfig`]'s own "bundle
/// every constant belonging to one interaction subsystem" shape.
/// [`Default`] reproduces the module constants above byte-identically.
/// One `config: FlyControlConfig` field on [`FlyController`] with a
/// [`FlyController::config`]/[`FlyController::set_config`] accessor pair
/// — the same established shape [`crate::engine::GraphEngine::label_halo`]/
/// `set_label_halo` already use.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FlyControlConfig {
    /// Base inertial-velocity target, "legacy arrow steps per second" —
    /// was [`NAVIGATION_BASE_STEPS_PER_SECOND`].
    pub base_steps_per_second: f32,
    /// Exponential ease-IN response rate while a key is held — was
    /// [`NAVIGATION_ACCEL_RESPONSE`].
    pub accel_response: f32,
    /// Exponential ease-OUT response rate once a key releases — was
    /// [`NAVIGATION_DECEL_RESPONSE`].
    pub decel_response: f32,
    /// Velocity snap-to-zero floor — was [`NAVIGATION_STOP_EPSILON`].
    pub stop_epsilon: f32,
    /// [`FlyController::tick`]'s own `dt` clamp — was [`MAX_TICK_DT`].
    pub max_tick_dt: f32,
    /// Strafe (local-right) distance-per-tick scale — was
    /// [`STRAFE_DISTANCE_SCALE`].
    pub strafe_distance_scale: f32,
    /// Forward/back (local-forward) distance-per-tick scale — was
    /// [`FORWARD_DISTANCE_SCALE`].
    pub forward_distance_scale: f32,
    /// [`FlyController::set_keyboard_sensitivity`]'s own clamp range —
    /// were [`KEYBOARD_SENSITIVITY_MIN`]/[`KEYBOARD_SENSITIVITY_MAX`].
    pub keyboard_sensitivity_min: f32,
    pub keyboard_sensitivity_max: f32,
    /// [`FlyController::set_mouse_sensitivity`]'s own clamp range — were
    /// [`MOUSE_SENSITIVITY_MIN`]/[`MOUSE_SENSITIVITY_MAX`].
    pub mouse_sensitivity_min: f32,
    pub mouse_sensitivity_max: f32,
}

impl Default for FlyControlConfig {
    fn default() -> Self {
        Self {
            base_steps_per_second: NAVIGATION_BASE_STEPS_PER_SECOND,
            accel_response: NAVIGATION_ACCEL_RESPONSE,
            decel_response: NAVIGATION_DECEL_RESPONSE,
            stop_epsilon: NAVIGATION_STOP_EPSILON,
            max_tick_dt: MAX_TICK_DT,
            strafe_distance_scale: STRAFE_DISTANCE_SCALE,
            forward_distance_scale: FORWARD_DISTANCE_SCALE,
            keyboard_sensitivity_min: KEYBOARD_SENSITIVITY_MIN,
            keyboard_sensitivity_max: KEYBOARD_SENSITIVITY_MAX,
            mouse_sensitivity_min: MOUSE_SENSITIVITY_MIN,
            mouse_sensitivity_max: MOUSE_SENSITIVITY_MAX,
        }
    }
}

/// Held-key state for WASD/arrow-key navigation — generic left/right +
/// forward/backward axis tracking, lifted verbatim from the source app's
/// own `MovementKeys`.
#[derive(Debug, Default, Clone, Copy)]
struct MovementKeys {
    left: bool,
    right: bool,
    forward: bool,
    backward: bool,
}

impl MovementKeys {
    /// Update held state for a recognized WASD/arrow key. Returns `false`
    /// (and leaves state untouched) for any other key.
    fn set(&mut self, key: KeyCode, pressed: bool) -> bool {
        let target = match key {
            KeyCode::ArrowLeft | KeyCode::A => &mut self.left,
            KeyCode::ArrowRight | KeyCode::D => &mut self.right,
            KeyCode::ArrowUp | KeyCode::W => &mut self.forward,
            KeyCode::ArrowDown | KeyCode::S => &mut self.backward,
            _ => return false,
        };
        *target = pressed;
        true
    }

    /// `(horizontal, vertical)` in `[-1, 1]` each — opposite keys held
    /// together cancel to `0.0`.
    fn vector(&self) -> (f32, f32) {
        let horizontal = f32::from(u8::from(self.right)) - f32::from(u8::from(self.left));
        let vertical = f32::from(u8::from(self.forward)) - f32::from(u8::from(self.backward));
        (horizontal, vertical)
    }

    fn clear(&mut self) {
        *self = Self::default();
    }
}

/// Game-style WASD/arrow-key + captured-mouse fly navigation over a
/// [`Camera3D`] — see the module doc for exactly what was lifted and
/// what stayed app-side.
#[derive(Debug, Clone, Copy)]
pub struct FlyController {
    movement_keys: MovementKeys,
    /// Clamped 2D held-intent vector (`[horizontal, vertical]`), each in
    /// `[-1, 1]`, diagonal-normalized to one input unit.
    input: [f32; 2],
    /// Inertial "legacy arrow steps per second" velocity — see
    /// [`NAVIGATION_BASE_STEPS_PER_SECOND`]'s own doc comment.
    velocity: [f32; 2],
    keyboard_sensitivity: f32,
    mouse_sensitivity: f32,
    /// Every "feel" constant this controller ticks against (graph-
    /// strengthening arc Wave G2b) — see [`FlyController::config`]/
    /// [`FlyController::set_config`].
    config: FlyControlConfig,
}

impl Default for FlyController {
    fn default() -> Self {
        Self {
            movement_keys: MovementKeys::default(),
            input: [0.0; 2],
            velocity: [0.0; 2],
            keyboard_sensitivity: 1.0,
            mouse_sensitivity: 1.0,
            config: FlyControlConfig::default(),
        }
    }
}

impl FlyController {
    pub fn new() -> Self {
        Self::default()
    }

    /// Current held 2D intent (`[horizontal, vertical]`), post diagonal
    /// normalization — mostly useful for tests/telemetry.
    pub fn input(&self) -> [f32; 2] {
        self.input
    }

    /// Current inertial velocity (`[horizontal, vertical]`, legacy
    /// "arrow steps per second" units) — exposed so a caller can publish
    /// it (e.g. over agent-api) or build its own pan dispatch on top of
    /// [`FlyController::tick`]'s per-tick step instead of the built-in
    /// `translate_local` mapping.
    pub fn velocity(&self) -> [f32; 2] {
        self.velocity
    }

    pub fn keyboard_sensitivity(&self) -> f32 {
        self.keyboard_sensitivity
    }

    pub fn set_keyboard_sensitivity(&mut self, sensitivity: f32) {
        self.keyboard_sensitivity = sensitivity.clamp(self.config.keyboard_sensitivity_min, self.config.keyboard_sensitivity_max);
    }

    pub fn mouse_sensitivity(&self) -> f32 {
        self.mouse_sensitivity
    }

    pub fn set_mouse_sensitivity(&mut self, sensitivity: f32) {
        self.mouse_sensitivity = sensitivity.clamp(self.config.mouse_sensitivity_min, self.config.mouse_sensitivity_max);
    }

    /// Current fly-control "feel" config (graph-strengthening arc Wave
    /// G2b) — see [`FlyControlConfig`]'s own doc comment. Default:
    /// [`FlyControlConfig::default`], byte-identical to this controller's
    /// pre-existing hardcoded constants.
    pub fn config(&self) -> &FlyControlConfig {
        &self.config
    }

    /// Replace this controller's fly-control config — re-clamps the
    /// current keyboard/mouse sensitivity against the NEW bounds
    /// immediately (a caller narrowing the range must not leave a
    /// stale, now out-of-range sensitivity value in place).
    pub fn set_config(&mut self, config: FlyControlConfig) {
        self.config = config;
        self.keyboard_sensitivity = self.keyboard_sensitivity.clamp(self.config.keyboard_sensitivity_min, self.config.keyboard_sensitivity_max);
        self.mouse_sensitivity = self.mouse_sensitivity.clamp(self.config.mouse_sensitivity_min, self.config.mouse_sensitivity_max);
    }

    /// Track a held WASD/arrow key and fold it into the current 2D
    /// intent — fuses the source app's own `MovementKeys::update` +
    /// `set_navigation_input` into one call. Returns `true` if `key` was
    /// part of the navigation key set (a caller's `on_event` can
    /// early-return on `true`, exactly like the source app did).
    pub fn set_key(&mut self, key: KeyCode, pressed: bool) -> bool {
        if !self.movement_keys.set(key, pressed) {
            return false;
        }
        let (horizontal, vertical) = self.movement_keys.vector();
        self.set_input(horizontal, vertical);
        true
    }

    /// Directly set held intent, bypassing key tracking — e.g. for a
    /// caller driving navigation from a virtual joystick/gamepad stick.
    /// Diagonals are normalized to one input unit (the source app's own
    /// `set_navigation_input`).
    pub fn set_input(&mut self, horizontal: f32, vertical: f32) {
        let mut input = [horizontal.clamp(-1.0, 1.0), vertical.clamp(-1.0, 1.0)];
        let length = (input[0] * input[0] + input[1] * input[1]).sqrt();
        if length > 1.0 {
            input[0] /= length;
            input[1] /= length;
        }
        self.input = input;
    }

    /// Immediately clear held keys, intent, and inertia — call this on
    /// focus loss (or anywhere a `KeyUp` might be missed) so the camera
    /// can never keep drifting. Mirrors the source app's own
    /// `stop_movement`/`stop_navigation` pair.
    pub fn stop(&mut self) {
        self.movement_keys.clear();
        self.input = [0.0; 2];
        self.velocity = [0.0; 2];
    }

    /// Apply captured-mouse motion as free look, scaled by
    /// [`FlyController::mouse_sensitivity`] — the source app's own
    /// `free_look_view`.
    pub fn apply_look_delta(&self, delta_x: f32, delta_y: f32, camera: &mut Camera3D) {
        camera.free_look(-delta_x * self.mouse_sensitivity, delta_y * self.mouse_sensitivity);
    }

    /// Advance inertial keyboard navigation with exponential ease-in/
    /// ease-out (the source app's own `tick_navigation`), then dispatch
    /// the resulting per-axis step into [`Camera3D::translate_local`] —
    /// strafe on the local-right axis, forward/back on the local-forward
    /// axis. This IS the controller's one built-in pan style (the source
    /// app's `NavigationFrame::Viewport`) — see the module doc for why a
    /// different semantic mapping stays entirely the caller's concern.
    pub fn tick(&mut self, dt: f32, camera: &mut Camera3D) {
        let dt = dt.clamp(0.0, self.config.max_tick_dt);
        for axis in 0..2 {
            let target = self.input[axis] * self.config.base_steps_per_second * self.keyboard_sensitivity;
            let response = if self.input[axis].abs() > f32::EPSILON { self.config.accel_response } else { self.config.decel_response };
            let blend = 1.0 - (-response * dt).exp();
            self.velocity[axis] += (target - self.velocity[axis]) * blend;
            if target == 0.0 && self.velocity[axis].abs() < self.config.stop_epsilon {
                self.velocity[axis] = 0.0;
            }
        }

        let strafe_step = self.velocity[0] * dt;
        let forward_step = self.velocity[1] * dt;
        if strafe_step != 0.0 || forward_step != 0.0 {
            let strafe_distance = strafe_step * camera.distance * self.config.strafe_distance_scale;
            let forward_distance = forward_step * camera.distance * self.config.forward_distance_scale;
            camera.translate_local(strafe_distance, 0.0, forward_distance);
        }
    }

    /// Ergonomic single ingestion point for the two event kinds this
    /// controller cares about: `KeyDown`/`KeyUp` always update held
    /// intent (returns `true` only for a recognized WASD/arrow key);
    /// `PointerDelta` applies free look ONLY while `mouse_look_active`
    /// (the caller's own cursor-capture gate — this controller has no
    /// opinion on `CursorCaptureMode`). Every other event is ignored
    /// (`false`). A caller that wants finer control (e.g. driving intent
    /// from a source other than `PlatformEvent`) should call
    /// [`FlyController::set_key`]/[`FlyController::apply_look_delta`]
    /// directly instead.
    pub fn on_event(&mut self, event: &PlatformEvent, mouse_look_active: bool, camera: &mut Camera3D) -> bool {
        match event {
            PlatformEvent::KeyDown { key, .. } => self.set_key(*key, true),
            PlatformEvent::KeyUp { key, .. } => self.set_key(*key, false),
            PlatformEvent::PointerDelta { dx, dy } if mouse_look_active => {
                self.apply_look_delta(*dx as f32, *dy as f32, camera);
                true
            }
            _ => false,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use uzor::input::ModifierKeys;

    fn key_down(key: KeyCode) -> PlatformEvent {
        PlatformEvent::KeyDown { key, modifiers: ModifierKeys::default() }
    }

    fn key_up(key: KeyCode) -> PlatformEvent {
        PlatformEvent::KeyUp { key, modifiers: ModifierKeys::default() }
    }

    fn converge(controller: &mut FlyController, camera: &mut Camera3D, seconds: f32) {
        let dt = 1.0 / 60.0;
        let mut elapsed = 0.0;
        while elapsed < seconds {
            controller.tick(dt, camera);
            elapsed += dt;
        }
    }

    #[test]
    fn set_key_recognizes_wasd_and_arrow_variants_and_rejects_other_keys() {
        let mut controller = FlyController::new();
        assert!(controller.set_key(KeyCode::W, true));
        assert_eq!(controller.input(), [0.0, 1.0]);
        assert!(controller.set_key(KeyCode::ArrowRight, true));
        assert!((controller.input()[0] - 1.0 / 2.0f32.sqrt()).abs() < 1e-6);
        assert!(!controller.set_key(KeyCode::Escape, true), "a non-navigation key must not be consumed");
    }

    #[test]
    fn opposite_keys_cancel_to_zero_input_and_settle_to_zero_velocity() {
        let mut controller = FlyController::new();
        let mut camera = Camera3D::default();
        controller.set_key(KeyCode::W, true);
        controller.set_key(KeyCode::S, true);
        assert_eq!(controller.input(), [0.0, 0.0], "forward+backward held together must cancel");

        converge(&mut controller, &mut camera, 1.0);
        assert!(controller.velocity()[1].abs() < 1e-4, "velocity must settle to zero with cancelled input");
        assert!(camera.target.length() < 1e-3, "camera must not drift with cancelled input");
    }

    #[test]
    fn pressing_forward_then_ticking_moves_the_camera_along_its_own_forward_axis() {
        let mut controller = FlyController::new();
        let mut camera = Camera3D::default();
        let forward_axis = camera.forward();

        controller.set_key(KeyCode::W, true);
        converge(&mut controller, &mut camera, 1.0);

        assert!(controller.velocity()[1] > 0.0, "forward velocity must build up under held W");
        assert!(
            camera.target.dot(forward_axis) > 0.0,
            "the camera must have moved along its own (orientation-invariant) forward axis"
        );
    }

    #[test]
    fn releasing_a_key_decays_velocity_via_inertia_instead_of_snapping_to_zero() {
        let mut controller = FlyController::new();
        let mut camera = Camera3D::default();
        controller.set_key(KeyCode::W, true);
        converge(&mut controller, &mut camera, 1.0);
        let steady_state = controller.velocity()[1];
        assert!(steady_state > 0.0);

        controller.set_key(KeyCode::W, false);
        controller.tick(1.0 / 60.0, &mut camera);
        let after_one_tick = controller.velocity()[1];

        assert!(after_one_tick > 0.0, "velocity must not snap to zero the instant the key releases");
        assert!(after_one_tick < steady_state, "velocity must be decaying toward zero after release");
    }

    #[test]
    fn keyboard_sensitivity_scales_the_converged_steady_state_velocity() {
        let mut default_controller = FlyController::new();
        let mut default_camera = Camera3D::default();
        default_controller.set_key(KeyCode::W, true);
        converge(&mut default_controller, &mut default_camera, 2.0);

        let mut fast_controller = FlyController::new();
        fast_controller.set_keyboard_sensitivity(2.0);
        let mut fast_camera = Camera3D::default();
        fast_controller.set_key(KeyCode::W, true);
        converge(&mut fast_controller, &mut fast_camera, 2.0);

        let ratio = fast_controller.velocity()[1] / default_controller.velocity()[1];
        assert!((ratio - 2.0).abs() < 1e-2, "2x keyboard sensitivity must converge to ~2x the steady-state velocity, got ratio {ratio}");
    }

    #[test]
    fn set_keyboard_sensitivity_clamps_to_the_documented_range() {
        let mut controller = FlyController::new();
        controller.set_keyboard_sensitivity(100.0);
        assert_eq!(controller.keyboard_sensitivity(), KEYBOARD_SENSITIVITY_MAX);
        controller.set_keyboard_sensitivity(-5.0);
        assert_eq!(controller.keyboard_sensitivity(), KEYBOARD_SENSITIVITY_MIN);
    }

    #[test]
    fn set_mouse_sensitivity_clamps_to_the_documented_range() {
        let mut controller = FlyController::new();
        controller.set_mouse_sensitivity(100.0);
        assert_eq!(controller.mouse_sensitivity(), MOUSE_SENSITIVITY_MAX);
        controller.set_mouse_sensitivity(-5.0);
        assert_eq!(controller.mouse_sensitivity(), MOUSE_SENSITIVITY_MIN);
    }

    #[test]
    fn apply_look_delta_scales_the_resulting_yaw_change_by_mouse_sensitivity() {
        let low = FlyController::new();
        let mut high = FlyController::new();
        high.set_mouse_sensitivity(2.0);

        let mut camera_low = Camera3D::default();
        let mut camera_high = Camera3D::default();
        low.apply_look_delta(30.0, 5.0, &mut camera_low);
        high.apply_look_delta(30.0, 5.0, &mut camera_high);

        let yaw_delta_low = camera_low.yaw - Camera3D::default().yaw;
        let yaw_delta_high = camera_high.yaw - Camera3D::default().yaw;
        assert!(yaw_delta_low.abs() > 0.0);
        let ratio = yaw_delta_high / yaw_delta_low;
        assert!((ratio - 2.0).abs() < 1e-4, "2x mouse sensitivity must double the resulting yaw delta, got ratio {ratio}");
    }

    #[test]
    fn set_input_normalizes_a_diagonal_to_unit_length() {
        let mut controller = FlyController::new();
        controller.set_input(1.0, 1.0);
        let [h, v] = controller.input();
        assert!((h * h + v * v).sqrt() <= 1.0 + 1e-6, "a diagonal input must be clamped to unit length");
        assert!((h - v).abs() < 1e-6, "diagonal normalization must preserve the original direction");
    }

    #[test]
    fn stop_immediately_clears_keys_input_and_velocity() {
        let mut controller = FlyController::new();
        let mut camera = Camera3D::default();
        controller.set_key(KeyCode::W, true);
        converge(&mut controller, &mut camera, 1.0);
        assert!(controller.velocity()[1] > 0.0);

        controller.stop();
        assert_eq!(controller.input(), [0.0, 0.0]);
        assert_eq!(controller.velocity(), [0.0, 0.0]);

        let target_before = camera.target;
        controller.tick(1.0 / 60.0, &mut camera);
        assert_eq!(camera.target, target_before, "a tick after stop() with no held keys must not move the camera");
    }

    #[test]
    fn fly_control_config_defaults_to_the_prior_hardcoded_constants() {
        let config = FlyControlConfig::default();
        assert_eq!(config.base_steps_per_second, NAVIGATION_BASE_STEPS_PER_SECOND);
        assert_eq!(config.accel_response, NAVIGATION_ACCEL_RESPONSE);
        assert_eq!(config.decel_response, NAVIGATION_DECEL_RESPONSE);
        assert_eq!(config.stop_epsilon, NAVIGATION_STOP_EPSILON);
        assert_eq!(config.max_tick_dt, MAX_TICK_DT);
        assert_eq!(config.strafe_distance_scale, STRAFE_DISTANCE_SCALE);
        assert_eq!(config.forward_distance_scale, FORWARD_DISTANCE_SCALE);
        assert_eq!(config.keyboard_sensitivity_min, KEYBOARD_SENSITIVITY_MIN);
        assert_eq!(config.keyboard_sensitivity_max, KEYBOARD_SENSITIVITY_MAX);
        assert_eq!(config.mouse_sensitivity_min, MOUSE_SENSITIVITY_MIN);
        assert_eq!(config.mouse_sensitivity_max, MOUSE_SENSITIVITY_MAX);
        assert_eq!(*FlyController::new().config(), config, "a fresh controller must start on the default config");
    }

    #[test]
    fn set_config_changes_the_converged_steady_state_velocity() {
        let mut default_controller = FlyController::new();
        let mut default_camera = Camera3D::default();
        default_controller.set_key(KeyCode::W, true);
        converge(&mut default_controller, &mut default_camera, 2.0);

        let mut fast_controller = FlyController::new();
        fast_controller.set_config(FlyControlConfig { base_steps_per_second: 8.0, ..FlyControlConfig::default() });
        let mut fast_camera = Camera3D::default();
        fast_controller.set_key(KeyCode::W, true);
        converge(&mut fast_controller, &mut fast_camera, 2.0);

        let ratio = fast_controller.velocity()[1] / default_controller.velocity()[1];
        assert!((ratio - 2.0).abs() < 1e-2, "doubling base_steps_per_second must double the converged steady-state velocity, got ratio {ratio}");
    }

    #[test]
    fn set_config_re_clamps_the_current_sensitivity_against_the_new_bounds() {
        let mut controller = FlyController::new();
        controller.set_keyboard_sensitivity(2.0);
        assert_eq!(controller.keyboard_sensitivity(), 2.0);

        controller.set_config(FlyControlConfig { keyboard_sensitivity_max: 1.0, ..FlyControlConfig::default() });
        assert_eq!(controller.keyboard_sensitivity(), 1.0, "a narrowed max must re-clamp the already-set sensitivity, not leave it stale and out of range");
    }

    #[test]
    fn set_keyboard_sensitivity_clamps_against_a_custom_configs_own_bounds() {
        let mut controller = FlyController::new();
        controller.set_config(FlyControlConfig { keyboard_sensitivity_min: 0.5, keyboard_sensitivity_max: 3.0, ..FlyControlConfig::default() });
        controller.set_keyboard_sensitivity(10.0);
        assert_eq!(controller.keyboard_sensitivity(), 3.0);
        controller.set_keyboard_sensitivity(0.0);
        assert_eq!(controller.keyboard_sensitivity(), 0.5);
    }

    #[test]
    fn on_event_consumes_key_events_and_gated_pointer_delta_only() {
        let mut controller = FlyController::new();
        let mut camera = Camera3D::default();

        assert!(controller.on_event(&key_down(KeyCode::W), false, &mut camera));
        assert_eq!(controller.input(), [0.0, 1.0]);

        let camera_before = camera;
        assert!(!controller.on_event(&PlatformEvent::PointerDelta { dx: 10.0, dy: 0.0 }, false, &mut camera), "pointer delta must be ignored while mouse look is not active");
        assert_eq!(camera.yaw, camera_before.yaw);

        assert!(controller.on_event(&PlatformEvent::PointerDelta { dx: 10.0, dy: 0.0 }, true, &mut camera));
        assert_ne!(camera.yaw, camera_before.yaw, "pointer delta must apply free look once mouse look is active");

        assert!(controller.on_event(&key_up(KeyCode::W), false, &mut camera));
        assert_eq!(controller.input(), [0.0, 0.0]);

        assert!(!controller.on_event(&PlatformEvent::Scroll { dx: 0.0, dy: 1.0 }, true, &mut camera), "an unrelated event must not be consumed");
    }
}