From 14efc21556189b60ceba7facf9bd5199e0665acc Mon Sep 17 00:00:00 2001 From: Leftos Aslanoglou Date: Wed, 21 Oct 2026 15:01:22 -0700 Subject: [PATCH 1/3] feat: drive vNAS ground contact from height above ground vNAS reports each aircraft's height above ground at 1 Hz. The timeline now carries it to the displayed instant, desktop and remote browsers alike, and the interpolation turns it into ground contact (in below 3 ft, out above 5 ft): in contact the aircraft sits on the sampled terrain, airborne it is drawn at the reported height. The vNAS 50/80 ft ground flag stays for labels; an observation without a height gives no drawing ground flag, so old replays keep the landing blends. The gear is down in contact and retracts once per contact on positive climb after 2 s out of contact, over 8 s; the 150 ft rule takes over above 150 ft, after a backward seek, and for aircraft never seen in contact. The motion suite covers contact, the gear rule, the height and heightless vNAS feeds. Refs: TC3D-50 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + USER_GUIDE.md | 2 +- docs/architecture.md | 4 + docs/plans/aircraft-motion-review.md | 32 +-- docs/remote-access-architecture.md | 7 +- src-tauri/src/server.rs | 49 +++++ .../hooks/useAircraftInterpolation.ts | 70 +++++-- src/renderer/hooks/useAircraftModels.ts | 21 +- src/renderer/hooks/useRemoteObservations.ts | 97 +++++---- .../services/SettingsSharedWorkerService.ts | 1 + src/renderer/stores/aircraftTimelineStore.ts | 60 +++++- src/renderer/stores/vnasStore.ts | 30 ++- src/renderer/types/aircraft-timeline.ts | 18 +- src/renderer/types/gear.ts | 23 +++ src/renderer/types/index.ts | 7 + src/renderer/types/remote.ts | 28 +++ src/renderer/types/vatsim.ts | 21 +- src/renderer/utils/gearAnimationController.ts | 160 +++++++++++---- tests/motion/contact.test.ts | 112 +++++++++++ tests/motion/gearRule.test.ts | 190 ++++++++++++++++++ tests/motion/harness.ts | 74 ++++++- tests/motion/heightAboveGround.test.ts | 95 +++++++++ tests/motion/heightlessVnas.test.ts | 56 ++++++ tests/motion/motion.test.ts | 146 +++++++++++++- tests/remoteObservations.test.ts | 58 ++++++ 25 files changed, 1190 insertions(+), 172 deletions(-) create mode 100644 src/renderer/types/gear.ts create mode 100644 src/renderer/types/remote.ts create mode 100644 tests/motion/contact.test.ts create mode 100644 tests/motion/gearRule.test.ts create mode 100644 tests/motion/heightAboveGround.test.ts create mode 100644 tests/motion/heightlessVnas.test.ts create mode 100644 tests/remoteObservations.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 60801f13..9fef0d67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to TowerCab 3D will be documented in this file. ### Changed - 3D buildings and aircraft no longer have an olive-green cast on their walls and fuselages; their reflected ground light is now a neutral grey. - Remote access accepts only devices on your local network, unless you turn on **Allow devices outside the local network** in Settings → Configuration. +- vNAS aircraft now touch down and lift off when the simulator's aircraft does, and raise their gear a few seconds after liftoff. ### Fixed - The remote access server no longer serves or writes files outside its own folders. diff --git a/USER_GUIDE.md b/USER_GUIDE.md index e8d604dd..2991c7fb 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -939,7 +939,7 @@ TowerCab 3D matches aircraft to models in this order: ### Features -- **Landing Gear Animation**: Gear extends below 2,000ft AGL, retracts above 500ft +- **Landing Gear Animation**: Gear is down on the ground and extends when descending below 2,000ft AGL; it retracts shortly after liftoff once the aircraft is climbing - **Multiple Engine Variants**: Supports aircraft with GE, PW, or RR engine variants - **Texture Scaling**: Choose texture resolution (Full 4K, 2K, 1K, or 512) to balance quality and storage - **LRU Cache**: Automatic cleanup when cache limit is reached diff --git a/docs/architecture.md b/docs/architecture.md index 6bbc6866..955a79e7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -79,6 +79,10 @@ useAircraftInterpolation() [60 Hz singleton] Note: AircraftPanel uses useAircraftFiltering for 1Hz UI updates ``` +**Ground contact and gear.** vNAS observations carry the simulator's height above ground (`heightAboveGround`, metres, 1 Hz; built in `vnasStore.ts`, carried to remote browsers by the observation messages). `aircraftTimelineStore.ts` interpolates it to the displayed instant. `useAircraftInterpolation.ts` turns it into `inContact` per frame: entered below 3 ft, left above 5 ft. In contact the aircraft sits on the sampled terrain; airborne it is drawn at the reported height. The vNAS 50/80 ft `onGround` flag stays on the observation for labels only: a vNAS observation without a height (old replays, the brief mixed VATSIM/vNAS window) gives no drawing ground flag (`drawingGroundFlag`), so the landing and departure blends apply. A source without a height takes `inContact` from the drawn result of those blends. + +`useAircraftModels.ts` feeds `inContact` and the data-source time to `updateGearAnimation` (`utils/gearAnimationController.ts`; inputs typed in `types/gear.ts`). In contact the gear is down. After contact ends, the gear retracts on positive climb once the aircraft has been out of contact 2 s of data time, over 8 s with an ease-out; that retract fires once per contact, after which the gear holds and extends only when descending below 2000 ft. The 150 ft climb rule takes over once the aircraft passes 150 ft out of contact or after a backward seek in replay. An aircraft not in contact since it was drawn (an airborne spawn, a go-around without touchdown) uses the 150 ft rule. `tests/motion/` covers it (`contact`, `gearRule`, `heightAboveGround`, `heightlessVnas`). + ### Weather Data Flow ``` diff --git a/docs/plans/aircraft-motion-review.md b/docs/plans/aircraft-motion-review.md index 8bac882f..32ee19f2 100644 --- a/docs/plans/aircraft-motion-review.md +++ b/docs/plans/aircraft-motion-review.md @@ -17,7 +17,7 @@ How positions are interpolated and how that becomes visible motion: taxi, flight ### Suggested order of work 1. One-site fixes that change no design (each has a failing test to write first, TDD): 4.1 steering clips, 1.1 delay persistence, 1.2 per-source observation spacing, 7.1 go-around flag and 7.2 its unit error, 7.3 altitude gate on finals, 6.1 gear datum. -2. Ground-contact redesign (D1): fix 3.1 together with 5.1 and 5.2, because fixing 3.1 alone makes vNAS arrivals snap 50 ft to the runway. +2. Ground-contact redesign (D1): fix 3.1 together with 5.1 and 5.2, because fixing 3.1 alone makes vNAS arrivals snap 50 ft to the runway. Done by TC3D-50 (3.1, 5.1, 5.2, 6.3 and the departure half of 6.2). 3. vNAS silence for stationary aircraft (D2): 1.3, 1.4. 4. Orientation model: 3.2 coordinated-turn bank, 5.3 no AoA on the runway, 5.4 rotation, 5.5 flare from the flight path. 5. VATSIM path shape (D4): 2.1, 2.2, 1.5. @@ -25,10 +25,10 @@ How positions are interpolated and how that becomes visible motion: taxi, flight ## Method -The measurements came from a scratch harness, since promoted to the regression suite `tests/motion/` (`sim.ts`, `harness.ts`, `motion.test.ts`, `spawn.test.ts`, `phase.test.ts`), where each finding a fix item owns is an `it.todo` that its fix turns into a red-first `it`. It drives the real code in node: `useAircraftInterpolation` (React mocked so its effects run, `requestAnimationFrame` stubbed, `Date` faked), `aircraftTimelineStore`, `smartSort`/`detectFlightPhase` at 1 Hz like `AircraftPanel.tsx:155-160`, and `gearAnimationController` called the way `useAircraftModels.ts:329-335,503-528` calls it. Terrain is flat and sampled with the same gate as `useGroundAircraftTerrain.ts:121-132`. +The measurements came from a scratch harness, since promoted to the regression suite `tests/motion/` (`sim.ts`, `harness.ts`, `motion.test.ts`, `spawn.test.ts`, `phase.test.ts`, `contact.test.ts`, `gearRule.test.ts`, `heightAboveGround.test.ts`, `heightlessVnas.test.ts`), where each finding a fix item owns is an `it.todo` that its fix turns into a red-first `it`. It drives the real code in node: `useAircraftInterpolation` (React mocked so its effects run, `requestAnimationFrame` stubbed, `Date` faked), `aircraftTimelineStore`, `smartSort`/`detectFlightPhase` at 1 Hz like `AircraftPanel.tsx:155-160`, and `gearAnimationController` called the way `useAircraftModels.ts:329-335,503-528` calls it. Terrain is flat and sampled with the same gate as `useGroundAircraftTerrain.ts:121-132`. - `sim.ts` integrates three truth flights at 10 Hz: DEP1 (straight and curved pushback, two 90 deg taxi turns at 12 and 8 kt, line-up and 25 s hold, roll at 3.5 kt/s, liftoff at 150 kt, 2500 fpm climb, 3 deg/s climbing turn at 175-210 kt, level-off, 25 deg-bank cruise turn at 250 kt); ARR1 (2000 ft intercept, 3 deg glide at 140 kt with a 6 deg crosswind crab, exponential flare from 30 ft, de-crab, rollout at -4 kt/s, 90 deg exit, taxi-in); GA1 (same approach, go-around at 200 ft, 2000 fpm, 3 deg/s turn to downwind). -- `harness.ts` turns truth into a VATSIM feed (every 15 s, received 0.4-1.4 s later, no ground flag or track) and a vNAS feed (1 Hz, received 80 ms later, true track, the `vnasStore.ts:25-48` 50/80 ft ground hysteresis, and no samples while the aircraft stands still, as vNAS does). +- `harness.ts` turns truth into a VATSIM feed (every 15 s, received 0.4-1.4 s later, no ground flag or track) and a vNAS feed (1 Hz, received 80 ms later, true track, the `vnasStore.ts:25-48` 50/80 ft label flag, the simulator's height above ground on each observation (the options `vnasWithoutHeight` and `vnasWithoutGroundFlag` drop the height or the flag, for replays recorded without them), and no samples while the aircraft stands still, as vNAS does). - `motion.test.ts` runs three scenarios for 720 s: VATSIM only; vNAS+VATSIM with VATSIM seen first; vNAS+VATSIM with vNAS seen first. (The review also ran the second at 144 fps; the suite drops it.) Each frame is compared with truth at the frame's own `displayTime`, so the deliberate display delay is not counted as error. `spawn.test.ts` and `phase.test.ts` isolate single findings; the review's `anim_names.py` and `steer_keys.py` model-clip scans were not promoted. Rerun: `pwsh -NoProfile -File tools/gate.ps1 run --log .tmp/motion.log --timeout 300 --slot light -- pnpm vitest run tests/motion`; with `MOTION_REPORT=1` the segment tables land in `.tmp/motion/report.txt` (the other variables are in `tests/motion/harness.ts`'s header). - Site geoid: N = -32.0 m at the default site (37.72 N, 122.22 W), N = +48.2 m at EDDF (50.033 N, 8.570 E), both from `geoidService`. - Not measured: RealTraffic (it supplies roll, vertical rate and a ground bit, none of which the harness feeds), sloped terrain, the Babylon label layer, and real network jitter on vNAS. @@ -39,7 +39,7 @@ The measurements came from a scratch harness, since promoted to the regression s ### How it works today -- Every source feeds `AircraftObservation`s into one timeline per callsign (`aircraftTimelineStore.ts:1254-1474`). VATSIM arrives in batches every 15 s with no track or ground flag (`vatsimStore.ts:166-182`); vNAS arrives at 1 Hz with true track and a 50/80 ft hysteresis ground flag (`vnasStore.ts:782-800`). +- Every source feeds `AircraftObservation`s into one timeline per callsign (`aircraftTimelineStore.ts:1254-1474`). VATSIM arrives in batches every 15 s with no track or ground flag (`vatsimStore.ts:166-182`); vNAS arrives at 1 Hz with true track, the simulator's height above ground (`heightAboveGround`, which decides ground contact) and a 50/80 ft hysteresis ground flag kept for labels (`vnasStore.ts:782-800`). - When at least two vNAS samples are under 30 s old, interpolation uses vNAS only (`aircraftTimelineStore.ts:421-429`, `VNAS_PREFERENCE_THRESHOLD_MS`). - Display time is anchored on the oldest observation: `displayTime = oldest.observedAt + (now - oldest.receivedAt) - delay` (`:739-740`). The delay is per aircraft, kept in the store's `dynamicDelays` map: max of the last 5 receive intervals + 150 ms, clamped 150 ms-20 s, starting at the source's delay (VATSIM about 17 s, vNAS 1.5 s) and moved toward its target at 500 ms/s when it rises and 100 ms/s when it falls, a gap between frames counting at most 1 s (`DYNAMIC_DELAY_MAX_ELAPSED_MS`), plus a 200 ms bump that builds over 0.4 s while extrapolating. A VATSIM aircraft first seen in the air holds at its first position until display time reaches it; one first seen moving on the ground appears at its second report; a held aircraft gets no bump. - Between two observations, lat/lon are lerped linearly from a reconciliation start (the last rendered position) to the `after` sample (`:909-970`); altitude uses a linear/smoothstep blend weighted by how much the vertical rate changes between segments (`:978-1018`); heading is lerped separately between the two samples' headings (`:1168-1173`). Past the newest sample it extrapolates along `groundTrack ?? heading` for up to 30 s (`:311-342`, `:1076-1140`). @@ -90,14 +90,14 @@ Ground aircraft use the same timeline path; the hook rate-limits heading at 20 d - Pitch = vertical rate x 5 deg per 1000 fpm (`PITCH_RATE_MULTIPLIER`) plus an AoA offset that falls from 5 deg at 120 kt to 2 deg at 350 kt of groundspeed (`useAircraftInterpolation.ts:264-283`), clamped to 20 deg and rate-limited to 5 deg/s. - Roll = ADS-B roll when present (RealTraffic), else turn rate x 5 (`ROLL_RATE_MULTIPLIER`, `:290-299`), clamped to 35 deg and rate-limited to 10 deg/s. Turn rate is the frame derivative of the rate-limited heading, clamped to 6 deg/s. -- "On ground" for these purposes is `timeline.onGround === true || groundspeed < 40` (`:167`, `:259`): no AoA and no roll. +- "On ground" for these purposes is ground contact (`inContact`) when the source reports a height above ground, else `timeline.onGround === true || groundspeed < 40` (`isOnGround` in `timelineToInterpolatedState`): no AoA and no roll. - Height: the reported altitude as is (ellipsoidal), blended toward terrain near the ground (area 5). ### Findings -**3.1 `isOnGround` is frozen at its first value.** The in-place update of an existing entry (`useAircraftInterpolation.ts:497-518`) copies every field except `isOnGround`, which is set only when the entry is created (`:367`). Measured: in the vNAS scenario with VATSIM seen first, 23927 of 24270 DEP1 frames carry `null` while the timeline says `true`/`false`; with vNAS seen first the value stays `true`, so the terrain branch at `:620` clamps the aircraft to the runway. Because the terrain hook samples whatever is under 300 m of rendered AGL, the clamp feeds itself: DEP1 is drawn at 0.3 ft AGL through its climb, turn and cruise (AGL error 1451 m), with roll forced to 0 and gear never retracting. +**3.1 `isOnGround` is frozen at its first value.** Fixed by TC3D-50. The in-place update of an existing entry (`useAircraftInterpolation.ts:497-518`) copies every field except `isOnGround`, which is set only when the entry is created (`:367`). Measured: in the vNAS scenario with VATSIM seen first, 23927 of 24270 DEP1 frames carry `null` while the timeline says `true`/`false`; with vNAS seen first the value stays `true`, so the terrain branch at `:620` clamps the aircraft to the runway. Because the terrain hook samples whatever is under 300 m of rendered AGL, the clamp feeds itself: DEP1 is drawn at 0.3 ft AGL through its climb, turn and cruise (AGL error 1451 m), with roll forced to 0 and gear never retracting. - Who hits it: any pilot vNAS reports before the VATSIM feed lists them, e.g. one who connects at a gate during a session. -- Fix: copy `isOnGround` in the update block. One line, but do not land it alone: with the field live, the `true` branch clamps vNAS arrivals from 50 ft (the hysteresis threshold) and they drop 50 ft in 0.25 s. Land with D1. +- Fix: the update block copies `isOnGround`, `heightAboveGround` and `inContact`, landed with D1: the vNAS 50/80 ft flag no longer reaches the drawing (it would have clamped arrivals from 50 ft), and ground contact comes from the height above ground. **3.2 Bank is half of a coordinated turn at airliner speeds.** Roll = 5 x turn rate fits about 100 kt only. Measured: 3 deg/s climbing turn at 175-210 kt shows 15.0 deg where a coordinated turn needs 25.9-29.4 deg; the 250 kt cruise turn shows 10.2 deg against 25.0 deg. Physics: tan(bank) = V x omega / g; the FAA rule of thumb gives 25 deg at 180 kt. - Fix: bank = atan(V x omega / g) with V from groundspeed (TAS when available), capped at 30 deg. One-site in the hook plus a constant. @@ -129,17 +129,17 @@ No control surface, flap, spoiler or reverser is animated. The FSLTL converter b ### How it works today -- Height near the ground (`useAircraftInterpolation.ts:613-665`): weight 1 (on terrain) below 5 kt, when `isOnGround === true`, or when terrain is above the reported height; for descending aircraft without a ground flag a landing blend from 50 m (weight 0) to 10 m AGL (weight 1); a departure blend from 5 m to 35 m. A crossing of the 0.5 weight starts a 15-frame transition. +- Height near the ground (`useAircraftInterpolation.ts:613-665`): weight 1 (on terrain) below 5 kt; for a source that reports a height above ground (vNAS), weight 1 in contact (below 3 ft, left above 5 ft) or when terrain is above the reported height, else 0 (the reported height); for the other sources weight 1 when `isOnGround === true` (RealTraffic) or when terrain is above the reported height, and for descending aircraft without a ground flag a landing blend from 50 m (weight 0) to 10 m AGL (weight 1); a departure blend from 5 m to 35 m. A crossing of the 0.5 weight starts a 15-frame transition. - Flare (`:740-806`, `geoMath.ts:81-125`): below 15 m reported AGL, descending faster than 150 m/min (490 fpm) and above 40 kt, pitch is lerped toward 6 deg x (sink / 300 m/min), max 12 deg, with smoothstep on height. The result is written after the pitch rate limiter. - Touchdown: when the flare condition ends, a 60-frame smoothstep lowers pitch to 0 and locks out the flare until clearly airborne (`:793-836`). - Takeoff: no rotation model; pitch comes from vertical rate once the reported altitude rises. ### Findings -**5.1 Arrivals touch down early.** The landing blend (50 m to 10 m) compresses the last 160 ft: drawn height is reported x (1 - weight), so the visual sink rate is 2.25x real at 50 m and the wheels meet the runway at 10 m (33 ft) reported. Measured: VATSIM arrival on the runway 2.4 s before real touchdown; vNAS arrival (VATSIM seen first) 7.1 s early, at 135 kt about 490 m short of the real touchdown point; vNAS seen first 5.5 s early. For vNAS this branch is reached only because of 3.1 (its frozen `null`); with 3.1 fixed alone, the `true` branch would clamp at 50 ft instead. The blend exists for VATSIM's poor height resolution; vNAS and RealTraffic altitudes do not need it. -- Fix: D1. Design. +**5.1 Arrivals touch down early.** Fixed by TC3D-50 for vNAS: the landing and departure blends now apply only to sources without a height above ground (VATSIM; vNAS observations lacking the height), and a vNAS arrival touches down when the sim's aircraft does. The measurements below are from before the fix. The landing blend (50 m to 10 m) compresses the last 160 ft: drawn height is reported x (1 - weight), so the visual sink rate is 2.25x real at 50 m and the wheels meet the runway at 10 m (33 ft) reported. Measured: VATSIM arrival on the runway 2.4 s before real touchdown; vNAS arrival (VATSIM seen first) 7.1 s early, at 135 kt about 490 m short of the real touchdown point; vNAS seen first 5.5 s early. For vNAS this branch was reached only because of 3.1 (its frozen `null`); with 3.1 fixed alone, the `true` branch would have clamped at 50 ft instead. The blend exists for VATSIM's poor height resolution; vNAS altitudes do not need it. +- Fix: D1, option 1 (see 3.1). -**5.2 vNAS arrivals pitch nose-down through the flare.** vNAS reports `onGround` below 50 ft (`vnasStore.ts:35-41`), so the hook drops the AoA offset (`:259-283`) and pitch becomes the bare flight-path angle. Measured: -0.6 to -2.9 deg between 51 and 39 ft, then the flare term (computed from reported AGL, `:755`) lifts it to +1.8 deg, and when the sink falls below 490 fpm `calculateFlarePitch` returns the base pitch in one frame: +1.8 to -2.1 deg within 0.5 s. Touchdown is drawn at about 0 deg. Reference: Boeing flares at about 20 ft by raising pitch 2-3 deg; Airbus at about 30 ft by about 2 deg; flare lasts 4-8 s. Fix with D1 and 5.5. +**5.2 vNAS arrivals pitch nose-down through the flare.** Fixed by TC3D-50: the AoA offset and the flare's height now come from ground contact and the reported height above ground, not the 50 ft flag; the flare's shape and de-rotation stay with 5.5. Before the fix vNAS reported `onGround` below 50 ft (`vnasStore.ts:35-41`), so the hook dropped the AoA offset (`:259-283`) and pitch becomes the bare flight-path angle. Measured: -0.6 to -2.9 deg between 51 and 39 ft, then the flare term (computed from reported AGL, `:755`) lifts it to +1.8 deg, and when the sink falls below 490 fpm `calculateFlarePitch` returns the base pitch in one frame: +1.8 to -2.1 deg within 0.5 s. Touchdown is drawn at about 0 deg. Reference: Boeing flares at about 20 ft by raising pitch 2-3 deg; Airbus at about 30 ft by about 2 deg; flare lasts 4-8 s. Fix with D1 and 5.5. **5.3 VATSIM departures roll down the runway nose-up.** VATSIM has no ground flag and the takeoff roll is above 40 kt, so the hook treats it as airborne and adds the 5 deg AoA offset; terrain clamping zeroes roll but not pitch (`:862-864`). Measured: +4.7 to +4.9 deg on the runway 2-6 s before liftoff (truth 0). The same happens on a VATSIM landing rollout: +3.2-3.3 deg for 6.4 s on the runway after the early touchdown of 5.1, then a ~1 s de-rotation. Fix: no AoA offset while the height blend weight is above 0.5. One-site. @@ -153,19 +153,19 @@ No control surface, flap, spoiler or reverser is animated. The FSLTL converter b ### How it works today -`calculateTargetGearProgress` (`gearAnimationController.ts:161-193`): down on the ground (`groundspeed < 40`), down when below 2000 ft AGL and descending faster than 100 fpm, up when above 150 ft and climbing faster than 100 fpm, else hold; 12 s eased transition (`:51-57`). New aircraft start down unless above 3000 ft (`:112-144`). FSLTL models only; built-in models have no gear animation and keep their static pose. State is cleared when the model is culled (`useAircraftModels.ts:535-538`) and re-initialised when it comes back into view. +`calculateTargetGearProgress` (`gearAnimationController.ts`): down in ground contact (`inContact`, from the interpolation pass; see 3.1 and 5.1), down when below 2000 ft AGL and descending faster than 100 fpm. After a contact-to-airborne transition it goes up on positive climb (over 100 fpm) once the aircraft has been out of contact 2 s of data time; that leave-contact retract fires once per contact, after which the gear holds (bar the extension on descent) until the aircraft passes 150 ft out of contact or a backward seek, when the 150 ft rule takes over: up when above 150 ft and climbing faster than 100 fpm. An aircraft not in contact since it was drawn (airborne spawn, go-around without touchdown) uses the 150 ft rule. Transitions take 8 s on an ease-out curve. New aircraft start down unless above 3000 ft (`initializeGearState`). FSLTL models only; built-in models have no gear animation and keep their static pose. State is cleared when the model is culled (`useAircraftModels.ts:535-538`) and re-initialised when it comes back into view. ### Findings **6.1 Gear AGL mixes datums.** Fixed by TC3D-49. `useAircraftModels.ts:509` (and `:332`) subtracts `groundElevationMeters` (MSL) from `interpolatedAltitude` (ellipsoidal); the comment at `:507-508` says both are MSL. The error equals the geoid height. Measured: retraction starts 7.8 s after liftoff at the -32 m site (5.3 s with the ellipsoidal ground), and 0.4 s after liftoff at EDDF (+48 m), where the rollout and taxi also read 158 ft "AGL" and only the 40 kt ground rule keeps the gear down. `useGroundAircraftTerrain.ts:123-128` already converts correctly. Fix: convert with `geoidService.mslToEllipsoidal`, or use the terrain-corrected AGL from the interpolation pass. One-site. -**6.2 VATSIM gear moves before the event.** Through 1.5, VATSIM vertical rate turns positive before liftoff (measured +1934 fpm one second before liftoff) and before a go-around (gear starting up 4 s early). The 150 ft threshold hides most of this on departures. Improves with D4. +**6.2 VATSIM gear moves before the event.** The departure half is fixed by TC3D-50 (the gear retracts after 2 s out of contact, not on the 150 ft threshold); the go-around half moved to TC3D-53. Through 1.5, VATSIM vertical rate turns positive before liftoff (measured +1934 fpm one second before liftoff) and before a go-around (gear starting up 4 s early). The 150 ft threshold hides most of this on departures. Improves with D4. -**6.3 Timing against real procedure.** Real gear-up is called at positive rate of climb on the altimeter and VSI, a few seconds after liftoff; this code waits for 150 ft, which with the right datum is 5.3 s (VATSIM) to 6.9 s (vNAS) after liftoff at 2500 fpm (measured). Retraction takes 6-12 s on jets per a secondary source, 8-10 s on the 737-800; the 12 s transition is at the slow end, and since the measured "first visible movement" comes 1-2 s into the cubic ease, the gear looks late. Use 8 s with a linear or ease-out curve. One-site constant. +**6.3 Timing against real procedure.** Fixed by TC3D-50: the transition is 8 s on an ease-out curve and the gear retracts at positive climb 2 s after leaving contact. The measurements and the proposal below are from before the fix. Real gear-up is called at positive rate of climb on the altimeter and VSI, a few seconds after liftoff; this code waits for 150 ft, which with the right datum is 5.3 s (VATSIM) to 6.9 s (vNAS) after liftoff at 2500 fpm (measured). Retraction takes 6-12 s on jets per a secondary source, 8-10 s on the 737-800; the 12 s transition is at the slow end, and since the measured "first visible movement" comes 1-2 s into the cubic ease, the gear looks late. Use 8 s with a linear or ease-out curve. One-site constant. **6.4 Culling resets gear.** A climbing aircraft panned out of view and back below 2000 ft without enough vertical rate history re-initialises gear down. Keep gear state per callsign independent of pool slots. One-site. -**6.5 Wall clock in replay.** `updateGearAnimation` is called with `Date.now()` (`useAircraftModels.ts:521`), so in replay and scrubbing the 12 s transition runs on wall time, not replay time. Pass the data-source timestamp. One-site. +**6.5 Wall clock in replay.** `updateGearAnimation` is called with `Date.now()` (`useAircraftModels.ts:521`), so in replay and scrubbing the 8 s transition runs on wall time, not replay time (the time out of contact already counts on the frame's data-source time, `GearFrameInput.dataTime`). Pass the data-source timestamp to the transition too. One-site. ## 7. Smart status (flight-phase badge) @@ -192,7 +192,7 @@ No control surface, flap, spoiler or reverser is animated. The FSLTL converter b ## What newer data and models make possible - **Predict from the timeline instead of easing.** The display delay means the renderer already holds the next 1-15 s of observations. Rotation (5.4), touchdown and de-rotation (5.5), gear-up at positive rate (6.3) and the go-around label (7.1) can all key off the next observation instead of thresholds on the present one. Same for curvature: a Hermite path through samples with known velocities replaces the chord-plus-heading-lerp (2.1). -- **Use vNAS AGL directly.** vNAS sends the sim's AGL (`aircraft.altitudeAgl`, `vnasStore.ts:792`) but only a 50/80 ft boolean reaches the timeline. Passing AGL through the observation gives a true contact signal and removes the need for the landing and departure blends for vNAS (D1). +- **Use vNAS AGL directly.** Done (TC3D-50): the sim's AGL (`aircraft.altitudeAgl`) rides each vNAS observation as `heightAboveGround` and gives the contact signal, so the landing and departure blends apply only to sources without it (D1). - **Native glTF clips beyond gear.** FSLTL models carry spoilers (71 of 105 sampled models with the standard clip alone), flaps (28 with the standard clip), reversers, wheel rotation and fan blur. Spoilers deploying at touchdown, reversers during rollout and wheels rolling on taxi are what a tower sees at close range; ailerons, elevators and rudder are on under 10 % of these models. E1 in [rendering-opportunities-design.md](./rendering-opportunities-design.md) is settled as gear-only parity; any wider scope is D3 and should build on E1's `activeAnimations` path, not the matrix path. - **Bank and pitch from physics.** The fixed multipliers were tuned when most traffic was seen at 15 s resolution; with 1 Hz tracks the turn rate is accurate enough that the coordinated-turn formula (3.2) and flight-path-plus-AoA pitch (5.5) are better than a tuned constant. diff --git a/docs/remote-access-architecture.md b/docs/remote-access-architecture.md index 86ed2f06..007ea032 100644 --- a/docs/remote-access-architecture.md +++ b/docs/remote-access-architecture.md @@ -270,6 +270,9 @@ pub struct ObservationData { pub ground_track: f64, pub vertical_rate: f64, pub on_ground: bool, + // Height above ground in metres (vNAS); "heightAboveGround" on the wire, absent when None + #[serde(default, skip_serializing_if = "Option::is_none")] + pub height_above_ground: Option, pub pitch: Option, pub roll: Option, pub source: String, // "vatsim" | "vnas" | "realtraffic" @@ -282,6 +285,8 @@ pub struct ObservationData { } ``` +`heightAboveGround` carries vNAS's height above ground (metres) so a remote screen decides ground contact the same way the host does (entered below 3 ft, left above 5 ft). VATSIM and RealTraffic observations omit the key, and a remote browser treats a missing key (also what an older host sends) as no height. `SettingsSharedWorkerService.ts` puts the field on the batch it hands the host, and `toTimelineObservation` in `useRemoteObservations.ts` (typed by `RemoteObservationData` in `types/remote.ts`) copies it onto the timeline observation. + ### WebSocket Message Types ```rust @@ -351,7 +356,7 @@ export function useRemoteObservations(): void { case 'observations': // Convert to AircraftObservation format, add to timeline store - addObservationBatch(message.data.map(convertObservation)) + addObservationBatch(message.data.map((obs) => toTimelineObservation(obs, Date.now()))) break case 'removals': diff --git a/src-tauri/src/server.rs b/src-tauri/src/server.rs index f008bc32..5f4fc483 100644 --- a/src-tauri/src/server.rs +++ b/src-tauri/src/server.rs @@ -62,6 +62,10 @@ pub struct ObservationData { pub ground_track: f64, pub vertical_rate: f64, pub on_ground: bool, + /// Height above the ground in metres (vNAS), so remote screens run the host's ground-contact rule; + /// absent on the wire for sources without one + #[serde(default, skip_serializing_if = "Option::is_none")] + pub height_above_ground: Option, pub pitch: Option, pub roll: Option, /// Data source: "vatsim", "vnas", or "realtraffic" @@ -2870,4 +2874,49 @@ mod tests { assert_refused(resolve_within(&root.0, bad), bad); } } + + fn observation(height_above_ground: Option) -> ObservationData { + ObservationData { + callsign: "DAL123".to_string(), + latitude: 37.72, + longitude: -122.22, + altitude: 10.0, + heading: 90.0, + groundspeed: 140.0, + ground_track: 90.0, + vertical_rate: 0.0, + on_ground: false, + height_above_ground, + pitch: None, + roll: None, + source: "vnas".to_string(), + observed_at: 1, + received_at: 2, + type_code: None, + origin: None, + destination: None, + flight_rules: None, + } + } + + #[test] + fn observation_height_above_ground_is_camel_case_when_present() { + let json = serde_json::to_value(observation(Some(1.25))).unwrap(); + assert_eq!(json["heightAboveGround"], serde_json::json!(1.25)); + } + + #[test] + fn observation_without_height_above_ground_omits_the_key() { + let json = serde_json::to_value(observation(None)).unwrap(); + assert!(json.get("heightAboveGround").is_none(), "{json}"); + assert!(json.get("height_above_ground").is_none(), "{json}"); + } + + #[test] + fn observation_from_a_payload_without_height_above_ground_has_none() { + let mut json = serde_json::to_value(observation(Some(1.25))).unwrap(); + json.as_object_mut().unwrap().remove("heightAboveGround"); + let parsed: ObservationData = serde_json::from_value(json).unwrap(); + assert_eq!(parsed.height_above_ground, None); + } } diff --git a/src/renderer/hooks/useAircraftInterpolation.ts b/src/renderer/hooks/useAircraftInterpolation.ts index 4948fc4a..0a21ca2c 100644 --- a/src/renderer/hooks/useAircraftInterpolation.ts +++ b/src/renderer/hooks/useAircraftInterpolation.ts @@ -99,6 +99,32 @@ interface TimelineRateState { const sharedTerrainStateRef = { current: new Map() } const sharedNosewheelStateRef = { current: new Map() } const sharedTimelineRateStateRef = { current: new Map() } +/** Ground contact per aircraft with a height above ground, kept for the hysteresis */ +const sharedContactStateRef = { current: new Map() } + +/** Ground contact is entered below this height above ground (3 ft) */ +const CONTACT_ENTER_HEIGHT_METERS = 3 * 0.3048 +/** Ground contact is left above this height above ground (5 ft) */ +const CONTACT_EXIT_HEIGHT_METERS = 5 * 0.3048 + +/** + * Ground contact for this frame from the height above ground at the displayed instant, with hysteresis. + * An aircraft with no earlier contact state enters contact only below the entry height. + * + * @returns Whether the aircraft is in contact, or null when the source reports no height + */ +function updateContact(callsign: string, heightAboveGround: number | null): boolean | null { + if (heightAboveGround === null) { + sharedContactStateRef.current.delete(callsign) + return null + } + const wasInContact = sharedContactStateRef.current.get(callsign) ?? false + const inContact = wasInContact + ? heightAboveGround <= CONTACT_EXIT_HEIGHT_METERS + : heightAboveGround < CONTACT_ENTER_HEIGHT_METERS + sharedContactStateRef.current.set(callsign, inContact) + return inContact +} // ============================================================================ // TIMELINE FRAME TIMING @@ -144,6 +170,10 @@ function timelineToInterpolatedState( } const lastFrameTime = sharedTimelineLastFrameTimeRef.current + const inContact = updateContact(callsign, timeline.heightAboveGround) + // On the ground for heading rate, pitch and roll: ground contact when the source reports its height above + // ground, otherwise the source's ground flag or a groundspeed under 40 kt + const isOnGround = inContact ?? (timeline.onGround === true || timeline.groundspeed < 40) // Calculate frame delta (ms) const frameDelta = lastFrameTime > 0 ? now - lastFrameTime : 16.67 // ~60fps default @@ -163,7 +193,6 @@ function timelineToInterpolatedState( const headingBase = prevSmoothedHeading ?? targetHeading // Determine rate limit based on ground/air state - const isOnGround = timeline.onGround === true || timeline.groundspeed < 40 const maxHeadingRate = isOnGround ? MAX_HEADING_RATE_DEG_PER_SEC_GROUND : MAX_HEADING_RATE_DEG_PER_SEC_AIR const dtSeconds = frameDelta / 1000 @@ -254,9 +283,6 @@ function timelineToInterpolatedState( rateState.prevPitch = pitch rateState.prevRoll = roll } else if (orientationEnabled) { - // Determine ground/air state (used for both pitch and roll) - const isOnGround = timeline.onGround === true || timeline.groundspeed < 40 - // Pitch from vertical rate (flight path angle) // ~1000 fpm climb = ~5 degrees pitch (standard approximation) // verticalRate is in m/min, convert to fpm for calculation @@ -362,8 +388,10 @@ function timelineToInterpolatedState( acceleration, track, - // Ground detection from authoritative source (vNAS AGL, ADS-B ground bit) + // Source ground flag (vNAS 50/80 ft flag for labels, ADS-B ground bit); contact comes from the height isOnGround: timeline.onGround, + heightAboveGround: timeline.heightAboveGround, + inContact, isInterpolated: true, @@ -420,11 +448,13 @@ function runInterpolationFrame() { // Entering imported replay mode - timelines are already loaded by // replayStore.importReplay() which calls loadImportedTimelines() or loadReplaySnapshots() sharedTimelineRateStateRef.current.clear() + sharedContactStateRef.current.clear() } else if (currentMode === 'replay') { // Entering buffer replay mode - scrub through existing timeline // The timeline already has live observations, no need to load anything - // Just clear rate tracking for fresh orientation calculations + // Just clear rate and contact tracking for fresh orientation and contact sharedTimelineRateStateRef.current.clear() + sharedContactStateRef.current.clear() } else if (currentMode === 'live' && previousMode === 'imported') { // Returning to live mode from imported replay - clear the data // Live observations will start populating the timeline again @@ -516,6 +546,9 @@ function runInterpolationFrame() { existing.track = interpolated.track existing.timestamp = interpolated.timestamp existing.displayDelay = interpolated.displayDelay + existing.isOnGround = interpolated.isOnGround + existing.heightAboveGround = interpolated.heightAboveGround + existing.inContact = interpolated.inContact } else { statesMap.set(callsign, interpolated) } @@ -616,20 +649,27 @@ function runInterpolationFrame() { if (isLowSpeed) { // Very low speed = definitely on ground, full terrain clamping groundBlendWeight = 1.0 + } else if (entry.heightAboveGround !== null) { + // The source reports its height above ground: on the sampled terrain in contact, at the reported height + // otherwise (kept above the terrain). Without a terrain sample the reported height stands, since the + // reference airport's elevation may not be the field the aircraft is on. The landing and departure blends + // below are for sources without that height. + const sampled = sampledTerrainHeight !== undefined + const terrainAboveReported = sampled && terrainClampedHeight > reportedHeightWithOffset + groundBlendWeight = sampled && (entry.inContact || terrainAboveReported) ? 1.0 : 0 } else if (entry.isOnGround === true && sampledTerrainHeight !== undefined) { - // Source data (vNAS AGL, ADS-B) confirms aircraft is on ground - trust it over computed AGL + // Source ground flag (RealTraffic's ADS-B ground bit) confirms aircraft is on ground - trust it over computed AGL // This is more reliable than altitude-based detection when MSL/ellipsoidal conversion has errors groundBlendWeight = 1.0 } else if (sampledTerrainHeight !== undefined && terrainClampedHeight > reportedHeightWithOffset) { // Terrain higher than reported = prevent going underground groundBlendWeight = 1.0 } else if (entry.isOnGround === false && altitudeAGL >= DEPARTURE_BLEND_END_AGL) { - // Source data (vNAS, ADS-B) confirms aircraft is airborne AND well above ground + // Source ground flag confirms aircraft is airborne AND well above ground // Skip progressive blending - trust reported altitude fully groundBlendWeight = 0 } else if (entry.isOnGround === false) { // Source says airborne but aircraft is still very close to ground - // (e.g., vNAS AGL oscillating around 50ft threshold at high-elevation airports) // Use progressive departure blend instead of snapping to reported altitude, // which prevents visible oscillation when the ground flag toggles rapidly const blendRange = DEPARTURE_BLEND_END_AGL - DEPARTURE_BLEND_START_AGL @@ -669,6 +709,11 @@ function runInterpolationFrame() { // For state transition tracking, consider "clamped" if blend weight > 0.5 const shouldClampToTerrain = groundBlendWeight > 0.5 + // A source without a height above ground takes its contact from the drawn result, so the gear has one input + if (entry.heightAboveGround === null) { + entry.inContact = shouldClampToTerrain + } + // Reset smoothed terrain height for aircraft that are truly airborne if (groundBlendWeight === 0 && sampledTerrainHeight !== undefined && altitudeAGL > 50) { terrainState.smoothedTerrainHeight = 0 @@ -749,9 +794,9 @@ function runInterpolationFrame() { sharedNosewheelStateRef.current.set(callsign, nosewheelState) } - // Calculate altitude above ground level (AGL) - // Use reported altitude (not corrected) since terrain height is in ellipsoid coords - const altitudeAGLFlare = reportedEllipsoidHeight - sampledTerrainHeight + // Height above ground for the flare: the source's own when it reports one, else the reported altitude + // (not corrected) above the terrain sample, both in ellipsoid coords + const altitudeAGLFlare = entry.heightAboveGround ?? reportedEllipsoidHeight - sampledTerrainHeight // Clear groundCommitted when clearly airborne again (handles go-around / touch-and-go) // Both conditions required to prevent false clears from a single noisy datum @@ -872,6 +917,7 @@ function runInterpolationFrame() { sharedTerrainStateRef.current.delete(callsign) sharedNosewheelStateRef.current.delete(callsign) sharedTimelineRateStateRef.current.delete(callsign) + sharedContactStateRef.current.delete(callsign) } } diff --git a/src/renderer/hooks/useAircraftModels.ts b/src/renderer/hooks/useAircraftModels.ts index 87f7f71a..0897c5f5 100644 --- a/src/renderer/hooks/useAircraftModels.ts +++ b/src/renderer/hooks/useAircraftModels.ts @@ -3,7 +3,6 @@ import { useCallback, useEffect, useRef } from 'react' import { SUN_ELEVATION_DAY, SUN_ELEVATION_NIGHT } from '../constants/lighting' import { FSLTL_MODEL_HEIGHT_OFFSET, - GROUNDSPEED_THRESHOLD_KNOTS, getFsltlModelColorBlendAmount, getModelColorBlendAmount, getModelColorRgb, @@ -329,8 +328,8 @@ export function useAircraftModels( modelPoolAssignments.current.set(poolIndex, aircraft.callsign) // Initialize gear state based on aircraft's current conditions - // This ensures aircraft spawning in flight have gear up, while ground aircraft have gear down - const isOnGround = aircraft.interpolatedGroundspeed < GROUNDSPEED_THRESHOLD_KNOTS + // This ensures aircraft spawning in flight have gear up, while aircraft in ground contact have gear down + const inContact = aircraft.inContact === true const altitudeAglMeters = gearHeightAboveGroundMeters( aircraft.interpolatedAltitude, groundElevationMeters, @@ -339,7 +338,7 @@ export function useAircraftModels( ) const altitudeAglFeet = altitudeAglMeters * 3.28084 const verticalRateFpm = aircraft.verticalRate * 3.28084 - initializeGearState(aircraft.callsign, altitudeAglFeet, verticalRateFpm, isOnGround) + initializeGearState(aircraft.callsign, altitudeAglFeet, verticalRateFpm, inContact) } } @@ -509,8 +508,8 @@ export function useAircraftModels( const isModelLoading = modelPoolLoading.current.has(poolIndex) if (isFsltlForGear && model.ready && !isModelLoading) { - // Determine if aircraft is on ground based on groundspeed threshold - const isOnGround = aircraft.interpolatedGroundspeed < GROUNDSPEED_THRESHOLD_KNOTS + // Ground contact from the interpolation pass (null only before the aircraft's first drawn frame) + const inContact = aircraft.inContact === true // AGL from the ellipsoidal aircraft altitude and the MSL ground, converted to one datum const altitudeAglMeters = gearHeightAboveGroundMeters( @@ -525,11 +524,15 @@ export function useAircraftModels( const verticalRateFpm = aircraft.verticalRate * 3.28084 // Update gear animation state and get current progress + // The time out of contact counts on the data-source time of the frame (replay-aware) const gearProgress = updateGearAnimation( aircraft.callsign, - altitudeAglFeet, - verticalRateFpm, - isOnGround, + { + altitudeFt: altitudeAglFeet, + verticalRateFpm, + inContact, + dataTime: aircraft.timestamp, + }, Date.now(), ) diff --git a/src/renderer/hooks/useRemoteObservations.ts b/src/renderer/hooks/useRemoteObservations.ts index 7abf01b5..9822002a 100644 --- a/src/renderer/hooks/useRemoteObservations.ts +++ b/src/renderer/hooks/useRemoteObservations.ts @@ -23,6 +23,7 @@ */ import { useCallback, useEffect, useRef } from 'react' +import type { RemoteObservationData } from '@/types' import { SOURCE_DISPLAY_DELAYS } from '../constants/aircraft-timeline' import { useAircraftTimelineStore } from '../stores/aircraftTimelineStore' import { useRemoteStatusStore } from '../stores/remoteStatusStore' @@ -30,28 +31,50 @@ import { useVnasStore } from '../stores/vnasStore' import type { AircraftMetadata, AircraftObservation } from '../types/aircraft-timeline' import { getApiBaseUrl, isRemoteMode } from '../utils/remoteMode' -// Types for WebSocket messages (match Rust types in server.rs) -interface ObservationData { - callsign: string - latitude: number - longitude: number - altitude: number - heading: number - groundspeed: number - groundTrack: number - verticalRate: number - onGround: boolean - pitch: number | null - roll: number | null - source: string - observedAt: number - receivedAt: number - typeCode: string | null - origin: string | null - destination: string | null - flightRules: string | null +/** + * Map one observation from the host's WebSocket into the timeline store's observation and metadata. + * + * @param obs - The observation as the host sent it + * @param now - Local receive time in ms, used for reconciliation + * @returns The callsign, observation and metadata for `addObservationBatch` + */ +export function toTimelineObservation( + obs: RemoteObservationData, + now: number, +): { callsign: string; observation: AircraftObservation; metadata: AircraftMetadata } { + const observation: AircraftObservation = { + latitude: obs.latitude, + longitude: obs.longitude, + altitude: obs.altitude, + heading: obs.heading, + groundspeed: obs.groundspeed, + groundTrack: obs.groundTrack, + verticalRate: obs.verticalRate, + onGround: obs.onGround, + heightAboveGround: obs.heightAboveGround ?? undefined, + pitch: obs.pitch, + roll: obs.roll, + headingIsTrue: obs.source === 'vnas' || obs.source === 'vatsim', + source: obs.source as 'vatsim' | 'vnas' | 'realtraffic', + observedAt: obs.observedAt, + receivedAt: now, // Use local time for reconciliation + displayDelay: + SOURCE_DISPLAY_DELAYS[obs.source as keyof typeof SOURCE_DISPLAY_DELAYS] ?? SOURCE_DISPLAY_DELAYS.vatsim, + } + + const metadata: AircraftMetadata = { + cid: 0, // Unknown CID for remote observations + aircraftType: obs.typeCode ?? 'UNKN', + transponder: '', // Unknown transponder for remote observations + departure: obs.origin, + arrival: obs.destination, + } + + return { callsign: obs.callsign, observation, metadata } } +// Types for WebSocket messages (match Rust types in server.rs) + interface InitMessage { type: 'init' session_facilities: string[] @@ -61,7 +84,7 @@ interface InitMessage { interface ObservationsMessage { type: 'observations' - data: ObservationData[] + data: RemoteObservationData[] } interface RemovalsMessage { @@ -149,37 +172,7 @@ export function useRemoteObservations(): void { case 'observations': { // Convert to timeline store format const now = Date.now() - const batch = message.data.map((obs) => { - const observation: AircraftObservation = { - latitude: obs.latitude, - longitude: obs.longitude, - altitude: obs.altitude, - heading: obs.heading, - groundspeed: obs.groundspeed, - groundTrack: obs.groundTrack, - verticalRate: obs.verticalRate, - onGround: obs.onGround, - pitch: obs.pitch, - roll: obs.roll, - headingIsTrue: obs.source === 'vnas' || obs.source === 'vatsim', - source: obs.source as 'vatsim' | 'vnas' | 'realtraffic', - observedAt: obs.observedAt, - receivedAt: now, // Use local time for reconciliation - displayDelay: - SOURCE_DISPLAY_DELAYS[obs.source as keyof typeof SOURCE_DISPLAY_DELAYS] ?? - SOURCE_DISPLAY_DELAYS.vatsim, - } - - const metadata: AircraftMetadata = { - cid: 0, // Unknown CID for remote observations - aircraftType: obs.typeCode ?? 'UNKN', - transponder: '', // Unknown transponder for remote observations - departure: obs.origin, - arrival: obs.destination, - } - - return { callsign: obs.callsign, observation, metadata } - }) + const batch = message.data.map((obs) => toTimelineObservation(obs, now)) if (batch.length > 0) { addObservationBatch(batch) diff --git a/src/renderer/services/SettingsSharedWorkerService.ts b/src/renderer/services/SettingsSharedWorkerService.ts index c591ff98..24ab240e 100644 --- a/src/renderer/services/SettingsSharedWorkerService.ts +++ b/src/renderer/services/SettingsSharedWorkerService.ts @@ -421,6 +421,7 @@ class SettingsSharedWorkerService { groundTrack: observation.groundTrack ?? observation.heading, verticalRate: observation.verticalRate ?? 0, onGround: observation.onGround ?? false, + heightAboveGround: observation.heightAboveGround, pitch: observation.pitch, roll: observation.roll, source: observation.source, diff --git a/src/renderer/stores/aircraftTimelineStore.ts b/src/renderer/stores/aircraftTimelineStore.ts index 0c01ba53..6a8c1536 100644 --- a/src/renderer/stores/aircraftTimelineStore.ts +++ b/src/renderer/stores/aircraftTimelineStore.ts @@ -449,6 +449,53 @@ function filterObservationsForSource(observations: AircraftObservation[], now: n return vnasOnly } +/** The newest observation that carries a height above ground, or null when none does. */ +function newestHeightSample(observations: AircraftObservation[]): AircraftObservation | null { + for (let i = observations.length - 1; i >= 0; i--) { + if (observations[i].heightAboveGround !== undefined) return observations[i] + } + return null +} + +/** + * Height above ground at the displayed instant. + * + * Lerped between two bracketing samples that both carry it; while extrapolating or held, the nearest sample's. + * Null when a bracketing sample lacks it, and once the newest sample carrying it was received + * `VNAS_PREFERENCE_THRESHOLD_MS` or more ago, so an aircraft fallen back to VATSIM is treated as a source + * without height. + */ +function heightAboveGroundAt( + observations: AircraftObservation[], + before: AircraftObservation | null, + after: AircraftObservation | null, + displayTime: number, + now: number, +): number | null { + const newestWithHeight = newestHeightSample(observations) + if (!newestWithHeight || now - newestWithHeight.receivedAt >= VNAS_PREFERENCE_THRESHOLD_MS) return null + + if (before && after) { + if (before.heightAboveGround === undefined || after.heightAboveGround === undefined) return null + const interval = after.observedAt - before.observedAt + const t = interval > 0 ? (displayTime - before.observedAt) / interval : 1 + return lerp(before.heightAboveGround, after.heightAboveGround, t) + } + return (before ?? after)?.heightAboveGround ?? null +} + +/** + * The interpolated state's ground flag, taken from the observation that supplies it. + * + * vNAS's flag is the 50/80 ft label hysteresis, not a ground contact, so without a height above ground at the + * displayed instant (a replay recorded before the field, or a bracket that mixes in a sample without it) it is + * null and the aircraft takes the blends of a source without ground state. The observation keeps its flag for labels. + */ +function drawingGroundFlag(flagSource: AircraftObservation, heightAboveGround: number | null): boolean | null { + if (flagSource.source === 'vnas' && heightAboveGround === null) return null + return flagSource.onGround +} + /** * Calculate the target display delay from observation intervals. * Uses the maximum recent interval plus a jitter buffer to ensure @@ -933,7 +980,7 @@ function interpolateTimeline( let altitude: number let groundspeed: number let groundTrack: number | null - let onGround: boolean | null + let groundFlagSource: AircraftObservation let pitch: number | null let roll: number | null let verticalRate: number | null @@ -1083,7 +1130,7 @@ function interpolateTimeline( } // Interpolate ADS-B data if available on both observations - onGround = after.onGround // Use the later observation's ground state + groundFlagSource = after // Use the later observation's ground state pitch = before.pitch !== null && after.pitch !== null ? lerp(before.pitch, after.pitch, t) : (after.pitch ?? before.pitch) roll = before.roll !== null && after.roll !== null ? lerp(before.roll, after.roll, t) : (after.roll ?? before.roll) @@ -1145,7 +1192,7 @@ function interpolateTimeline( } // Pass through ADS-B data from last observation - onGround = before.onGround + groundFlagSource = before pitch = before.pitch roll = before.roll @@ -1184,7 +1231,7 @@ function interpolateTimeline( groundspeed = heldOnGround ? 0 : after.groundspeed groundTrack = heldOnGround ? null : after.groundTrack // Pass through ADS-B data - onGround = after.onGround + groundFlagSource = after pitch = after.pitch roll = after.roll verticalRate = after.verticalRate @@ -1243,6 +1290,8 @@ function interpolateTimeline( newDynamicDelay = updatedState } + const heightAboveGround = heightAboveGroundAt(allObservations, before, after, displayTime, now) + return { result: { callsign, @@ -1254,7 +1303,8 @@ function interpolateTimeline( groundTrack, reportedGroundspeed: newestObs.groundspeed, // Extended ADS-B data - onGround, + onGround: drawingGroundFlag(groundFlagSource, heightAboveGround), + heightAboveGround, pitch, roll, verticalRate, diff --git a/src/renderer/stores/vnasStore.ts b/src/renderer/stores/vnasStore.ts index 66d7adb8..d35f7e48 100644 --- a/src/renderer/stores/vnasStore.ts +++ b/src/renderer/stores/vnasStore.ts @@ -21,10 +21,10 @@ let lastProcessedOAuthCallback: string | null = null const speedHistory = new Map() /** - * Ground state hysteresis tracking per aircraft. - * Prevents rapid toggling of the onGround flag when AGL oscillates near the threshold, - * which causes visible oscillation between terrain-clamped and floating positions - * (especially at high-elevation airports like KRNO where terrain models disagree). + * Ground state hysteresis tracking per aircraft, for the observation's `onGround` flag. + * The flag is for labels only: drawn height, pitch and ground contact come from the height above + * ground each observation carries (`heightAboveGround`), which the interpolation loop turns into + * contact per frame. The hysteresis keeps the flag from toggling when AGL oscillates near the threshold. * * Enter ground: AGL < 50ft, Exit ground: AGL > 80ft */ @@ -47,6 +47,19 @@ function getOnGroundWithHysteresis(callsign: string, altitudeAglFt: number): boo return onGround } +/** + * The ground fields of a vNAS observation: the 50/80 ft label flag and the simulator's height above ground in + * metres. A non-finite AGL leaves the height out, so that observation counts as one without height. + */ +function vnasGroundFields( + callsign: string, + altitudeAglFt: number, +): Pick { + const onGround = getOnGroundWithHysteresis(callsign, altitudeAglFt) + if (!Number.isFinite(altitudeAglFt)) return { onGround } + return { onGround, heightAboveGround: altitudeAglFt * FEET_TO_METERS } +} + /** Number of samples to keep in the speed history window */ const SPEED_WINDOW_SIZE = 5 @@ -787,9 +800,8 @@ export const useVnasStore = create((set, get) => ({ groundspeed: calculatedGroundspeed, // Calculated from position changes groundTrack: aircraft.trueGroundTrack ?? null, headingIsTrue: true, // vNAS heading is always reliable (from simulator) - // vNAS provides AGL directly from simulator - use it for ground detection - // Uses hysteresis to prevent oscillation at high-elevation airports - onGround: getOnGroundWithHysteresis(aircraft.callsign, aircraft.altitudeAgl), + // vNAS provides AGL directly from the simulator: the height for ground contact, plus the label flag + ...vnasGroundFields(aircraft.callsign, aircraft.altitudeAgl), pitch: null, roll: null, verticalRate: null, @@ -900,8 +912,8 @@ export const useVnasStore = create((set, get) => ({ groundspeed: calculatedSpeeds.get(ac.callsign) ?? 0, groundTrack: ac.trueGroundTrack ?? null, headingIsTrue: true, // vNAS heading is always reliable (from simulator) - // vNAS provides AGL directly from simulator - uses hysteresis to prevent oscillation - onGround: getOnGroundWithHysteresis(ac.callsign, ac.altitudeAgl), + // vNAS provides AGL directly from the simulator: the height for ground contact, plus the label flag + ...vnasGroundFields(ac.callsign, ac.altitudeAgl), pitch: null, roll: null, verticalRate: null, diff --git a/src/renderer/types/aircraft-timeline.ts b/src/renderer/types/aircraft-timeline.ts index 468ed545..d1b98ba2 100644 --- a/src/renderer/types/aircraft-timeline.ts +++ b/src/renderer/types/aircraft-timeline.ts @@ -43,6 +43,12 @@ export interface AircraftObservation { */ onGround: boolean | null + /** + * Height of the aircraft above the ground under it, in metres, as the source reports it (vNAS: the simulator's + * own AGL). Absent for VATSIM and RealTraffic; replay carries what was recorded. Ground contact is decided from it. + */ + heightAboveGround?: number + /** * Pitch angle in degrees. * For broadcast source: pre-interpolated pitch from main app. @@ -217,11 +223,19 @@ export interface TimelineInterpolationResult { // Extended ADS-B data (passed through from observations when available) /** - * On-ground flag from ADS-B transponder. - * true = on ground, false = airborne, null = unknown. + * On-ground flag for drawing, from the source's ground flag (ADS-B ground bit, vNAS 50/80 ft flag). + * true = on ground, false = airborne, null = unknown. Null when the flag comes from a vNAS observation + * and the result has no height above ground: the vNAS flag is for labels only. */ onGround: boolean | null + /** + * Height above ground in metres at the displayed instant: lerped between two samples that carry it, the last + * sample's while extrapolating or held. Null for sources without it, and once the samples carrying it are older + * than `VNAS_PREFERENCE_THRESHOLD_MS`. + */ + heightAboveGround: number | null + /** * Pitch angle in degrees (interpolated from observations if available). * For broadcast source: pre-interpolated pitch from main app. diff --git a/src/renderer/types/gear.ts b/src/renderer/types/gear.ts new file mode 100644 index 00000000..da2ac34d --- /dev/null +++ b/src/renderer/types/gear.ts @@ -0,0 +1,23 @@ +/** + * Landing gear animation types, used by `utils/gearAnimationController.ts` and its callers. + */ + +/** Ground contact as the gear rule sees it */ +export interface GearContact { + /** Whether the aircraft is in ground contact */ + inContact: boolean + /** Seconds since the aircraft was last in contact, or null when it has not been in contact since it was drawn */ + secondsSinceContact: number | null +} + +/** One frame of aircraft state for the gear */ +export interface GearFrameInput { + /** Altitude in feet AGL */ + altitudeFt: number + /** Vertical rate in feet per minute (positive = climbing) */ + verticalRateFpm: number + /** Whether the aircraft is in ground contact */ + inContact: boolean + /** Data-source time of this frame in milliseconds, the clock the time out of contact is counted on */ + dataTime: number +} diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index fbf27820..10657dbe 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -219,6 +219,13 @@ export type { TimelineInterpolationResult, } from './aircraft-timeline' +// ============================================================================ +// LANDING GEAR AND REMOTE OBSERVATION TYPES +// ============================================================================ + +export type { GearContact, GearFrameInput } from './gear' +export type { RemoteObservationData } from './remote' + // ============================================================================ // REALTRAFFIC API TYPES // ============================================================================ diff --git a/src/renderer/types/remote.ts b/src/renderer/types/remote.ts new file mode 100644 index 00000000..0e62af8d --- /dev/null +++ b/src/renderer/types/remote.ts @@ -0,0 +1,28 @@ +/** + * Types for the host's observations WebSocket as remote browsers receive it (match `ObservationData` in + * `src-tauri/src/server.rs`). + */ + +/** One aircraft observation as the host broadcasts it */ +export interface RemoteObservationData { + callsign: string + latitude: number + longitude: number + altitude: number + heading: number + groundspeed: number + groundTrack: number + verticalRate: number + onGround: boolean + /** Height above ground in metres; absent for sources without it and from hosts that predate the field */ + heightAboveGround?: number | null + pitch: number | null + roll: number | null + source: string + observedAt: number + receivedAt: number + typeCode: string | null + origin: string | null + destination: string | null + flightRules: string | null +} diff --git a/src/renderer/types/vatsim.ts b/src/renderer/types/vatsim.ts index 770c9871..0ba1ea72 100644 --- a/src/renderer/types/vatsim.ts +++ b/src/renderer/types/vatsim.ts @@ -217,13 +217,24 @@ export interface InterpolatedAircraftState extends AircraftState { verticalRate: number // Vertical rate in METERS/MINUTE turnRate: number // degrees/sec /** - * On-ground flag from authoritative source data (vNAS AGL < 50ft, ADS-B ground bit). - * - true = source confirms aircraft is on ground - * - false = source confirms aircraft is airborne - * - null = unknown, must infer from groundspeed/altitude - * More reliable than altitude-based detection when available. + * On-ground flag from the source (vNAS: below 50 ft, left above 80 ft, kept for labels; ADS-B ground bit). + * - true = source reports the aircraft on the ground + * - false = source reports it airborne + * - null = no flag (VATSIM) + * Ground contact for drawing and the gear is `inContact`, not this flag. */ isOnGround: boolean | null + /** + * Height above ground in metres reported by the source at the displayed instant (vNAS), or null for + * sources without it (VATSIM, RealTraffic); replay carries what was recorded. + */ + heightAboveGround: number | null + /** + * Ground contact decided per frame. From `heightAboveGround` when the source reports it: entered below 3 ft, + * left above 5 ft. Otherwise from the drawn result of the landing and departure height blends (drawn more than + * halfway onto the terrain). Null only before the aircraft's first drawn frame. + */ + inContact: boolean | null /** * Ground acceleration in knots per second. * Positive = accelerating, negative = decelerating. diff --git a/src/renderer/utils/gearAnimationController.ts b/src/renderer/utils/gearAnimationController.ts index f5a1b684..2d2394f9 100644 --- a/src/renderer/utils/gearAnimationController.ts +++ b/src/renderer/utils/gearAnimationController.ts @@ -8,15 +8,17 @@ * - 0.0 = Gear fully retracted (up) * - 1.0 = Gear fully extended (down) * - * ## Gear State Logic (with hysteresis) + * ## Gear State Logic * Gear EXTENDS when: - * - On the ground + * - In ground contact * - Descending below 2,000 ft AGL * * Gear RETRACTS when: - * - Climbing above 500 ft AGL + * - Climbing after 2 s out of ground contact (positive rate confirmed) + * - Climbing above 150 ft AGL, for an aircraft not in contact since it was drawn * - * The hysteresis prevents gear cycling during level flight near thresholds. + * Otherwise the gear keeps its state, so level flight does not cycle it. + * Each transition takes 8 s on an ease-out curve. * * ## Implementation Notes * We bypass Cesium's built-in animation system (which has issues with animationTime @@ -28,6 +30,7 @@ */ import type * as Cesium from 'cesium' +import type { GearContact, GearFrameInput } from '@/types' import { geoidService } from '../services/GeoidService' import { type AnimationSet, applyGearAnimationsPercent, parseAnimationSetFromUrl } from './gltfAnimationParser' @@ -58,7 +61,7 @@ export type GearState = 'up' | 'extending' | 'down' | 'retracting' export interface GearAnimationConfig { /** Altitude below which gear extends when descending (feet AGL) */ extendAltitude: number - /** Altitude above which gear retracts when climbing (feet AGL) */ + /** Altitude above which an aircraft not in contact since it was drawn retracts when climbing (feet AGL) */ retractAltitude: number /** Vertical rate threshold to consider aircraft "descending" (ft/min, negative) */ descentRateThreshold: number @@ -71,14 +74,24 @@ export interface GearAnimationConfig { /** Default gear animation configuration */ export const DEFAULT_GEAR_CONFIG: GearAnimationConfig = { extendAltitude: 2000, // Extend gear when descending below 2000ft AGL - retractAltitude: 150, // Retract gear when climbing above 150ft AGL + retractAltitude: 150, // Without a liftoff seen, retract when climbing above 150ft AGL descentRateThreshold: -100, // ft/min - consider descending if < -100 ft/min climbRateThreshold: 100, // ft/min - consider climbing if > 100 ft/min - transitionTime: 12.0, // 12 seconds to extend/retract (realistic timing) + transitionTime: 8.0, // 8 seconds to extend/retract (737-800: 8-10 s) } +/** Seconds out of ground contact before a climbing aircraft retracts its gear (positive rate confirmed) */ +export const GEAR_RETRACT_MIN_AIRBORNE_SECONDS = 2 + /** Per-aircraft gear animation state */ interface GearAnimationState { + /** Data-source time of the last frame in ground contact, or null when not in contact since it was drawn */ + lastContactTime: number | null + /** + * Whether the leave-contact retract has been commanded since the last contact. While set and out of contact the + * gear holds its state (bar the extension when descending), so a later climb below retractAltitude does not retract. + */ + leaveContactRetractDone: boolean /** Current gear progress (0 = up, 1 = down) */ progress: number /** Target gear progress */ @@ -101,6 +114,8 @@ function getGearState(callsign: string): GearAnimationState { let state = gearStates.get(callsign) if (!state) { state = { + lastContactTime: null, + leaveContactRetractDone: false, progress: 1.0, // Start with gear down (safer default) targetProgress: 1.0, transitionStartTime: 0, @@ -127,14 +142,14 @@ function getGearState(callsign: string): GearAnimationState { * @param callsign - Aircraft callsign * @param altitude - Current altitude in feet AGL * @param verticalRate - Vertical rate in feet per minute (positive = climbing) - * @param isOnGround - Whether aircraft is on the ground + * @param inContact - Whether the aircraft is in ground contact * @param config - Gear animation configuration */ export function initializeGearState( callsign: string, altitude: number, verticalRate: number, - isOnGround: boolean, + inContact: boolean, config: GearAnimationConfig = DEFAULT_GEAR_CONFIG, ): void { // For NEW aircraft (verticalRate=0 indicates no history), be more conservative about gear state. @@ -149,12 +164,15 @@ export function initializeGearState( // For new aircraft, only assume gear UP if clearly at cruise altitude // For aircraft with vertical rate history, use normal logic - const assumeGearDown = isNewAircraft ? !clearlyCruising : isOnGround || altitude < config.extendAltitude + const assumeGearDown = isNewAircraft ? !clearlyCruising : inContact || altitude < config.extendAltitude // Calculate what the gear state should be based on current aircraft conditions - const initialProgress = calculateTargetGearProgress(altitude, verticalRate, isOnGround, assumeGearDown, config) + const contact: GearContact = { inContact, secondsSinceContact: inContact ? 0 : null } + const initialProgress = calculateTargetGearProgress(altitude, verticalRate, contact, assumeGearDown, config) const state: GearAnimationState = { + lastContactTime: null, + leaveContactRetractDone: false, progress: initialProgress, targetProgress: initialProgress, transitionStartTime: 0, @@ -167,14 +185,17 @@ export function initializeGearState( /** * Determine target gear state based on aircraft conditions * - * Uses hysteresis to prevent gear cycling: + * - Gear is down while in ground contact * - Gear extends when descending below extendAltitude (2000ft default) - * - Gear retracts when climbing above retractAltitude (500ft default) - * - Between thresholds, gear maintains current state + * - After leaving contact, gear retracts once climbing and out of contact for + * GEAR_RETRACT_MIN_AIRBORNE_SECONDS (positive rate confirmed) + * - An aircraft not in contact since it was drawn (airborne spawn, go-around without touchdown) retracts when + * climbing above retractAltitude and stays down below it + * - Otherwise the gear keeps its current state, so level flight does not cycle it * * @param altitude - Altitude in feet AGL * @param verticalRate - Vertical rate in feet per minute (positive = climbing) - * @param isOnGround - Whether aircraft is on the ground + * @param contact - Ground contact and the time since the aircraft was last in contact * @param currentGearDown - Current gear state (true = down, false = up) * @param config - Gear animation configuration * @returns Target gear progress (0 = up, 1 = down) @@ -182,35 +203,77 @@ export function initializeGearState( export function calculateTargetGearProgress( altitude: number, verticalRate: number, - isOnGround: boolean, + contact: GearContact, currentGearDown: boolean, config: GearAnimationConfig = DEFAULT_GEAR_CONFIG, ): number { - // Always gear down when on ground - if (isOnGround) { + if (contact.inContact) { return 1.0 } - const isDescending = verticalRate < config.descentRateThreshold const isClimbing = verticalRate > config.climbRateThreshold + const hold = currentGearDown ? 1.0 : 0.0 - // Extend gear when descending below extend altitude - if (altitude < config.extendAltitude && isDescending) { + if (isExtendingOnDescent(altitude, verticalRate, config)) { return 1.0 } - // Retract gear when climbing above retract altitude - if (altitude > config.retractAltitude && isClimbing) { + if (contact.secondsSinceContact === null) { + return targetWithoutContactSeen(altitude, isClimbing, hold, config) + } + + if (isLeaveContactRetract(verticalRate, contact, config)) { return 0.0 } - // Very low altitude - always gear down for safety - if (altitude < config.retractAltitude) { - return 1.0 + return hold +} + +/** Whether the aircraft is descending below extendAltitude, where the gear extends. */ +function isExtendingOnDescent(altitude: number, verticalRate: number, config: GearAnimationConfig): boolean { + return altitude < config.extendAltitude && verticalRate < config.descentRateThreshold +} + +/** Whether the leave-contact retract applies: out of contact for the minimum time and climbing (positive rate). */ +function isLeaveContactRetract(verticalRate: number, contact: GearContact, config: GearAnimationConfig): boolean { + return ( + !contact.inContact && + contact.secondsSinceContact !== null && + contact.secondsSinceContact >= GEAR_RETRACT_MIN_AIRBORNE_SECONDS && + verticalRate > config.climbRateThreshold + ) +} + +/** + * Target gear progress for an aircraft not in contact since it was drawn (airborne spawn, go-around without + * touchdown): up when climbing above retractAltitude, down below it, else held. + */ +function targetWithoutContactSeen( + altitude: number, + isClimbing: boolean, + hold: number, + config: GearAnimationConfig, +): number { + if (altitude > config.retractAltitude && isClimbing) { + return 0.0 } + return altitude < config.retractAltitude ? 1.0 : hold +} - // Between thresholds or level flight - maintain current state (hysteresis) - return currentGearDown ? 1.0 : 0.0 +/** + * Keep the remembered last contact for this frame: a contact records its time and clears the leave-contact retract; + * passing retractAltitude out of contact or a seek back past the last contact (replay scrub) forgets both, so the + * aircraft takes the retractAltitude rule until its next contact and the time out of contact never runs negative. + */ +function updateRememberedContact(state: GearAnimationState, frame: GearFrameInput, config: GearAnimationConfig): void { + const seekedBack = state.lastContactTime !== null && frame.dataTime < state.lastContactTime + if (frame.inContact) { + state.lastContactTime = frame.dataTime + state.leaveContactRetractDone = false + } else if (seekedBack || frame.altitudeFt > config.retractAltitude) { + state.lastContactTime = null + state.leaveContactRetractDone = false + } } /** @@ -218,24 +281,39 @@ export function calculateTargetGearProgress( * Should be called each frame for aircraft with animations * * @param callsign - Aircraft callsign - * @param altitude - Current altitude in feet AGL - * @param verticalRate - Vertical rate in feet per minute (positive = climbing) - * @param isOnGround - Whether aircraft is on ground - * @param currentTime - Current time in milliseconds (Date.now()) + * @param frame - This frame's altitude, vertical rate, ground contact and data-source time + * @param currentTime - Current time in milliseconds (Date.now()), the clock the transition runs on * @param config - Gear animation configuration * @returns Current gear progress (0-1) */ export function updateGearAnimation( callsign: string, - altitude: number, - verticalRate: number, - isOnGround: boolean, + frame: GearFrameInput, currentTime: number, config: GearAnimationConfig = DEFAULT_GEAR_CONFIG, ): number { const state = getGearState(callsign) + updateRememberedContact(state, frame, config) + const contact: GearContact = { + inContact: frame.inContact, + secondsSinceContact: state.lastContactTime === null ? null : (frame.dataTime - state.lastContactTime) / 1000, + } const currentGearDown = state.progress > 0.5 // Consider gear "down" if more than halfway - const targetProgress = calculateTargetGearProgress(altitude, verticalRate, isOnGround, currentGearDown, config) + let targetProgress: number + if (state.leaveContactRetractDone && !frame.inContact) { + // Hold the commanded target, not the progress: the retract has just started while the gear is still down + const extending = isExtendingOnDescent(frame.altitudeFt, frame.verticalRateFpm, config) + targetProgress = extending ? 1.0 : state.targetProgress + } else { + targetProgress = calculateTargetGearProgress( + frame.altitudeFt, + frame.verticalRateFpm, + contact, + currentGearDown, + config, + ) + state.leaveContactRetractDone = isLeaveContactRetract(frame.verticalRateFpm, contact, config) + } // Check if target changed if (Math.abs(targetProgress - state.targetProgress) > 0.01) { @@ -251,8 +329,8 @@ export function updateGearAnimation( const elapsed = (currentTime - state.transitionStartTime) / 1000 // Convert to seconds const transitionProgress = Math.min(elapsed / config.transitionTime, 1.0) - // Ease in-out for smooth animation - const easedProgress = easeInOutCubic(transitionProgress) + // Ease out: the gear moves at once and slows into its stop + const easedProgress = easeOutCubic(transitionProgress) // Interpolate between start and target state.progress = @@ -334,10 +412,10 @@ export function applyGearAnimation( } /** - * Cubic ease in-out function for smooth transitions + * Cubic ease-out function for gear transitions */ -function easeInOutCubic(t: number): number { - return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2 +function easeOutCubic(t: number): number { + return 1 - (1 - t) ** 3 } /** diff --git a/tests/motion/contact.test.ts b/tests/motion/contact.test.ts new file mode 100644 index 00000000..bda261e7 --- /dev/null +++ b/tests/motion/contact.test.ts @@ -0,0 +1,112 @@ +// @vitest-environment node +/** + * Ground contact from the vNAS height above ground does not flicker when the drawn (Cesium) terrain and the + * simulator's terrain disagree, the KRNO case the 50/80 ft flag hysteresis was added for. Contact is decided on + * the simulator's height, so a 1.5 m mismatch either way leaves one change of contact per flight, and the + * aircraft sits on the drawn runway through the roll. + */ +import { beforeAll, describe, expect, it, vi } from 'vitest' +import { type FrameRecord, runScenario, type ScenarioResult } from './harness' + +vi.mock('react', () => { + const api = { + useEffect: (fn: () => unknown) => { + fn() + }, + useState: (v: T) => [v, () => {}], + } + return { ...api, default: api } +}) + +const TERRAIN_MISMATCH_M = 1.5 +const GROUND_OFFSET_M = 0.1 // GROUND_AIRCRAFT_TERRAIN_OFFSET: the clamp draws the wheels this far above the terrain +const ROLLOUT_HEIGHT_ERR_MAX_M = 0.05 // measured 0.000 m +// Wider than the 2 ft (0.61 m) between the 3 ft entry and the 5 ft exit only together with the sink between samples +const HEIGHT_JITTER_M = 0.5 +// The reference airport sits this far below a field the aircraft rolls on without a terrain sample +const UNSAMPLED_REFERENCE_OFFSET_M = -300 +const UNSAMPLED_ROLL_HEIGHT_ERR_MAX_M = 0.2 // measured 0.063 m (299.9 m when drawn at the reference airport) +const FLARE_CHECK_MAX_AGL_M = 15 // FLARE_START_ALTITUDE_METERS +const FLARE_PITCH_DIFF_MAX_DEG = 0.01 // measured 0.000 deg + +const scenarios = new Map() +let jittered: ScenarioResult +let unsampled: ScenarioResult + +beforeAll(async () => { + for (const offset of [TERRAIN_MISMATCH_M, -TERRAIN_MISMATCH_M]) { + scenarios.set(offset, await runScenario({ fps: 60, useVnas: true, vnasFirst: false, durationS: 720, terrainOffsetM: offset })) + } + jittered = await runScenario({ fps: 60, useVnas: true, vnasFirst: false, durationS: 720, vnasHeightJitterM: HEIGHT_JITTER_M }) + unsampled = await runScenario({ + fps: 60, + useVnas: true, + vnasFirst: false, + durationS: 240, + unsampledReferenceOffsetM: UNSAMPLED_REFERENCE_OFFSET_M, + }) +}, 300_000) + +/** Number of times contact changes between frames that carry it. */ +function contactChanges(recs: FrameRecord[]): number { + let changes = 0 + let last: boolean | null = null + for (const r of recs) { + if (r.inContact === null) continue + if (last !== null && r.inContact !== last) changes++ + last = r.inContact + } + return changes +} + +describe.each([TERRAIN_MISMATCH_M, -TERRAIN_MISMATCH_M])('drawn terrain %f m from the simulator ground', (offset) => { + it('the arrival enters contact once and stays in it through the roll', () => { + const recs = (scenarios.get(offset) as ScenarioResult).frames.get('ARR1') ?? [] + expect(recs.some((r) => r.inContact === true)).toBe(true) + expect(contactChanges(recs)).toBe(1) + }) + + it('the departure leaves contact once', () => { + const recs = (scenarios.get(offset) as ScenarioResult).frames.get('DEP1') ?? [] + expect(recs.some((r) => r.inContact === false)).toBe(true) + expect(contactChanges(recs)).toBe(1) + }) + + it(`the arrival is drawn on the drawn runway through the rollout, within ${ROLLOUT_HEIGHT_ERR_MAX_M} m`, () => { + const recs = ((scenarios.get(offset) as ScenarioResult).frames.get('ARR1') ?? []).filter( + (r) => r.truth.segment === 'rollout', + ) + expect(recs.length).toBeGreaterThan(100) + const worst = recs.reduce((m, r) => Math.max(m, Math.abs(r.agl - (offset + GROUND_OFFSET_M))), 0) + expect(worst, `rollout height off the drawn runway by ${worst.toFixed(3)} m`).toBeLessThanOrEqual( + ROLLOUT_HEIGHT_ERR_MAX_M, + ) + }) +}) + +describe(`a vNAS height jittering by ${HEIGHT_JITTER_M} m between samples`, () => { + it.each(['ARR1', 'DEP1'])('%s changes contact exactly once', (callsign) => { + const recs = jittered.frames.get(callsign) ?? [] + expect(recs.length).toBeGreaterThan(1000) + expect(contactChanges(recs)).toBe(1) + }) +}) + +it('a rolling vNAS aircraft without a terrain sample keeps its reported height, not the reference airport', () => { + const recs = (unsampled.frames.get('DEP1') ?? []).filter((r) => r.truth.segment === 'takeoff_roll' && r.gs > 10) + expect(recs.length).toBeGreaterThan(100) + const worst = recs.reduce((m, r) => Math.max(m, Math.abs(r.agl - r.truth.agl)), 0) + expect(worst, `drawn ${worst.toFixed(3)} m off its reported height`).toBeLessThanOrEqual(UNSAMPLED_ROLL_HEIGHT_ERR_MAX_M) +}) + +it(`the vNAS arrival's flare pitch does not depend on the drawn terrain (±${TERRAIN_MISMATCH_M} m)`, () => { + const low = (offset: number) => + ((scenarios.get(offset) as ScenarioResult).frames.get('ARR1') ?? []).filter( + (r) => ['glide', 'flare'].includes(r.truth.segment) && r.truth.agl < FLARE_CHECK_MAX_AGL_M, + ) + const above = new Map(low(TERRAIN_MISMATCH_M).map((r) => [r.frameTime, r.pitch])) + const below = low(-TERRAIN_MISMATCH_M).filter((r) => above.has(r.frameTime)) + expect(below.length).toBeGreaterThan(100) + const worst = below.reduce((m, r) => Math.max(m, Math.abs(r.pitch - (above.get(r.frameTime) ?? Number.NaN))), 0) + expect(worst, `flare pitch differs by ${worst.toFixed(3)} deg`).toBeLessThanOrEqual(FLARE_PITCH_DIFF_MAX_DEG) +}) diff --git a/tests/motion/gearRule.test.ts b/tests/motion/gearRule.test.ts new file mode 100644 index 00000000..969c0dd5 --- /dev/null +++ b/tests/motion/gearRule.test.ts @@ -0,0 +1,190 @@ +// @vitest-environment node +/** + * The gear rule driven by ground contact (findings 6.2 and 6.3): down in contact; up once the aircraft has been out + * of contact for 2 s and climbs, rather than at 150 ft; an aircraft not in contact since it was drawn keeps the + * 150 ft rule; the existing extension below 2000 ft when descending; and an 8 s ease-out transition. + */ +import { beforeEach, describe, expect, it } from 'vitest' +import { + calculateTargetGearProgress, + clearAllGearStates, + initializeGearState, + updateGearAnimation, +} from '@/utils/gearAnimationController' + +const CLIMB_FPM = 2000 +const LEVEL_FPM = 0 +const DESCENT_FPM = -700 +const IN_CONTACT = { inContact: true, secondsSinceContact: 0 } +const NEVER_IN_CONTACT = { inContact: false, secondsSinceContact: null } +const outOfContactFor = (seconds: number) => ({ inContact: false, secondsSinceContact: seconds }) + +describe('calculateTargetGearProgress', () => { + it('keeps the gear down in contact, even when the reported rate climbs', () => { + expect(calculateTargetGearProgress(0, CLIMB_FPM, IN_CONTACT, false)).toBe(1) + }) + + it('holds the gear down for the first 2 s out of contact, even when climbing', () => { + expect(calculateTargetGearProgress(10, CLIMB_FPM, outOfContactFor(1.9), true)).toBe(1) + }) + + it('retracts 2 s after leaving contact with a positive climb, well below 150 ft', () => { + expect(calculateTargetGearProgress(10, CLIMB_FPM, outOfContactFor(2), true)).toBe(0) + }) + + it('holds the gear down out of contact until the climb is above 100 fpm', () => { + expect(calculateTargetGearProgress(10, 50, outOfContactFor(5), true)).toBe(1) + }) + + it('an aircraft not in contact since it was drawn retracts only above 150 ft', () => { + expect(calculateTargetGearProgress(100, CLIMB_FPM, NEVER_IN_CONTACT, true)).toBe(1) + expect(calculateTargetGearProgress(200, CLIMB_FPM, NEVER_IN_CONTACT, true)).toBe(0) + }) + + it('extends when descending below 2000 ft', () => { + expect(calculateTargetGearProgress(1500, DESCENT_FPM, NEVER_IN_CONTACT, false)).toBe(1) + expect(calculateTargetGearProgress(1500, DESCENT_FPM, outOfContactFor(60), false)).toBe(1) + }) + + it('holds the current state in level flight', () => { + expect(calculateTargetGearProgress(1500, LEVEL_FPM, outOfContactFor(60), false)).toBe(0) + expect(calculateTargetGearProgress(1500, LEVEL_FPM, outOfContactFor(60), true)).toBe(1) + }) +}) + +describe('updateGearAnimation', () => { + beforeEach(() => { + clearAllGearStates() + }) + + const frame = (inContact: boolean, altitudeFt: number, verticalRateFpm: number, dataTime: number) => ({ + altitudeFt, + verticalRateFpm, + inContact, + dataTime, + }) + + it('an aircraft spawned in contact starts with the gear down', () => { + initializeGearState('SPAWN', 0, LEVEL_FPM, true) + expect(updateGearAnimation('SPAWN', frame(true, 0, LEVEL_FPM, 0), 0)).toBe(1) + }) + + it('retracts 2 s after leaving contact, over 8 s with an ease-out curve', () => { + const t0 = 1_000_000 + initializeGearState('DEP', 0, LEVEL_FPM, true) + expect(updateGearAnimation('DEP', frame(true, 0, LEVEL_FPM, t0), t0)).toBe(1) + expect(updateGearAnimation('DEP', frame(false, 10, CLIMB_FPM, t0 + 1900), t0 + 1900)).toBe(1) + expect(updateGearAnimation('DEP', frame(false, 30, CLIMB_FPM, t0 + 2000), t0 + 2000)).toBe(1) + // Ease-out cubic: 1 - (1 - 1/8)^3 of the travel done one second in + expect(updateGearAnimation('DEP', frame(false, 60, CLIMB_FPM, t0 + 3000), t0 + 3000)).toBeCloseTo((7 / 8) ** 3, 6) + expect(updateGearAnimation('DEP', frame(false, 200, CLIMB_FPM, t0 + 9999), t0 + 9999)).toBeGreaterThan(0) + expect(updateGearAnimation('DEP', frame(false, 200, CLIMB_FPM, t0 + 10_000), t0 + 10_000)).toBe(0) + }) + + it('the 2 s out of contact count on the data time, not the transition clock', () => { + const t0 = 1_000_000 + initializeGearState('REPLAY', 0, LEVEL_FPM, true) + updateGearAnimation('REPLAY', frame(true, 0, LEVEL_FPM, t0), 0) + expect(updateGearAnimation('REPLAY', frame(false, 10, CLIMB_FPM, t0 + 1900), 60_000)).toBe(1) + // A wall-clock count would have started the retraction on the call above and moved the gear by now + expect(updateGearAnimation('REPLAY', frame(false, 10, CLIMB_FPM, t0 + 1900), 61_000)).toBe(1) + }) + + it('a seek back before the last contact forgets it, so the gear does not hold down for good', () => { + const t0 = 1_000_000 + initializeGearState('SEEK', 0, LEVEL_FPM, true) + updateGearAnimation('SEEK', frame(true, 0, LEVEL_FPM, t0), 0) + // Seek 60 s back to the aircraft climbing out at 2000 ft: not in contact since the seek, so the 150 ft rule + updateGearAnimation('SEEK', frame(false, 2000, CLIMB_FPM, t0 - 60_000), 1000) + expect(updateGearAnimation('SEEK', frame(false, 2050, CLIMB_FPM, t0 - 59_000), 2000)).toBeLessThan(1) + }) +}) + +describe('updateGearAnimation after the leave-contact retract', () => { + beforeEach(() => { + clearAllGearStates() + }) + + const t0 = 1_000_000 + const BRIEF_CLIMB_FPM = 300 + /** One step of a flight: contact, height (ft), vertical rate (fpm) and seconds from t0, on both clocks. */ + type Step = [boolean, number, number, number] + /** Feeds the steps in order and returns the gear progress after the last one. */ + const fly = (callsign: string, steps: Step[]): number => { + let progress = Number.NaN + for (const [inContact, altitudeFt, verticalRateFpm, s] of steps) { + const time = t0 + s * 1000 + progress = updateGearAnimation(callsign, { altitudeFt, verticalRateFpm, inContact, dataTime: time }, time) + } + return progress + } + const LIFTOFF_AND_RETRACT: Step[] = [ + [true, 0, LEVEL_FPM, 0], + [false, 10, CLIMB_FPM, 1], + [false, 30, CLIMB_FPM, 2], + ] + /** Pattern at 1000 ft, then a descent onto a short final below 150 ft with the gear down. */ + const PATTERN_TO_SHORT_FINAL: Step[] = [ + [false, 1000, CLIMB_FPM, 40], + [false, 1000, LEVEL_FPM, 60], + [false, 800, DESCENT_FPM, 100], + [false, 120, DESCENT_FPM, 200], + [false, 100, DESCENT_FPM, 210], + ] + + it('pattern traffic: a brief climb on a short final below 150 ft does not start the gear up', () => { + initializeGearState('PATTERN', 0, LEVEL_FPM, true) + expect(fly('PATTERN', [...LIFTOFF_AND_RETRACT, [false, 60, CLIMB_FPM, 3]])).toBeLessThan(1) + expect(fly('PATTERN', PATTERN_TO_SHORT_FINAL)).toBe(1) + expect(fly('PATTERN', [[false, 102, BRIEF_CLIMB_FPM, 211]])).toBe(1) + expect(fly('PATTERN', [[false, 104, BRIEF_CLIMB_FPM, 212]])).toBe(1) + expect(fly('PATTERN', [[false, 100, DESCENT_FPM, 213]])).toBe(1) + }) + + it('touch-and-go: the leave-contact retract fires again 2 s after the second liftoff', () => { + initializeGearState('TGO', 0, LEVEL_FPM, true) + fly('TGO', [...LIFTOFF_AND_RETRACT, ...PATTERN_TO_SHORT_FINAL]) + expect( + fly('TGO', [ + [true, 0, DESCENT_FPM, 230], + [true, 0, LEVEL_FPM, 235], + [false, 10, CLIMB_FPM, 236], + [false, 20, CLIMB_FPM, 237.9], + ]), + ).toBe(1) + fly('TGO', [[false, 30, CLIMB_FPM, 238]]) + expect(fly('TGO', [[false, 60, CLIMB_FPM, 239]])).toBeLessThan(1) + }) + + it('a liftoff that passes 150 ft before climbing faster than 100 fpm retracts by the 150 ft rule, and only then', () => { + initializeGearState('SLOW', 0, LEVEL_FPM, true) + expect( + fly('SLOW', [ + [true, 0, LEVEL_FPM, 0], + [false, 10, 50, 1], + [false, 100, 50, 5], + [false, 160, 50, 8], + ]), + ).toBe(1) + fly('SLOW', [[false, 200, CLIMB_FPM, 10]]) + expect(fly('SLOW', [[false, 260, CLIMB_FPM, 11]])).toBeLessThan(1) + expect(fly('SLOW', PATTERN_TO_SHORT_FINAL)).toBe(1) + expect(fly('SLOW', [[false, 102, BRIEF_CLIMB_FPM, 211]])).toBe(1) + expect(fly('SLOW', [[false, 104, BRIEF_CLIMB_FPM, 212]])).toBe(1) + }) + + it('a go-around from a low pattern keeps the gear down below 150 ft and retracts by the 150 ft rule above it', () => { + initializeGearState('LOWGA', 0, LEVEL_FPM, true) + fly('LOWGA', [...LIFTOFF_AND_RETRACT, [false, 100, LEVEL_FPM, 20]]) + expect( + fly('LOWGA', [ + [false, 80, DESCENT_FPM, 30], + [false, 40, DESCENT_FPM, 40], + ]), + ).toBe(1) + expect(fly('LOWGA', [[false, 50, CLIMB_FPM, 41]])).toBe(1) + expect(fly('LOWGA', [[false, 80, CLIMB_FPM, 42]])).toBe(1) + fly('LOWGA', [[false, 160, CLIMB_FPM, 45]]) + expect(fly('LOWGA', [[false, 200, CLIMB_FPM, 46]])).toBeLessThan(1) + }) +}) diff --git a/tests/motion/harness.ts b/tests/motion/harness.ts index 7a7e33ae..1d65be4d 100644 --- a/tests/motion/harness.ts +++ b/tests/motion/harness.ts @@ -55,6 +55,26 @@ export interface ScenarioOptions { useVnas: boolean vnasFirst: boolean durationS: number + /** Height of the drawn (Cesium) terrain above the sim's ground, in metres; 0 when omitted. */ + terrainOffsetM?: number + /** + * When set, no aircraft gets a terrain sample (as above 300 m AGL, or a sample dropped) and the reference airport + * elevation is this many metres above the sim's ground. + */ + unsampledReferenceOffsetM?: number + /** Metres added to and taken from the vNAS height above ground on alternate samples (floored at 0); 0 when omitted. */ + vnasHeightJitterM?: number + /** vNAS observations carry no height above ground, as in a replay recorded before the field existed. */ + vnasWithoutHeight?: boolean + /** vNAS observations carry no 50/80 ft ground flag (null), as VATSIM's do. */ + vnasWithoutGroundFlag?: boolean +} + +/** What each vNAS observation carries besides its position. */ +interface VnasFeedOptions { + jitterM: number + withHeight: boolean + withGroundFlag: boolean } export interface FrameRecord { @@ -72,6 +92,7 @@ export interface FrameRecord { motionBearing: number | null isOnGroundEntry: boolean | null onGroundTimeline: boolean | null + inContact: boolean | null gearApp: number gearEllipsoidal: number displayDelayMs: number @@ -172,6 +193,7 @@ function makeObservation( receivedAt: 0, source, displayDelay: source === 'vnas' ? 1500 : 17000, + ...(source === 'vnas' ? { heightAboveGround: s.agl } : {}), } } @@ -185,7 +207,13 @@ function vatsimObservations(f: Flight, times: number[], groundEllip: number): Ob } /** vNAS at 1 Hz, receiving 80 ms later, with the 50/80 ft ground hysteresis and silence while parked. */ -function vnasObservations(f: Flight, start: number, durationS: number, groundEllip: number): Observation[] { +function vnasObservations( + f: Flight, + start: number, + durationS: number, + groundEllip: number, + feed: VnasFeedOptions, +): Observation[] { const out: Observation[] = [] const meta = { cid: 0, aircraftType: 'B738', transponder: '', departure: null, arrival: null } const end = f.samples[f.samples.length - 1].t @@ -195,7 +223,12 @@ function vnasObservations(f: Flight, start: number, durationS: number, groundEll const prevS = truthAt(f.samples, tg - f.offset - 2) if (s.gs < 1 && prevS && prevS.gs < 1) continue onGround = onGround ? s.agl / 0.3048 < 80 : s.agl / 0.3048 < 50 - const observation = makeObservation(f, tg, 'vnas', onGround, groundEllip) + const observation = makeObservation(f, tg, 'vnas', feed.withGroundFlag ? onGround : null, groundEllip) + if (feed.withHeight) { + observation.heightAboveGround = Math.max(0, s.agl + (out.length % 2 === 0 ? -feed.jitterM : feed.jitterM)) + } else { + delete observation.heightAboveGround + } observation.receivedAt = observation.observedAt + 80 out.push({ callsign: f.callsign, receivedAt: observation.receivedAt, isVatsim: false, observation, metadata: meta }) } @@ -216,7 +249,13 @@ function buildObservations(flights: Flight[], opts: ScenarioOptions, groundEllip ) if (opts.useVnas) { const firstVatsim = times[0] ?? f.offset - out.push(...vnasObservations(f, opts.vnasFirst ? f.offset : firstVatsim + 20, opts.durationS, groundEllip)) + const start = opts.vnasFirst ? f.offset : firstVatsim + 20 + const feed: VnasFeedOptions = { + jitterM: opts.vnasHeightJitterM ?? 0, + withHeight: !opts.vnasWithoutHeight, + withGroundFlag: !opts.vnasWithoutGroundFlag, + } + out.push(...vnasObservations(f, start, opts.durationS, groundEllip, feed)) } } out.sort((a, b) => a.receivedAt - b.receivedAt) @@ -254,11 +293,15 @@ function buildRunwayContext(runwayThr: { x: number; y: number }): SortContext { } /** Flat terrain under every aircraft below 300 m AGL or 40 kt, the gate useGroundAircraftTerrain uses. */ -function terrainFor(map: AircraftMap, groundEllip: number): Map { +function terrainFor( + map: AircraftMap, + groundEllip: number, + terrainHeight: number, +): Map { const terrain = new Map() for (const e of map.values()) { if (e.interpolatedGroundspeed < 40 || e.interpolatedAltitude - groundEllip < 300) { - terrain.set(e.callsign, { height: groundEllip, slopeDegrees: 0 }) + terrain.set(e.callsign, { height: terrainHeight, slopeDegrees: 0 }) } } return terrain @@ -292,7 +335,8 @@ function recordFrame(deps: FrameDeps, feed: FeedState, e: AircraftEntry, now: nu const motionBearing = moved ? ((Math.atan2(x - prev.x, y - prev.y) * 180) / Math.PI + 360) % 360 : null feed.prevXY.set(e.callsign, { x, y }) - const onGroundApp = e.interpolatedGroundspeed < 40 + // The gear's ground input is the entry's contact, as useAircraftModels passes it + const onGroundApp = e.inContact === true const vrFpm = e.verticalRate * 3.28084 const aglFtApp = deps.gear.gearHeightAboveGroundMeters( @@ -324,8 +368,17 @@ function recordFrame(deps: FrameDeps, feed: FeedState, e: AircraftEntry, now: nu motionBearing, isOnGroundEntry: e.isOnGround, onGroundTimeline: ts.onGround, - gearApp: deps.gear.updateGearAnimation(e.callsign, aglFtApp, vrFpm, onGroundApp, now), - gearEllipsoidal: deps.gear.updateGearAnimation(`${e.callsign}#ell`, aglFtEll, vrFpm, onGroundApp, now), + inContact: e.inContact, + gearApp: deps.gear.updateGearAnimation( + e.callsign, + { altitudeFt: aglFtApp, verticalRateFpm: vrFpm, inContact: onGroundApp, dataTime: e.timestamp }, + now, + ), + gearEllipsoidal: deps.gear.updateGearAnimation( + `${e.callsign}#ell`, + { altitudeFt: aglFtEll, verticalRateFpm: vrFpm, inContact: onGroundApp, dataTime: e.timestamp }, + now, + ), displayDelayMs: ts.displayDelay, extrapolating: ts.isExtrapolating, }) @@ -359,6 +412,8 @@ export async function runScenario(opts: ScenarioOptions): Promise [f.callsign, f])) } @@ -383,7 +438,8 @@ export async function runScenario(opts: ScenarioOptions): Promise void) | null rafCallback = null diff --git a/tests/motion/heightAboveGround.test.ts b/tests/motion/heightAboveGround.test.ts new file mode 100644 index 00000000..e61ee1b5 --- /dev/null +++ b/tests/motion/heightAboveGround.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment node +/** + * The height above ground a source reports (vNAS) reaches the interpolated timeline state: lerped between two + * samples, carried while extrapolating past the last one, absent for sources without it (VATSIM), and dropped + * once the source's samples are older than the vNAS preference window. + */ +import { beforeEach, expect, it, vi } from 'vitest' +import { SOURCE_DISPLAY_DELAYS, VNAS_PREFERENCE_THRESHOLD_MS } from '@/constants/aircraft-timeline' +import type { AircraftDataSource, AircraftObservation } from '@/types/aircraft-timeline' + +type TimelineModule = typeof import('@/stores/aircraftTimelineStore') + +const T0 = Date.UTC(2026, 0, 1, 12, 0, 0) +const RECEIVE_LAG_MS = 80 +const METADATA = { cid: 0, aircraftType: 'B738', transponder: '', departure: null, arrival: null } + +let tl: TimelineModule + +beforeEach(async () => { + vi.resetModules() + // vnasStore registers a removal listener at module init; loading it first resolves the store import cycle + // in the same order the app does. + await import('@/stores/vnasStore') + tl = await import('@/stores/aircraftTimelineStore') +}) + +function observation( + source: AircraftDataSource, + observedAt: number, + heightAboveGround: number | undefined, +): AircraftObservation { + return { + latitude: 37.72, + longitude: -122.22 + (observedAt - T0) * 1e-8, + altitude: 100, + heading: 90, + groundspeed: 140, + groundTrack: 90, + headingIsTrue: true, + onGround: null, + pitch: null, + roll: null, + verticalRate: null, + observedAt, + receivedAt: observedAt + RECEIVE_LAG_MS, + source, + displayDelay: SOURCE_DISPLAY_DELAYS[source], + ...(heightAboveGround === undefined ? {} : { heightAboveGround }), + } +} + +/** Feeds two vNAS samples one second apart, at 10 m and then 4 m above ground. */ +function feedTwoVnasSamples(): void { + const store = tl.useAircraftTimelineStore.getState() + store.addObservation('VNAS1', observation('vnas', T0, 10), METADATA) + store.addObservation('VNAS1', observation('vnas', T0 + 1000, 4), METADATA) +} + +it('the height above ground is lerped between the two vNAS samples around the displayed instant', () => { + feedTwoVnasSamples() + const now = T0 + RECEIVE_LAG_MS + SOURCE_DISPLAY_DELAYS.vnas + 400 + const result = tl.useAircraftTimelineStore.getState().getInterpolatedState('VNAS1', now) + expect(result).not.toBeNull() + if (!result) return + expect(result.isExtrapolating).toBe(false) + const fraction = (result.displayTime - T0) / 1000 + expect(fraction, 'display time falls inside the segment').toBeGreaterThan(0) + expect(fraction).toBeLessThan(1) + expect(result.heightAboveGround).toBeCloseTo(10 + (4 - 10) * fraction, 6) +}) + +it('a VATSIM aircraft has no height above ground', () => { + const store = tl.useAircraftTimelineStore.getState() + store.addObservation('VAT1', observation('vatsim', T0, undefined), METADATA) + store.addObservation('VAT1', observation('vatsim', T0 + 15000, undefined), METADATA) + const result = store.getInterpolatedState('VAT1', T0 + RECEIVE_LAG_MS + SOURCE_DISPLAY_DELAYS.vatsim + 5000) + expect(result).not.toBeNull() + expect(result?.heightAboveGround).toBeNull() +}) + +it('the last vNAS height is carried while extrapolating past the last sample', () => { + feedTwoVnasSamples() + const now = T0 + 1000 + RECEIVE_LAG_MS + 5000 + const result = tl.useAircraftTimelineStore.getState().getInterpolatedState('VNAS1', now) + expect(result?.isExtrapolating).toBe(true) + expect(result?.heightAboveGround).toBe(4) +}) + +it('the height is dropped once the vNAS samples are older than the vNAS preference window', () => { + feedTwoVnasSamples() + const now = T0 + 1000 + RECEIVE_LAG_MS + VNAS_PREFERENCE_THRESHOLD_MS + const result = tl.useAircraftTimelineStore.getState().getInterpolatedState('VNAS1', now) + expect(result).not.toBeNull() + expect(result?.heightAboveGround).toBeNull() +}) diff --git a/tests/motion/heightlessVnas.test.ts b/tests/motion/heightlessVnas.test.ts new file mode 100644 index 00000000..a9804f7d --- /dev/null +++ b/tests/motion/heightlessVnas.test.ts @@ -0,0 +1,56 @@ +// @vitest-environment node +/** + * A vNAS observation without a height above ground (a replay recorded before the field existed) keeps its 50/80 ft + * ground flag for labels only: the arrival is drawn by the landing blend a VATSIM arrival gets on the same path, not + * snapped to the runway from 50 ft with its angle of attack dropped. + */ +import { beforeAll, expect, it, vi } from 'vitest' +import { type FrameRecord, runScenario, type ScenarioResult } from './harness' + +vi.mock('react', () => { + const api = { + useEffect: (fn: () => unknown) => { + fn() + }, + useState: (v: T) => [v, () => {}], + } + return { ...api, default: api } +}) + +const CHECK_AGL_M = 12 // about 40 ft, under the 50 ft at which the label flag says ground +const CHECK_AGL_TOLERANCE_M = 0.5 +const HEIGHT_DIFF_MAX_M = 0.01 +const PITCH_DIFF_MAX_DEG = 0.01 + +let flagged: ScenarioResult +let unflagged: ScenarioResult + +beforeAll(async () => { + const base = { fps: 60, useVnas: true, vnasFirst: false, durationS: 720, vnasWithoutHeight: true } + flagged = await runScenario(base) + unflagged = await runScenario({ ...base, vnasWithoutGroundFlag: true }) +}, 300_000) + +function arrival(result: ScenarioResult): FrameRecord[] { + return (result.frames.get('ARR1') ?? []).filter((r) => r.truth.segment === 'glide' || r.truth.segment === 'flare') +} + +it(`a vNAS arrival without a height is drawn by the landing blend at ${CHECK_AGL_M} m, not snapped by its label flag`, () => { + const check = arrival(flagged).reduce((best, r) => + Math.abs(r.truth.agl - CHECK_AGL_M) < Math.abs(best.truth.agl - CHECK_AGL_M) ? r : best, + ) + expect(Math.abs(check.truth.agl - CHECK_AGL_M)).toBeLessThan(CHECK_AGL_TOLERANCE_M) + // The same feed without the flag is what a VATSIM arrival on this path gets: the landing blend + const reference = arrival(unflagged).find((r) => r.frameTime === check.frameTime) + expect(reference).toBeDefined() + const ref = reference as FrameRecord + expect( + Math.abs(check.agl - ref.agl), + `drawn ${check.agl.toFixed(3)} m, landing blend ${ref.agl.toFixed(3)} m`, + ).toBeLessThanOrEqual(HEIGHT_DIFF_MAX_M) + expect( + Math.abs(check.pitch - ref.pitch), + `pitch ${check.pitch.toFixed(2)} deg, landing blend ${ref.pitch.toFixed(2)} deg`, + ).toBeLessThanOrEqual(PITCH_DIFF_MAX_DEG) + expect(check.onGroundTimeline, 'the label flag is not a drawing input without a height').toBeNull() +}) diff --git a/tests/motion/motion.test.ts b/tests/motion/motion.test.ts index 29c6eb3a..fd510651 100644 --- a/tests/motion/motion.test.ts +++ b/tests/motion/motion.test.ts @@ -13,6 +13,7 @@ import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { beforeAll, describe, expect, it, vi } from 'vitest' +import { GEAR_RETRACT_MIN_AIRBORNE_SECONDS } from '@/utils/gearAnimationController' import { angleDiff, type FrameRecord, runScenario, type ScenarioResult } from './harness' vi.mock('react', () => { @@ -38,6 +39,26 @@ const VATSIM_GLIDE_POS_ERR_MAX_M = 60 // measured 13.1 m const GA_POS_ERR_MAX_M = 5.5 // measured 1.4 m const MIN_GEAR_DOWN = 0.95 // measured 1.00 const PLAYBACK_OFF_FRACTION_MAX = 0.1 // measured 0.019 (1.9 % of moving frames) +const DEPARTURE_AGL_ERR_MAX_M = 2 // measured 0.5 m (1451 m while isOnGround stayed frozen) +// Contact is entered below 3 ft (0.91 m), the most an arrival can be drawn under its reported height before touchdown. +const ARRIVAL_BELOW_TRUTH_MAX_M = 1 // measured 0.66 m +// The sim's flare sinks at about 120 fpm through the last 3 ft, so contact starts before the wheels touch. +// The band where the 50 ft ground flag used to drop the AoA offset and leave the bare -3 deg flight-path angle. +const FLARE_CHECK_LOW_FT = 39 +const FLARE_CHECK_HIGH_FT = 51 +// The 5.2 check asks for pitch at or above 0 deg in that band: measured lowest 1.0 deg (vNAS, VATSIM seen first) +const TOUCHDOWN_EARLY_MAX_S = 2 // measured 1.3 s for both vNAS scenarios (7.1 s and 5.5 s with the landing blend) +const GEAR_LOOKBACK_S = 30 +const VATSIM_GEAR_UP_LATE_MAX_S = 6 // measured 3.8 s +// Gear-up on a go-around without touchdown, before the early-climbing VATSIM chord is fixed (TC3D-53). Measured +// -5.6 s: the same retract instant as the 150 ft rule's -4.0 s under the old 12 s ease-in-out, which took 1.6 s +// to leave 0.99; the ease-out moves the gear at once. +const GA_GEAR_UP_START_S = -5.6 +const GA_GEAR_UP_TOLERANCE_S = 0.5 +const GEAR_UP_AFTER_CONTACT_MAX_S = 3 // measured 2.1 s (7.3 s at 150 ft); gear starts up 2.9 s after liftoff +const GEAR_TRANSITION_S = 8 +// Ease-out cubic leaves 0.67 one second into an 8 s retraction; linear 0.875, the old cubic ease-in-out 0.99. +const GEAR_EASE_OUT_AT_1S_MAX = 0.75 const ROOT = resolve(__dirname, '../..') const REPORT_PATH = resolve(ROOT, '.tmp/motion/report.txt') @@ -74,6 +95,15 @@ function minValue(recs: FrameRecord[], pick: (r: FrameRecord) => number): number return recs.reduce((m, r) => Math.min(m, pick(r)), Number.POSITIVE_INFINITY) } +/** + * Seconds from a truth event to the first frame whose gear reads below fully down, looking back from + * GEAR_LOOKBACK_S before the event; negative when the gear starts up early, +Infinity when it never does. + */ +function gearUpStartS(recs: FrameRecord[], eventT: number): number { + const first = recs.find((r) => r.tLocal > eventT - GEAR_LOOKBACK_S && r.gearApp < 0.99) + return first ? first.tLocal - eventT : Number.POSITIVE_INFINITY +} + function phaseOrder(res: ScenarioResult, callsign: string): string[] { return (res.phases.get(callsign) ?? []).map((p) => p.phase) } @@ -170,11 +200,13 @@ function report(res: ScenarioResult, label: string): void { out.push( ` phase changes (${ph.length}): ${ph.map((p) => `${p.tLocal.toFixed(0)}s:${p.phase}[${p.segment}]`).join(' > ')}`, ) - const gearRetract = recs.find((r) => r.tLocal > f.events.initial_climb - 30 && r.gearApp < 0.99) - if (f.callsign === 'DEP1' && gearRetract) { - out.push( - ` gear retract starts (app AGL) at ${(gearRetract.tLocal - f.events.initial_climb).toFixed(1)} s after liftoff`, - ) + const gearEvent = f.callsign === 'GA1' ? 'go_around' : 'initial_climb' + if (f.callsign !== 'ARR1') { + out.push(` gear retract starts at ${gearUpStartS(recs, f.events[gearEvent]).toFixed(1)} s after ${gearEvent}`) + } + const leftContact = recs.findIndex((r, i) => i > 0 && recs[i - 1].inContact === true && r.inContact === false) + if (leftContact > 0) { + out.push(` leaves contact at ${(recs[leftContact].tLocal - f.events.initial_climb).toFixed(1)} s after liftoff`) } const wheelsOn = recs.find((r) => r.tLocal > f.events.rollout - 60 && r.agl < 0.2) if (f.callsign === 'ARR1' && wheelsOn) { @@ -325,13 +357,107 @@ describe('findings the review measured that are still bugs today', () => { expect(offsetS, `left the final phases ${offsetS.toFixed(1)} s after the go-around began`).toBeLessThanOrEqual(8) }) // TC3D-50 + it(`3.1 isOnGround follows the timeline after the first frame, so a vNAS-first departure is not glued to the runway (TC3D-50)`, () => { + const recs = framesFor(vnasFirst, 'DEP1') + expect(recs.length).toBeGreaterThan(1000) + const mismatched = recs.filter((r) => r.isOnGroundEntry !== r.onGroundTimeline).length + expect(mismatched, 'frames where entry.isOnGround differs from the timeline onGround').toBe(0) + const airborne = inSegments(recs, ['climbing_turn', 'climb', 'level_off', 'cruise']) + expect(airborne.length).toBeGreaterThan(100) + const worst = maxAbs(airborne, (r) => r.agl - r.truth.agl) + expect(worst, `drawn AGL error after liftoff ${worst.toFixed(1)} m`).toBeLessThanOrEqual(DEPARTURE_AGL_ERR_MAX_M) + }) + + it('5.1 an arrival is not drawn on the runway before it touches down (TC3D-50)', () => { + for (const [label, res] of [ + ['vNAS, VATSIM seen first', vnas], + ['vNAS seen first', vnasFirst], + ] as const) { + const flight = res.flights.find((f) => f.callsign === 'ARR1') + expect(flight, label).toBeDefined() + if (!flight) return + const recs = framesFor(res, 'ARR1') + const descent = inSegments(recs, ['glide', 'flare']).filter((r) => r.tLocal < flight.events.rollout) + expect(descent.length, label).toBeGreaterThan(100) + const sink = maxValue(descent, (r) => r.truth.agl - r.agl) + expect(sink, `${label}: drawn ${sink.toFixed(2)} m below truth before touchdown`).toBeLessThanOrEqual( + ARRIVAL_BELOW_TRUTH_MAX_M, + ) + const wheelsOn = recs.find((r) => r.tLocal > flight.events.rollout - 60 && r.agl < 0.2) + const earlyS = wheelsOn ? flight.events.rollout - wheelsOn.tLocal : Number.POSITIVE_INFINITY + expect(earlyS, `${label}: drawn on the runway ${earlyS.toFixed(2)} s before truth touchdown`).toBeLessThanOrEqual( + TOUCHDOWN_EARLY_MAX_S, + ) + } + }) + it('5.2 a vNAS arrival does not pitch nose-down through the flare (TC3D-50)', () => { + for (const [label, res] of [ + ['vNAS, VATSIM seen first', vnas], + ['vNAS seen first', vnasFirst], + ] as const) { + const recs = inSegments(framesFor(res, 'ARR1'), ['glide', 'flare']).filter( + (r) => r.truth.agl >= FLARE_CHECK_LOW_FT * 0.3048 && r.truth.agl <= FLARE_CHECK_HIGH_FT * 0.3048, + ) + expect(recs.length, label).toBeGreaterThan(30) + const lowest = minValue(recs, (r) => r.pitch) + expect(lowest, `${label}: pitch ${lowest.toFixed(1)} deg between ${FLARE_CHECK_LOW_FT} and ${FLARE_CHECK_HIGH_FT} ft`).toBeGreaterThanOrEqual(0) + } + }) + it('6.2 a VATSIM aircraft does not start its gear up before liftoff (TC3D-50)', () => { + const flight = vatsim.flights.find((f) => f.callsign === 'DEP1') + expect(flight).toBeDefined() + if (!flight) return + const liftoff = flight.events.initial_climb + const recs = framesFor(vatsim, 'DEP1') + expect(recs.filter((r) => r.tLocal > liftoff - GEAR_LOOKBACK_S && r.tLocal < liftoff).length).toBeGreaterThan(100) + const startS = gearUpStartS(recs, liftoff) + expect(startS, `gear started up ${startS.toFixed(1)} s after liftoff`).toBeGreaterThanOrEqual(0) + expect(startS, `gear started up ${startS.toFixed(1)} s after liftoff`).toBeLessThanOrEqual(VATSIM_GEAR_UP_LATE_MAX_S) + }) it.todo( - '3.1 isOnGround follows the timeline after the first frame, so a vNAS-first departure is not glued to the runway (TC3D-50)', + '6.2 a VATSIM aircraft does not start its gear up before a go-around: the 15 s chord climbs early, finding 1.5 (TC3D-53)', ) - it.todo('5.1 an arrival is not drawn on the runway before it touches down (TC3D-50)') - it.todo('5.2 a vNAS arrival does not pitch nose-down through the flare (TC3D-50)') - it.todo('6.2 a VATSIM aircraft does not start its gear up before liftoff or before a go-around (TC3D-50)') - it.todo('6.3 gear retraction starts a few seconds after liftoff, with an 8 s transition (TC3D-50)') + it(`a VATSIM go-around that never touched down keeps the 150 ft gear rule, gear up ${GA_GEAR_UP_START_S} s from it (TC3D-50)`, () => { + const flight = vatsim.flights.find((f) => f.callsign === 'GA1') + expect(flight).toBeDefined() + if (!flight) return + const startS = gearUpStartS(framesFor(vatsim, 'GA1'), flight.events.go_around) + expect(startS, `gear started up ${startS.toFixed(1)} s after the go-around`).toBeGreaterThanOrEqual( + GA_GEAR_UP_START_S - GA_GEAR_UP_TOLERANCE_S, + ) + expect(startS, `gear started up ${startS.toFixed(1)} s after the go-around`).toBeLessThanOrEqual( + GA_GEAR_UP_START_S + GA_GEAR_UP_TOLERANCE_S, + ) + }) + + it('6.3 gear retraction starts a few seconds after liftoff, with an 8 s transition (TC3D-50)', () => { + for (const [label, res] of [ + ['vNAS, VATSIM seen first', vnas], + ['vNAS seen first', vnasFirst], + ] as const) { + const recs = framesFor(res, 'DEP1') + const leftContact = recs.findIndex((r, i) => i > 0 && recs[i - 1].inContact === true && r.inContact === false) + expect(leftContact, `${label}: the departure leaves contact`).toBeGreaterThan(0) + const moving = recs.findIndex((r, i) => i >= leftContact && r.gearApp < 1) + expect(moving, `${label}: the gear starts up`).toBeGreaterThan(0) + // The transition starts on the frame before the gear first moves (zero elapsed time, progress still 1) + const start = recs[moving - 1] + const delayS = start.frameTime - recs[leftContact].frameTime + expect(delayS, `${label}: gear started up ${delayS.toFixed(2)} s after leaving contact`).toBeGreaterThanOrEqual( + GEAR_RETRACT_MIN_AIRBORNE_SECONDS - 0.05, + ) + expect(delayS, `${label}: gear started up ${delayS.toFixed(2)} s after leaving contact`).toBeLessThanOrEqual( + GEAR_UP_AFTER_CONTACT_MAX_S, + ) + const oneSecondIn = recs.find((r) => r.frameTime >= start.frameTime + 1) + expect(oneSecondIn?.gearApp, `${label}: an ease-out moves most at the start`).toBeLessThanOrEqual( + GEAR_EASE_OUT_AT_1S_MAX, + ) + const up = recs.find((r) => r.frameTime > start.frameTime && r.gearApp === 0) + const transitionS = (up?.frameTime ?? Number.POSITIVE_INFINITY) - start.frameTime + expect(transitionS, `${label}: gear transition ${transitionS.toFixed(2)} s`).toBeCloseTo(GEAR_TRANSITION_S, 1) + } + }) // TC3D-51 it.todo('1.3 vNAS silence from a stationary aircraft does not inflate the display delay (TC3D-51)') it.todo('1.4 a stopped aircraft does not extrapolate forward after the last vNAS sample (TC3D-51)') diff --git a/tests/remoteObservations.test.ts b/tests/remoteObservations.test.ts new file mode 100644 index 00000000..7e3aff20 --- /dev/null +++ b/tests/remoteObservations.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment node +/** + * A remote browser maps the host's WebSocket observation into the timeline observation, carrying the height above + * ground when the host sends it and leaving it absent when the key is missing (an older host, or a source without + * a height). + */ +import { beforeAll, describe, expect, it } from 'vitest' +import type { RemoteObservationData } from '@/types' + +type RemoteModule = typeof import('@/hooks/useRemoteObservations') + +const NOW = 1_700_000_000_000 +let toTimelineObservation: RemoteModule['toTimelineObservation'] + +beforeAll(async () => { + // vnasStore registers a removal listener at module init; loading it first resolves the store import cycle in + // the order the app does + await import('@/stores/vnasStore') + ;({ toTimelineObservation } = await import('@/hooks/useRemoteObservations')) +}) + +function wireObservation(extra: Partial = {}): RemoteObservationData { + return { + callsign: 'DAL123', + latitude: 37.72, + longitude: -122.22, + altitude: 10, + heading: 90, + groundspeed: 140, + groundTrack: 90, + verticalRate: 0, + onGround: false, + pitch: null, + roll: null, + source: 'vnas', + observedAt: NOW - 500, + receivedAt: NOW - 400, + typeCode: 'B738', + origin: 'KLAX', + destination: 'KOAK', + flightRules: null, + ...extra, + } +} + +describe('toTimelineObservation', () => { + it('carries heightAboveGround into the observation', () => { + const { callsign, observation } = toTimelineObservation(wireObservation({ heightAboveGround: 1.25 }), NOW) + expect(callsign).toBe('DAL123') + expect(observation.heightAboveGround).toBe(1.25) + }) + + it('leaves heightAboveGround absent when the message has no such key', () => { + const { observation } = toTimelineObservation(wireObservation(), NOW) + expect(observation.heightAboveGround).toBeUndefined() + expect(observation.receivedAt).toBe(NOW) + }) +}) From b2c1cc148208f034da5998f03019fe9fa2dee096 Mon Sep 17 00:00:00 2001 From: Leftos Aslanoglou Date: Wed, 21 Oct 2026 16:39:04 -0700 Subject: [PATCH 2/3] feat: hold a stopped vNAS aircraft through vNAS silence A vNAS aircraft on the ground whose last measured steps are under 2 kt is drawn at its last sample while vNAS is quiet, with no extrapolation, and the silent gap is left out of the display-delay statistics, so a line-up wait no longer raises the delay and the takeoff roll after it plays at 1x. The stop test measures speed between vNAS samples (the last step within 3 s, plus the step before or a 3-6 s window), never the windowed groundspeed, and is cached per sample. STOPPED_SPEED_KTS and the contact heights are shared through @/constants. Refs: TC3D-51 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/architecture.md | 2 + docs/plans/aircraft-motion-review.md | 23 +- src/renderer/constants/aircraft-timeline.ts | 22 ++ src/renderer/constants/index.ts | 59 +++ .../hooks/useAircraftInterpolation.ts | 11 +- src/renderer/stores/aircraftTimelineStore.ts | 131 +++++- .../utils/aircraft/flightPhaseDetector.ts | 2 +- tests/motion/harness.ts | 3 + tests/motion/motion.test.ts | 84 +++- tests/motion/vnasSilence.test.ts | 373 ++++++++++++++++++ 11 files changed, 671 insertions(+), 40 deletions(-) create mode 100644 tests/motion/vnasSilence.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fef0d67..f6cef5a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ All notable changes to TowerCab 3D will be documented in this file. - The flight phase badge now shows "Go Around" after a go-around, and leaves "Short Final" as soon as the aircraft climbs away. - Aircraft passing over an airport at altitude are no longer labelled "Final" or "Short Final". - Datablocks on iPads and other high-resolution screens now sit on their aircraft at full size, instead of half size and in the wrong place. +- vNAS aircraft holding short or lined up no longer creep forward while vNAS is quiet, and their takeoff roll no longer plays in fast motion. ### Removed - The experimental VR mode and its VR button in the controls bar. diff --git a/docs/architecture.md b/docs/architecture.md index 955a79e7..58ed8f67 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -81,6 +81,8 @@ Note: AircraftPanel uses useAircraftFiltering for 1Hz UI updates **Ground contact and gear.** vNAS observations carry the simulator's height above ground (`heightAboveGround`, metres, 1 Hz; built in `vnasStore.ts`, carried to remote browsers by the observation messages). `aircraftTimelineStore.ts` interpolates it to the displayed instant. `useAircraftInterpolation.ts` turns it into `inContact` per frame: entered below 3 ft, left above 5 ft. In contact the aircraft sits on the sampled terrain; airborne it is drawn at the reported height. The vNAS 50/80 ft `onGround` flag stays on the observation for labels only: a vNAS observation without a height (old replays, the brief mixed VATSIM/vNAS window) gives no drawing ground flag (`drawingGroundFlag`), so the landing and departure blends apply. A source without a height takes `inContact` from the drawn result of those blends. +**Stopped vNAS aircraft.** vNAS sends nothing for an aircraft that stands still, so `aircraftTimelineStore.ts` treats the silence after a stopped vNAS sample as parking (`isStoppedVnasSample`). A sample is stopped when the aircraft is on the ground (height above ground at most the contact exit height, 5 ft; a sample without a height is never held) and its last step is under `STOPPED_SPEED_KTS` (2 kt), and either the step before it or its speed over `VNAS_STOP_WINDOW_MS` (3 s) is too. Speeds are measured between vNAS samples, never taken from the reported groundspeed. The newest observation, if stopped, is held through the silence: no extrapolation, groundspeed 0, no ground track, vertical rate 0. A receive interval over `VNAS_PARKED_GAP_MS` (3 s) that starts at a stopped vNAS sample and ends at a vNAS sample is left out of the display-delay statistics, so a line-up wait neither raises the delay nor plays the takeoff roll fast. The restart needs no special case: reconciliation moves the aircraft from the held spot to the first moving sample. After 30 s of vNAS silence the timeline falls back to the mixed VATSIM list. Moving vNAS aircraft that go quiet and all VATSIM and RealTraffic aircraft still extrapolate. `tests/motion/` covers it (`vnasSilence`). + `useAircraftModels.ts` feeds `inContact` and the data-source time to `updateGearAnimation` (`utils/gearAnimationController.ts`; inputs typed in `types/gear.ts`). In contact the gear is down. After contact ends, the gear retracts on positive climb once the aircraft has been out of contact 2 s of data time, over 8 s with an ease-out; that retract fires once per contact, after which the gear holds and extends only when descending below 2000 ft. The 150 ft climb rule takes over once the aircraft passes 150 ft out of contact or after a backward seek in replay. An aircraft not in contact since it was drawn (an airborne spawn, a go-around without touchdown) uses the 150 ft rule. `tests/motion/` covers it (`contact`, `gearRule`, `heightAboveGround`, `heightlessVnas`). ### Weather Data Flow diff --git a/docs/plans/aircraft-motion-review.md b/docs/plans/aircraft-motion-review.md index 32ee19f2..38083a99 100644 --- a/docs/plans/aircraft-motion-review.md +++ b/docs/plans/aircraft-motion-review.md @@ -18,14 +18,14 @@ How positions are interpolated and how that becomes visible motion: taxi, flight 1. One-site fixes that change no design (each has a failing test to write first, TDD): 4.1 steering clips, 1.1 delay persistence, 1.2 per-source observation spacing, 7.1 go-around flag and 7.2 its unit error, 7.3 altitude gate on finals, 6.1 gear datum. 2. Ground-contact redesign (D1): fix 3.1 together with 5.1 and 5.2, because fixing 3.1 alone makes vNAS arrivals snap 50 ft to the runway. Done by TC3D-50 (3.1, 5.1, 5.2, 6.3 and the departure half of 6.2). -3. vNAS silence for stationary aircraft (D2): 1.3, 1.4. +3. vNAS silence for stationary aircraft (D2): 1.3, 1.4. Done by TC3D-51. 4. Orientation model: 3.2 coordinated-turn bank, 5.3 no AoA on the runway, 5.4 rotation, 5.5 flare from the flight path. 5. VATSIM path shape (D4): 2.1, 2.2, 1.5. 6. Animated parts beyond gear (D3), coordinated with E1 in [rendering-opportunities-design.md](./rendering-opportunities-design.md). ## Method -The measurements came from a scratch harness, since promoted to the regression suite `tests/motion/` (`sim.ts`, `harness.ts`, `motion.test.ts`, `spawn.test.ts`, `phase.test.ts`, `contact.test.ts`, `gearRule.test.ts`, `heightAboveGround.test.ts`, `heightlessVnas.test.ts`), where each finding a fix item owns is an `it.todo` that its fix turns into a red-first `it`. It drives the real code in node: `useAircraftInterpolation` (React mocked so its effects run, `requestAnimationFrame` stubbed, `Date` faked), `aircraftTimelineStore`, `smartSort`/`detectFlightPhase` at 1 Hz like `AircraftPanel.tsx:155-160`, and `gearAnimationController` called the way `useAircraftModels.ts:329-335,503-528` calls it. Terrain is flat and sampled with the same gate as `useGroundAircraftTerrain.ts:121-132`. +The measurements came from a scratch harness, since promoted to the regression suite `tests/motion/` (`sim.ts`, `harness.ts`, `motion.test.ts`, `spawn.test.ts`, `phase.test.ts`, `contact.test.ts`, `gearRule.test.ts`, `heightAboveGround.test.ts`, `heightlessVnas.test.ts`, `vnasSilence.test.ts`), where each finding a fix item owns is an `it.todo` that its fix turns into a red-first `it`. It drives the real code in node: `useAircraftInterpolation` (React mocked so its effects run, `requestAnimationFrame` stubbed, `Date` faked), `aircraftTimelineStore`, `smartSort`/`detectFlightPhase` at 1 Hz like `AircraftPanel.tsx:155-160`, and `gearAnimationController` called the way `useAircraftModels.ts:329-335,503-528` calls it. Terrain is flat and sampled with the same gate as `useGroundAircraftTerrain.ts:121-132`. - `sim.ts` integrates three truth flights at 10 Hz: DEP1 (straight and curved pushback, two 90 deg taxi turns at 12 and 8 kt, line-up and 25 s hold, roll at 3.5 kt/s, liftoff at 150 kt, 2500 fpm climb, 3 deg/s climbing turn at 175-210 kt, level-off, 25 deg-bank cruise turn at 250 kt); ARR1 (2000 ft intercept, 3 deg glide at 140 kt with a 6 deg crosswind crab, exponential flare from 30 ft, de-crab, rollout at -4 kt/s, 90 deg exit, taxi-in); GA1 (same approach, go-around at 200 ft, 2000 fpm, 3 deg/s turn to downwind). - `harness.ts` turns truth into a VATSIM feed (every 15 s, received 0.4-1.4 s later, no ground flag or track) and a vNAS feed (1 Hz, received 80 ms later, true track, the `vnasStore.ts:25-48` 50/80 ft label flag, the simulator's height above ground on each observation (the options `vnasWithoutHeight` and `vnasWithoutGroundFlag` drop the height or the flag, for replays recorded without them), and no samples while the aircraft stands still, as vNAS does). @@ -39,15 +39,15 @@ The measurements came from a scratch harness, since promoted to the regression s ### How it works today -- Every source feeds `AircraftObservation`s into one timeline per callsign (`aircraftTimelineStore.ts:1254-1474`). VATSIM arrives in batches every 15 s with no track or ground flag (`vatsimStore.ts:166-182`); vNAS arrives at 1 Hz with true track, the simulator's height above ground (`heightAboveGround`, which decides ground contact) and a 50/80 ft hysteresis ground flag kept for labels (`vnasStore.ts:782-800`). -- When at least two vNAS samples are under 30 s old, interpolation uses vNAS only (`aircraftTimelineStore.ts:421-429`, `VNAS_PREFERENCE_THRESHOLD_MS`). -- Display time is anchored on the oldest observation: `displayTime = oldest.observedAt + (now - oldest.receivedAt) - delay` (`:739-740`). The delay is per aircraft, kept in the store's `dynamicDelays` map: max of the last 5 receive intervals + 150 ms, clamped 150 ms-20 s, starting at the source's delay (VATSIM about 17 s, vNAS 1.5 s) and moved toward its target at 500 ms/s when it rises and 100 ms/s when it falls, a gap between frames counting at most 1 s (`DYNAMIC_DELAY_MAX_ELAPSED_MS`), plus a 200 ms bump that builds over 0.4 s while extrapolating. A VATSIM aircraft first seen in the air holds at its first position until display time reaches it; one first seen moving on the ground appears at its second report; a held aircraft gets no bump. -- Between two observations, lat/lon are lerped linearly from a reconciliation start (the last rendered position) to the `after` sample (`:909-970`); altitude uses a linear/smoothstep blend weighted by how much the vertical rate changes between segments (`:978-1018`); heading is lerped separately between the two samples' headings (`:1168-1173`). Past the newest sample it extrapolates along `groundTrack ?? heading` for up to 30 s (`:311-342`, `:1076-1140`). +- Every source feeds `AircraftObservation`s into one timeline per callsign (`aircraftTimelineStore.ts`). VATSIM arrives in batches every 15 s with no track or ground flag (`vatsimStore.ts:166-182`); vNAS arrives at 1 Hz with true track, the simulator's height above ground (`heightAboveGround`, which decides ground contact) and a 50/80 ft hysteresis ground flag kept for labels (`vnasStore.ts:782-800`). +- When at least two vNAS samples are under 30 s old, interpolation uses vNAS only (`VNAS_PREFERENCE_THRESHOLD_MS`). +- Display time is anchored on the oldest observation: `displayTime = oldest.observedAt + (now - oldest.receivedAt) - delay`. The delay is per aircraft, kept in the store's `dynamicDelays` map: max of the last 5 receive intervals + 150 ms (a vNAS interval over 3 s that starts at a stopped sample is left out, see 1.3), clamped 150 ms-20 s, starting at the source's delay (VATSIM about 17 s, vNAS 1.5 s) and moved toward its target at 500 ms/s when it rises and 100 ms/s when it falls, a gap between frames counting at most 1 s (`DYNAMIC_DELAY_MAX_ELAPSED_MS`), plus a 200 ms bump that builds over 0.4 s while extrapolating. A VATSIM aircraft first seen in the air holds at its first position until display time reaches it; one first seen moving on the ground appears at its second report; a held aircraft gets no bump. +- Between two observations, lat/lon are lerped linearly from a reconciliation start (the last rendered position) to the `after` sample ; altitude uses a linear/smoothstep blend weighted by how much the vertical rate changes between segments; heading is lerped separately between the two samples' headings. Past the newest sample it extrapolates along `groundTrack ?? heading` for up to 30 s (`extrapolatePosition`), except that the silence after a stopped vNAS sample is held (1.3, 1.4). - The hook (`useAircraftInterpolation.ts:123-374`) rate-limits heading (6 deg/s air, 20 deg/s ground), derives vertical rate, turn rate and acceleration from frame-to-frame differences with a per-frame EMA (`RATE_SMOOTHING = 0.15`), then pitch and roll (area 3) and terrain blending (area 5). ### Findings -**1.1 The delay rate limit is bypassed after any steady period.** Fixed by TC3D-49. `getInterpolatedStates` writes the new `dynamicDelay` back only when `currentDelayMs` or the bump changed (`aircraftTimelineStore.ts:1698-1720`), so `lastUpdateTime` stays at the last change. When the target moves later, `applyDelayTransition` multiplies the whole stale interval by 500 ms/s (`:521`, `:548-553`) and the delay jumps in one frame. +**1.1 The delay rate limit is bypassed after any steady period.** Fixed by TC3D-49: `getInterpolatedStates` writes the new `dynamicDelay` back every frame, so `applyDelayTransition` sees the real frame delta. Before the fix it wrote back only when `currentDelayMs` or the bump changed, `lastUpdateTime` stayed at the last change, and the whole stale interval times 500 ms/s moved the delay in one frame. - What a controller sees: a VATSIM aircraft appears, jumps back, then moves in slow motion. Measured (ARR1 at 140-160 kt, `spawn.txt`): first frame 575 m ahead of truth, next frame a 419 m backward jump, then 0.5 m/frame (30 m/s) against a true 72 m/s for 12 s while the delay ramps 9.4 s to 14.9 s. The same jump hits a vNAS aircraft that stopped and moved again (1.3). - Fix: persist `lastUpdateTime` every frame (or make `applyDelayTransition` use the frame delta). One-site; test: two VATSIM observations 15 s apart, assert no frame-to-frame position step larger than speed x frame time. @@ -55,11 +55,12 @@ The measurements came from a scratch harness, since promoted to the regression s - Measured (`debug.txt`, GA1 on a 140 kt glide): the vNAS sample at 333.37 s is dropped, display time steps back 0.28 s and the aircraft 20 m (at 144 fps the backward snap is 22.6 m in one frame). In the harness every vNAS aircraft does it at the same instant; in the field the chance is about the 100 ms window over the 1 s vNAS period whenever the VATSIM sample is the newer one (unmeasured). - Fix: apply the spacing check per source, and do not insert VATSIM samples into a timeline whose vNAS data is fresh (they are filtered out anyway). One-site. -**1.3 vNAS silence while stationary inflates the delay at every stop-and-go.** vNAS sends nothing for a parked aircraft, so the first interval after a hold equals the hold. The target delay becomes min(20 s, hold + 150 ms) and, through 1.1, is applied at once. +**1.3 vNAS silence while stationary inflates the delay at every stop-and-go.** Fixed by TC3D-51 (D2). vNAS sends nothing for a parked aircraft, so the first interval after a hold equals the hold. The target delay becomes min(20 s, hold + 150 ms) and, through 1.1, is applied at once. - Measured (DEP1, vNAS, 25 s line-up hold): delay 1.4 s, then 11.2 s at the first frame of the takeoff roll, falling at the 500 ms/s cap back to 1.1 s over about 20 s of wall time; meanwhile the roll is shown ~10 s late at 1.5x speed, so its acceleration looks 2.25x real. Same at the end of pushback (6.9 s). Moving frames with playback rate off by more than 10 %: 11.6 % for DEP1 under vNAS against 1.9 % under VATSIM only. -- Fix: see D2. Design. +- Fix: a receive interval over 3 s (`VNAS_PARKED_GAP_MS`) that starts at a stopped vNAS sample and ends at a vNAS sample is left out of the delay statistics, so the delay stays put and the takeoff roll plays at 1x. Stops past the 30 s VATSIM fallback are TC3D-258. -**1.4 vNAS extrapolation creeps a stopped aircraft forward.** After the last vNAS sample (gs 0.4-2 kt as it stops) the timeline extrapolates at that speed for up to 30 s (`:1076-1081`, `MAX_EXTRAPOLATION_TIME`). Measured: up to 8.2 m drift on the line-up hold; the bracket `[last sample before the stop, first sample of the roll]` then interpolates position and groundspeed across the 21 s gap, which also feeds 7.4. Fix with D2. +**1.4 vNAS extrapolation creeps a stopped aircraft forward.** Fixed by TC3D-51 (D2). After the last vNAS sample (gs 0.4-2 kt as it stops) the timeline extrapolated at that speed for up to 30 s (`MAX_EXTRAPOLATION_TIME`). Measured: up to 8.2 m drift on the line-up hold; the bracket `[last sample before the stop, first sample of the roll]` then interpolates position and groundspeed across the 21 s gap, which also feeds 7.4. +- Fix: a vNAS aircraft on the ground (height above ground at most 5 ft; a sample with no height is never held) whose last step is under `STOPPED_SPEED_KTS` (2 kt), and either the step before it or its speed over the 3 s `VNAS_STOP_WINDOW_MS` is too, is held at that sample through vNAS silence: groundspeed 0, no track, vertical rate 0. Speeds are measured between vNAS samples, never taken from the reported groundspeed. The restart reconciles from the held spot to the first moving sample with no jump. Moving aircraft and VATSIM/RealTraffic aircraft still extrapolate. **1.5 VATSIM climbs and descents begin up to one sample early.** Altitude is lerped across the whole 15 s segment, so a change that happens late in a segment is spread over all of it. Measured (GA1, VATSIM): the aircraft starts climbing about 5 s before the real go-around and is 116 ft high at the go-around point; gear starts retracting 4 s before it (6.2 shows the same mechanism for departures). Inherent to linear segments; see D4. @@ -210,7 +211,7 @@ No control surface, flap, spoiler or reverser is animated. The FSLTL converter b 2. *Exclude any interval over 3 s from the delay statistics regardless of speed.* Worst case: genuine vNAS outages in flight are bridged by extrapolation instead of a longer delay, so position errors grow after 1.5 s. 3. *Leave it.* Worst case: the measured 10 s late, 1.5x fast takeoff roll after every hold. -**Settled (owner ruling):** option 1, silence after a near-0 kt sample is treated as parked. +**Settled (owner ruling):** option 1, silence after a near-0 kt sample is treated as parked. Built by TC3D-51 as the hold and delay exclusion under 1.3 and 1.4. **D3. Animate more than gear?** 1. *After E1 lands, a separate item for spoilers on touchdown, reversers on rollout and wheel rotation, driven by the D1 contact signal* (recommended). Worst case: a mis-named or mis-keyed clip on some FSLTL models shows spoilers up on the ground or reversers deployed while taxiing; mitigate with a per-name allowlist checked against the converted model set. diff --git a/src/renderer/constants/aircraft-timeline.ts b/src/renderer/constants/aircraft-timeline.ts index 5a422caa..0f866a41 100644 --- a/src/renderer/constants/aircraft-timeline.ts +++ b/src/renderer/constants/aircraft-timeline.ts @@ -75,6 +75,28 @@ export const AIRCRAFT_TIMEOUT = 30000 // 30 seconds */ export const VNAS_PREFERENCE_THRESHOLD_MS = 30000 // 30 seconds +/** Ground contact is entered below this height above ground (3 ft) */ +export const CONTACT_ENTER_HEIGHT_METERS = 3 * 0.3048 + +/** Ground contact is left above this height above ground (5 ft); a stopped vNAS sample at or below it is parked */ +export const CONTACT_EXIT_HEIGHT_METERS = 5 * 0.3048 + +/** + * Span of vNAS samples over which a sample's own speed is measured (ms). + * + * The speed is the distance from the sample at or just before this long earlier to the sample, over the time + * between them. Under STOPPED_SPEED_KTS the sample marks a stop: the silence after it is parking. A span rather + * than the last two samples, because vNAS can repeat a position for a moving aircraft. + */ +export const VNAS_STOP_WINDOW_MS = 3000 // 3 seconds + +/** + * Receive interval after a stopped vNAS sample beyond which it is parking silence, not a late sample (ms). + * + * Such an interval is left out of the display-delay statistics, so a hold does not raise the delay. + */ +export const VNAS_PARKED_GAP_MS = 3000 // 3 seconds + /** * Minimum time between observations to consider them distinct (ms). * diff --git a/src/renderer/constants/index.ts b/src/renderer/constants/index.ts index 5a923362..e2c77bae 100644 --- a/src/renderer/constants/index.ts +++ b/src/renderer/constants/index.ts @@ -19,6 +19,13 @@ export { // Aircraft model pool AIRCRAFT_POOL_SIZE, + // Aircraft interpolation + AOA_HIGH_SPEED_THRESHOLD_KNOTS, + AOA_LOW_SPEED_THRESHOLD_KNOTS, + AOA_OFFSET_HIGH_SPEED_DEGREES, + AOA_OFFSET_LOW_SPEED_DEGREES, + DEPARTURE_BLEND_END_AGL, + DEPARTURE_BLEND_START_AGL, // Facade shader FACADE_BAY_WIDTH_M, FACADE_FADE_END, @@ -27,19 +34,36 @@ export { FACADE_LIT_SHARE, FACADE_WINDOW_HI, FACADE_WINDOW_LO, + FALLBACK_FLARE_PITCH_DEGREES, // Colors FALLBACK_MODEL_COLOR, + FLARE_START_ALTITUDE_METERS, FSLTL_MODEL_HEIGHT_OFFSET, // Aircraft positioning GROUND_AIRCRAFT_TERRAIN_OFFSET, + GROUNDSPEED_THRESHOLD_KNOTS, + HEIGHT_TRANSITION_LERP_FACTOR, // Rendering performance LABEL_CENTER_SCREEN_THRESHOLD_PX, LABEL_PROJECTION_MIN_DISTANCE_M, + LANDING_BLEND_DESCENT_RATE, + LANDING_BLEND_END_AGL, + LANDING_BLEND_START_AGL, + MAX_HEADING_RATE_DEG_PER_SEC_AIR, + MAX_HEADING_RATE_DEG_PER_SEC_GROUND, + MAX_PITCH_DEGREES, + MAX_PITCH_RATE_DEG_PER_SEC, + MAX_ROLL_DEGREES, + MAX_ROLL_RATE_DEG_PER_SEC, MODEL_POOL_SIZE, + NOSEWHEEL_LOWERING_LERP_FACTOR, + PITCH_RATE_MULTIPLIER, + ROLL_RATE_MULTIPLIER, SHADOW_COLOR, SHADOW_DISC_RADIUS, // Shadow configuration SHADOW_MAX_DISTANCE_KM, + TERRAIN_SMOOTHING_LERP_FACTOR, } from './rendering' // ============================================================================ @@ -342,6 +366,41 @@ export { SUN_POSITION_UPDATE_INTERVAL, } from './lighting' +// ============================================================================ +// FLIGHT PHASE CONSTANTS +// ============================================================================ + +export { FEET_TO_METERS, STOPPED_SPEED_KTS } from './flightPhase' + +// ============================================================================ +// AIRCRAFT TIMELINE CONSTANTS +// ============================================================================ + +export { + AIRCRAFT_TIMEOUT, + CONTACT_ENTER_HEIGHT_METERS, + CONTACT_EXIT_HEIGHT_METERS, + DYNAMIC_DELAY_EXTRAPOLATION_BUMP_MS, + DYNAMIC_DELAY_EXTRAPOLATION_DECAY_RATE, + DYNAMIC_DELAY_INTERVAL_WINDOW, + DYNAMIC_DELAY_JITTER_BUFFER_MS, + DYNAMIC_DELAY_MAX_DECREASE_RATE, + DYNAMIC_DELAY_MAX_ELAPSED_MS, + DYNAMIC_DELAY_MAX_INCREASE_RATE, + DYNAMIC_DELAY_MAX_MS, + DYNAMIC_DELAY_MIN_MS, + DYNAMIC_DELAY_MIN_OBSERVATIONS, + MAX_EXTRAPOLATION_TIME, + MAX_OBSERVATION_AGE_MS, + MAX_OBSERVATIONS_PER_AIRCRAFT, + MIN_OBSERVATION_INTERVAL, + PRUNE_INTERVAL, + SOURCE_DISPLAY_DELAYS, + VNAS_PARKED_GAP_MS, + VNAS_PREFERENCE_THRESHOLD_MS, + VNAS_STOP_WINDOW_MS, +} from './aircraft-timeline' + // ============================================================================ // VNAS CONSTANTS // ============================================================================ diff --git a/src/renderer/hooks/useAircraftInterpolation.ts b/src/renderer/hooks/useAircraftInterpolation.ts index 0a21ca2c..18cc3f00 100644 --- a/src/renderer/hooks/useAircraftInterpolation.ts +++ b/src/renderer/hooks/useAircraftInterpolation.ts @@ -1,10 +1,11 @@ import { useEffect, useState } from 'react' -import { STOPPED_SPEED_KTS } from '../constants/flightPhase' import { AOA_HIGH_SPEED_THRESHOLD_KNOTS, AOA_LOW_SPEED_THRESHOLD_KNOTS, AOA_OFFSET_HIGH_SPEED_DEGREES, AOA_OFFSET_LOW_SPEED_DEGREES, + CONTACT_ENTER_HEIGHT_METERS, + CONTACT_EXIT_HEIGHT_METERS, DEPARTURE_BLEND_END_AGL, DEPARTURE_BLEND_START_AGL, FALLBACK_FLARE_PITCH_DEGREES, @@ -23,8 +24,9 @@ import { NOSEWHEEL_LOWERING_LERP_FACTOR, PITCH_RATE_MULTIPLIER, ROLL_RATE_MULTIPLIER, + STOPPED_SPEED_KTS, TERRAIN_SMOOTHING_LERP_FACTOR, -} from '../constants/rendering' +} from '@/constants' import { geoidService } from '../services/GeoidService' import { useAircraftTimelineStore } from '../stores/aircraftTimelineStore' import { useSettingsStore } from '../stores/settingsStore' @@ -102,11 +104,6 @@ const sharedTimelineRateStateRef = { current: new Map /** Ground contact per aircraft with a height above ground, kept for the hysteresis */ const sharedContactStateRef = { current: new Map() } -/** Ground contact is entered below this height above ground (3 ft) */ -const CONTACT_ENTER_HEIGHT_METERS = 3 * 0.3048 -/** Ground contact is left above this height above ground (5 ft) */ -const CONTACT_EXIT_HEIGHT_METERS = 5 * 0.3048 - /** * Ground contact for this frame from the height above ground at the displayed instant, with hysteresis. * An aircraft with no earlier contact state enters contact only below the entry height. diff --git a/src/renderer/stores/aircraftTimelineStore.ts b/src/renderer/stores/aircraftTimelineStore.ts index 6a8c1536..8f29b76e 100644 --- a/src/renderer/stores/aircraftTimelineStore.ts +++ b/src/renderer/stores/aircraftTimelineStore.ts @@ -27,6 +27,7 @@ import { create } from 'zustand' import { AIRCRAFT_TIMEOUT, + CONTACT_EXIT_HEIGHT_METERS, DYNAMIC_DELAY_EXTRAPOLATION_BUMP_MS, DYNAMIC_DELAY_EXTRAPOLATION_DECAY_RATE, DYNAMIC_DELAY_INTERVAL_WINDOW, @@ -37,15 +38,21 @@ import { DYNAMIC_DELAY_MAX_MS, DYNAMIC_DELAY_MIN_MS, DYNAMIC_DELAY_MIN_OBSERVATIONS, + FEET_TO_METERS, + GROUNDSPEED_THRESHOLD_KNOTS, MAX_EXTRAPOLATION_TIME, MAX_OBSERVATION_AGE_MS, MAX_OBSERVATIONS_PER_AIRCRAFT, MIN_OBSERVATION_INTERVAL, PRUNE_INTERVAL, SOURCE_DISPLAY_DELAYS, + STOPPED_SPEED_KTS, + VNAS_PARKED_GAP_MS, VNAS_PREFERENCE_THRESHOLD_MS, -} from '../constants/aircraft-timeline' -import { GROUNDSPEED_THRESHOLD_KNOTS } from '../constants/rendering' + VNAS_STOP_WINDOW_MS, +} from '@/constants' +import { haversineDistanceFt } from '@/utils/aircraft/geoMath' +import { knotsToMps, mpsToKnots } from '@/utils/coordinates' import type { AircraftMetadata, AircraftObservation, @@ -321,7 +328,7 @@ function extrapolatePosition( const seconds = extrapolationTimeMs / 1000 const minutes = seconds / 60 const track = obs.groundTrack ?? obs.heading - const speedMps = obs.groundspeed * 0.514444 // knots to m/s + const speedMps = knotsToMps(obs.groundspeed) const distance = speedMps * seconds // Simple flat-earth approximation (accurate for short extrapolations) @@ -405,8 +412,11 @@ function calculateObservationIntervals(observations: AircraftObservation[], maxI const startIdx = Math.max(0, observations.length - maxIntervals - 1) for (let i = startIdx; i < observations.length - 1; i++) { - const interval = observations[i + 1].receivedAt - observations[i].receivedAt - if (interval > 0) { + const next = observations[i + 1] + const interval = next.receivedAt - observations[i].receivedAt + const isParkedSilence = + interval > VNAS_PARKED_GAP_MS && next.source === 'vnas' && isStoppedVnasSample(observations, i) + if (interval > 0 && !isParkedSilence) { intervals.push(interval) } } @@ -414,6 +424,80 @@ function calculateObservationIntervals(observations: AircraftObservation[], maxI return intervals } +/** Whether each sample marks a stop, decided once per sample: only the samples before it decide it. */ +const stoppedSampleCache = new WeakMap() + +/** + * Whether the sample at `index` is a vNAS sample of an aircraft standing on the ground, so the silence after it is + * parking. On the ground takes a height above ground at or below the contact exit height: vNAS's ground flag is the + * 50/80 ft label flag, so a sample without a height is never held. Exported for tests. + */ +export function isStoppedVnasSample(observations: AircraftObservation[], index: number): boolean { + const sample = observations[index] + const cached = stoppedSampleCache.get(sample) + if (cached !== undefined) return cached + const onGround = sample.heightAboveGround !== undefined && sample.heightAboveGround <= CONTACT_EXIT_HEIGHT_METERS + const stopped = sample.source === 'vnas' && onGround && hasStopped(observations, sample) + stoppedSampleCache.set(sample, stopped) + return stopped +} + +/** + * Whether a vNAS sample shows the aircraft stopped: its last step is under STOPPED_SPEED_KTS, and so is the step + * before it or its speed over the stop window. + * + * Two slow steps catch a stop that vNAS goes quiet right after; the window catches a creep whose single steps vNAS + * repeats. The last step keeps a roll whose window still lags from reading as stopped. Samples are picked by time, not + * by their place in the timeline, so a packet received out of order does not decide it. + * + * Every speed is measured, never taken from the reported groundspeed: a sample with no vNAS sample in the + * VNAS_STOP_WINDOW_MS before it is not stopped. The window sample is older than the previous one, so the two slow + * measurements always cover two different spans. + */ +function hasStopped(observations: AircraftObservation[], sample: AircraftObservation): boolean { + const previous = newestVnasSampleIn(observations, sample.observedAt - VNAS_STOP_WINDOW_MS, sample.observedAt - 1) + if (!previous || stepSpeedKts(previous, sample) >= STOPPED_SPEED_KTS) return false + const beforePrevious = newestVnasSampleIn( + observations, + previous.observedAt - VNAS_STOP_WINDOW_MS, + previous.observedAt - 1, + ) + if (beforePrevious && stepSpeedKts(beforePrevious, previous) < STOPPED_SPEED_KTS) return true + const windowStart = newestVnasSampleIn( + observations, + sample.observedAt - 2 * VNAS_STOP_WINDOW_MS, + Math.min(sample.observedAt - VNAS_STOP_WINDOW_MS, previous.observedAt - 1), + ) + return windowStart !== null && stepSpeedKts(windowStart, sample) < STOPPED_SPEED_KTS +} + +/** + * Forget the stop decisions of the stored samples observed after a vNAS sample that arrives late, so each is decided + * again with it. + */ +function forgetStopDecisionsAfter(observations: AircraftObservation[], late: AircraftObservation): void { + if (late.source !== 'vnas') return + for (const obs of observations) { + if (obs.observedAt > late.observedAt) stoppedSampleCache.delete(obs) + } +} + +/** The newest vNAS sample observed between `from` and `to` (inclusive, ms), wherever it sits in the timeline. */ +function newestVnasSampleIn(observations: AircraftObservation[], from: number, to: number): AircraftObservation | null { + let newest: AircraftObservation | null = null + for (const obs of observations) { + const inRange = obs.source === 'vnas' && obs.observedAt >= from && obs.observedAt <= to + if (inRange && (!newest || obs.observedAt > newest.observedAt)) newest = obs + } + return newest +} + +/** Speed in knots from `from` to `to` over the time between them. */ +function stepSpeedKts(from: AircraftObservation, to: AircraftObservation): number { + const meters = haversineDistanceFt(from.latitude, from.longitude, to.latitude, to.longitude) * FEET_TO_METERS + return mpsToKnots(meters / ((to.observedAt - from.observedAt) / 1000)) +} + /** * The newest stored observation from the given source, or null when there is none. * The minimum-spacing check compares against this so a sample from one source never @@ -1107,13 +1191,8 @@ function interpolateTimeline( // This is essential for VATSIM data which doesn't include ground track. // Without this, pushback detection fails because track defaults to heading. if (groundTrack === null && interval > 0) { - // Calculate distance moved in meters (approximate, using equirectangular projection) - const latDiff = after.latitude - before.latitude - const lonDiff = after.longitude - before.longitude - const avgLat = (before.latitude + after.latitude) / 2 - const latMeters = latDiff * 111320 // 1 degree lat ≈ 111.32 km - const lonMeters = lonDiff * 111320 * Math.cos((avgLat * Math.PI) / 180) - const distanceMeters = Math.sqrt(latMeters * latMeters + lonMeters * lonMeters) + const distanceMeters = + haversineDistanceFt(before.latitude, before.longitude, after.latitude, after.longitude) * FEET_TO_METERS // Require minimum movement to calculate reliable bearing: // - At least 3 meters total (exceeds typical GPS noise of 2-5m) @@ -1154,6 +1233,23 @@ function interpolateTimeline( } else { verticalRate = null } + } else if ( + before && + before === observations[observations.length - 1] && + isStoppedVnasSample(observations, observations.length - 1) + ) { + // PARKED: vNAS goes silent for an aircraft that stands still, so the silence after a stopped sample (only the + // newest observation) is held at that sample, not extrapolated. Report what is drawn: no speed, + // no track. Holding is not extrapolation, so it does not bump the display delay. + latitude = before.latitude + longitude = before.longitude + altitude = before.altitude + groundspeed = 0 + groundTrack = null + groundFlagSource = before + pitch = before.pitch + roll = before.roll + verticalRate = 0 } else if (before) { // EXTRAPOLATION FORWARD: displayTime is after all observations const extrapolationTime = displayTime - before.observedAt @@ -1173,13 +1269,8 @@ function interpolateTimeline( const obsInterval = before.observedAt - prev.observedAt if (obsInterval > 0) { - // Calculate distance moved in meters (approximate, using equirectangular projection) - const latDiff = before.latitude - prev.latitude - const lonDiff = before.longitude - prev.longitude - const avgLat = (prev.latitude + before.latitude) / 2 - const latMeters = latDiff * 111320 - const lonMeters = lonDiff * 111320 * Math.cos((avgLat * Math.PI) / 180) - const distanceMeters = Math.sqrt(latMeters * latMeters + lonMeters * lonMeters) + const distanceMeters = + haversineDistanceFt(prev.latitude, prev.longitude, before.latitude, before.longitude) * FEET_TO_METERS // Same threshold logic as interpolation case const intervalSeconds = obsInterval / 1000 @@ -1375,6 +1466,7 @@ export const useAircraftTimelineStore = create((set, get) } // Add to existing timeline + forgetStopDecisionsAfter(existing.observations, observation) observations = [...existing.observations, observation] // Prune old observations by age (and hard cap) @@ -1498,6 +1590,7 @@ export const useAircraftTimelineStore = create((set, get) continue } + forgetStopDecisionsAfter(existing.observations, observation) observations = [...existing.observations, observation] // Prune old observations by age (and hard cap) diff --git a/src/renderer/utils/aircraft/flightPhaseDetector.ts b/src/renderer/utils/aircraft/flightPhaseDetector.ts index 4b333b6b..7e81fe3e 100644 --- a/src/renderer/utils/aircraft/flightPhaseDetector.ts +++ b/src/renderer/utils/aircraft/flightPhaseDetector.ts @@ -5,6 +5,7 @@ * Identifies 14 distinct phases from short final to stopped taxi. */ +import { STOPPED_SPEED_KTS } from '@/constants' import { geoidService } from '../../services/GeoidService' import type { AircraftTimeline } from '../../types/aircraft-timeline' import type { Runway } from '../../types/airport' @@ -30,7 +31,6 @@ const PATTERN_DISTANCE_NM = 5 // elevation, not actual terrain - may be inaccurate for sloped terrain. const GROUND_ALTITUDE_AGL_FT = 100 const PUSHBACK_MAX_SPEED_KTS = 8 // Pushback is typically 2-5 kts -const STOPPED_SPEED_KTS = 2 // Below 2 kts is truly stopped (accounts for GPS jitter) const RUNWAY_ALIGNMENT_DEG = 25 // Generous for wind correction/crab angle const HOLDING_SHORT_DISTANCE_FT = 500 const RUNWAY_WIDTH_BUFFER_FT = 150 diff --git a/tests/motion/harness.ts b/tests/motion/harness.ts index 1d65be4d..a93538b6 100644 --- a/tests/motion/harness.ts +++ b/tests/motion/harness.ts @@ -96,6 +96,8 @@ export interface FrameRecord { gearApp: number gearEllipsoidal: number displayDelayMs: number + /** The delay the display delay is easing toward, from the receive intervals; null without a delay state. */ + targetDelayMs: number | null extrapolating: boolean } @@ -380,6 +382,7 @@ function recordFrame(deps: FrameDeps, feed: FeedState, e: AircraftEntry, now: nu now, ), displayDelayMs: ts.displayDelay, + targetDelayMs: deps.tl.useAircraftTimelineStore.getState().dynamicDelays.get(e.callsign)?.targetDelayMs ?? null, extrapolating: ts.isExtrapolating, }) feed.frames.set(e.callsign, list) diff --git a/tests/motion/motion.test.ts b/tests/motion/motion.test.ts index fd510651..46ff6e35 100644 --- a/tests/motion/motion.test.ts +++ b/tests/motion/motion.test.ts @@ -13,6 +13,7 @@ import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { beforeAll, describe, expect, it, vi } from 'vitest' +import { knotsToMps } from '@/utils/coordinates' import { GEAR_RETRACT_MIN_AIRBORNE_SECONDS } from '@/utils/gearAnimationController' import { angleDiff, type FrameRecord, runScenario, type ScenarioResult } from './harness' @@ -59,6 +60,19 @@ const GEAR_UP_AFTER_CONTACT_MAX_S = 3 // measured 2.1 s (7.3 s at 150 ft); gear const GEAR_TRANSITION_S = 8 // Ease-out cubic leaves 0.67 one second into an 8 s retraction; linear 0.875, the old cubic ease-in-out 0.99. const GEAR_EASE_OUT_AT_1S_MAX = 0.75 +// The vNAS line-up hold, from the hold to 15 s into the roll. The target display delay stays at the 1 Hz value, the +// 1000 ms receive interval plus the 150 ms jitter buffer, with 1 ms for float rounding (min(20 s, gap + 150 ms) when +// the silence counted). The drawn position while standing: 8.2 m of creep when the last sample was extrapolated. +const PARKED_DELAY_WINDOW_S = 15 +const PARKED_TARGET_DELAY_MAX_MS = 1151 +const PARKED_POS_ERR_MAX_M = 0.05 +// The start of the roll after the hold: each frame step against the truth speed at that frame times this rate (the +// display delay easing down at 100 ms/s plays the timeline at 1.1x), plus a margin; and the drawn position against +// truth at the displayed instant. A regression pin: it measures the same with the hold and the statistics exclusion +// taken out, because the harness's last stopped sample reports its true speed of about 0 kt. +const ROLL_STEP_RATE_MAX = 1.2 +const ROLL_STEP_MARGIN_M = 0.05 // measured 0.017 m over the allowance +const ROLL_POS_ERR_MAX_M = 2 // measured 0.61 m const ROOT = resolve(__dirname, '../..') const REPORT_PATH = resolve(ROOT, '.tmp/motion/report.txt') @@ -459,8 +473,74 @@ describe('findings the review measured that are still bugs today', () => { } }) // TC3D-51 - it.todo('1.3 vNAS silence from a stationary aircraft does not inflate the display delay (TC3D-51)') - it.todo('1.4 a stopped aircraft does not extrapolate forward after the last vNAS sample (TC3D-51)') + it('1.3 vNAS silence from a stationary aircraft does not inflate the display delay (TC3D-51)', () => { + for (const [label, res] of [ + ['vNAS, VATSIM seen first', vnas], + ['vNAS seen first', vnasFirst], + ] as const) { + const flight = res.flights.find((f) => f.callsign === 'DEP1') + expect(flight, label).toBeDefined() + if (!flight) return + const recs = framesFor(res, 'DEP1').filter( + (r) => r.tLocal >= flight.events.lined_up && r.tLocal <= flight.events.takeoff_roll + PARKED_DELAY_WINDOW_S, + ) + expect(recs.length, label).toBeGreaterThan(1000) + // The target, not the current delay: with VATSIM seen first the current delay is still easing down from the + // VATSIM value here, which would hide a raised target + const worst = maxValue(recs, (r) => r.targetDelayMs ?? Number.POSITIVE_INFINITY) + expect(worst, `${label}: target display delay up to ${worst.toFixed(0)} ms around the line-up hold`).toBeLessThanOrEqual( + PARKED_TARGET_DELAY_MAX_MS, + ) + } + }) + + it('1.4 a stopped aircraft does not extrapolate forward after the last vNAS sample (TC3D-51)', () => { + for (const [label, res] of [ + ['vNAS, VATSIM seen first', vnas], + ['vNAS seen first', vnasFirst], + ] as const) { + const flight = res.flights.find((f) => f.callsign === 'DEP1') + expect(flight, label).toBeDefined() + if (!flight) return + // Frames shown before the first sample of the roll can be bracketed: the hold alone, not the start of the roll + const held = framesFor(res, 'DEP1').filter( + (r) => r.truth.segment === 'lined_up' && r.truth.gs === 0 && r.tLocal <= flight.events.takeoff_roll - 1.5, + ) + expect(held.length, label).toBeGreaterThan(600) + expect(held.filter((r) => r.extrapolating).length, `${label}: held frames extrapolating`).toBe(0) + const drift = maxPosErr(held) + expect(drift, `${label}: drawn ${drift.toFixed(2)} m from where it stands`).toBeLessThanOrEqual(PARKED_POS_ERR_MAX_M) + } + }) + + it('1.3 regression pin: the takeoff roll after a long stop keeps starting from the held spot with no jump (TC3D-51)', () => { + for (const [label, res] of [ + ['vNAS, VATSIM seen first', vnas], + ['vNAS seen first', vnasFirst], + ] as const) { + const flight = res.flights.find((f) => f.callsign === 'DEP1') + expect(flight, label).toBeDefined() + if (!flight) return + const recs = framesFor(res, 'DEP1').filter( + (r) => r.tLocal >= flight.events.lined_up && r.tLocal <= flight.events.takeoff_roll + PARKED_DELAY_WINDOW_S, + ) + expect(recs.length, label).toBeGreaterThan(1000) + let worstExcess = Number.NEGATIVE_INFINITY + for (let i = 1; i < recs.length; i++) { + const step = Math.hypot(recs[i].x - recs[i - 1].x, recs[i].y - recs[i - 1].y) + const truthSpeedMps = knotsToMps(Math.max(recs[i].truth.gs, recs[i - 1].truth.gs)) + const allowed = truthSpeedMps * (recs[i].frameTime - recs[i - 1].frameTime) * ROLL_STEP_RATE_MAX + worstExcess = Math.max(worstExcess, step - allowed) + } + expect(worstExcess, `${label}: a frame step exceeds the truth speed allowance by ${worstExcess.toFixed(3)} m`).toBeLessThanOrEqual( + ROLL_STEP_MARGIN_M, + ) + const posErr = maxPosErr(recs) + expect(posErr, `${label}: drawn up to ${posErr.toFixed(2)} m from truth at the displayed instant`).toBeLessThanOrEqual( + ROLL_POS_ERR_MAX_M, + ) + } + }) // TC3D-52 it.todo('3.2 bank is a coordinated turn at airliner speeds, not half of one (TC3D-52)') it.todo('3.4 the approach pitch is near 2.5-3 deg, not about 1 deg (TC3D-52)') diff --git a/tests/motion/vnasSilence.test.ts b/tests/motion/vnasSilence.test.ts new file mode 100644 index 00000000..22878569 --- /dev/null +++ b/tests/motion/vnasSilence.test.ts @@ -0,0 +1,373 @@ +// @vitest-environment node +/** + * vNAS sends nothing for an aircraft that stands still. When its last sample is under 2 kt the silence is parking: + * the aircraft is held where that sample put it, with no extrapolation, and the silent interval stays out of the + * display delay. A moving aircraft that goes quiet, and every VATSIM aircraft, still extrapolate as before. + */ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { DYNAMIC_DELAY_JITTER_BUFFER_MS, SOURCE_DISPLAY_DELAYS } from '@/constants/aircraft-timeline' +import type { + AircraftDataSource, + AircraftObservation, + DynamicDelayState, + TimelineInterpolationResult, +} from '@/types/aircraft-timeline' +import { knotsToMps } from '@/utils/coordinates' + +type TimelineModule = typeof import('@/stores/aircraftTimelineStore') + +const T0 = Date.UTC(2026, 0, 1, 12, 0, 0) +const FRAME_MS = 1000 / 60 +const CS = 'AAL1' +const METADATA = { cid: 1, aircraftType: 'B738', transponder: '1200', departure: null, arrival: null } +const KTS = knotsToMps(1) +const LAT = 37.72 +const LON = -122.22 +const M_PER_DEG_LAT = 111320 +const M_PER_DEG_LON = 111320 * Math.cos((LAT * Math.PI) / 180) +const VNAS_RECEIVE_LAG_MS = 80 +const VATSIM_RECEIVE_LAG_MS = 500 +const PARKED_SILENCE_MS = 25_000 +const HOLD_TOLERANCE_M = 0.01 +const EXTRAPOLATION_TOLERANCE_M = 0.05 +// 15 kt moves 0.13 m per 60 fps frame; catching up the 7.7 m the repeated sample cost at about twice that is +// smooth. A hold at the repeated sample would stand still for the gap and then cover about 31 m in 1 s. +const RESUME_STEP_MAX_M = 0.3 +// A 1 Hz stream received at a constant lag has every receive interval at exactly 1000 ms, so calculateTargetDelay +// (maximum interval plus the jitter buffer) gives 1150 ms. The 25 s silence counted as an interval would give +// min(20 s, 25.15 s) = 20 s. +const ONE_HZ_TARGET_MS = 1000 + DYNAMIC_DELAY_JITTER_BUFFER_MS + +let tl: TimelineModule + +beforeEach(async () => { + vi.resetModules() + // vnasStore registers a removal listener at module init; loading it first resolves the store import cycle + // in the same order the app does. + await import('@/stores/vnasStore') + tl = await import('@/stores/aircraftTimelineStore') +}) + +afterEach(() => { + vi.useRealTimers() +}) + +/** One eastbound sample `eastM` metres from the origin at scenario second `t`. */ +function sample(source: AircraftDataSource, t: number, eastM: number, groundspeed: number): AircraftObservation { + const observedAt = T0 + t * 1000 + return { + latitude: LAT, + longitude: LON + eastM / M_PER_DEG_LON, + altitude: 10, + heading: 90, + groundspeed, + groundTrack: source === 'vnas' ? 90 : null, + headingIsTrue: true, + onGround: true, + pitch: null, + roll: null, + verticalRate: null, + observedAt, + receivedAt: observedAt + (source === 'vnas' ? VNAS_RECEIVE_LAG_MS : VATSIM_RECEIVE_LAG_MS), + source, + displayDelay: SOURCE_DISPLAY_DELAYS[source], + ...(source === 'vnas' ? { heightAboveGround: 0 } : {}), + } +} + +/** A vNAS stream at 1 Hz for seconds 0..lastT, moving east at a constant speed. */ +function steadyVnas(lastT: number, speedKts: number): AircraftObservation[] { + const out: AircraftObservation[] = [] + for (let t = 0; t <= lastT; t++) out.push(sample('vnas', t, speedKts * KTS * t, speedKts)) + return out +} + +interface Frame { + now: number + result: TimelineInterpolationResult + delay: DynamicDelayState | undefined +} + +/** Feeds each sample once its receive time has passed and interpolates a 60 fps frame, up to `untilMs` after T0. */ +function run(samples: AircraftObservation[], untilMs: number): Frame[] { + const store = tl.useAircraftTimelineStore + const frames: Frame[] = [] + let next = 0 + for (let f = 0; f * FRAME_MS <= untilMs; f++) { + const now = T0 + f * FRAME_MS + while (next < samples.length && samples[next].receivedAt <= now) { + store.getState().addObservation(CS, samples[next], METADATA) + next++ + } + const result = store.getState().getInterpolatedStates(now).get(CS) + if (result) frames.push({ now, result, delay: store.getState().dynamicDelays.get(CS) }) + } + return frames +} + +function eastOf(position: { longitude: number }): number { + return (position.longitude - LON) * M_PER_DEG_LON +} + +function metresBetween(result: TimelineInterpolationResult, obs: AircraftObservation): number { + const east = (result.longitude - obs.longitude) * M_PER_DEG_LON + const north = (result.latitude - obs.latitude) * M_PER_DEG_LAT + return Math.hypot(east, north) +} + +/** Frames whose displayed instant is past the given sample, up to the end of the silence after it. */ +function pastSample(frames: Frame[], last: AircraftObservation): Frame[] { + return frames.filter((f) => f.result.displayTime > last.observedAt && f.now <= last.receivedAt + PARKED_SILENCE_MS) +} + +function expectHeldAt(frames: Frame[], last: AircraftObservation): void { + const held = pastSample(frames, last) + expect(held.length, 'frames displayed past the last sample').toBeGreaterThan(60) + const drift = held.reduce((m, f) => Math.max(m, metresBetween(f.result, last)), 0) + expect(drift, `drawn up to ${drift.toFixed(2)} m from the last sample`).toBeLessThanOrEqual(HOLD_TOLERANCE_M) + expect(held.filter((f) => f.result.groundspeed !== 0).length, 'held frames with a groundspeed').toBe(0) + expect(held.filter((f) => f.result.groundTrack !== null).length, 'held frames with a ground track').toBe(0) + expect(held.filter((f) => f.result.isExtrapolating).length, 'held frames marked as extrapolating').toBe(0) +} + +it('a vNAS aircraft whose last sample is 1.5 kt is held there through 25 s of silence (TC3D-51)', () => { + const samples = steadyVnas(19, 1.5) + const last = samples[samples.length - 1] + const frames = run(samples, last.receivedAt - T0 + PARKED_SILENCE_MS) + expectHeldAt(frames, last) +}) + +it(`the silence after a stopped vNAS sample stays out of the display delay: target ${ONE_HZ_TARGET_MS} ms (TC3D-51)`, () => { + const samples = steadyVnas(19, 1.5) + const last = samples[samples.length - 1] + const moving = sample('vnas', 19 + PARKED_SILENCE_MS / 1000, eastOf(last) + 2, 4) + const frames = run([...samples, moving], moving.receivedAt - T0 + 100) + + const silent = frames.filter((f) => f.now > last.receivedAt && f.now < moving.receivedAt) + expect(silent.length).toBeGreaterThan(1000) + const maxDelay = silent.reduce((m, f) => Math.max(m, f.result.displayDelay), 0) + expect(maxDelay, `display delay rose to ${maxDelay.toFixed(0)} ms during the silence`).toBeLessThanOrEqual( + ONE_HZ_TARGET_MS + 1e-6, + ) + + const after = frames[frames.length - 1].delay + expect(after).toBeDefined() + expect(after?.targetDelayMs, 'target delay once the first sample after the silence arrives').toBeCloseTo( + ONE_HZ_TARGET_MS, + 6, + ) + expect(Math.max(...(after?.intervalHistory ?? [])), 'longest interval kept in the statistics').toBe(1000) +}) + +it('control: a vNAS aircraft whose last sample is 15 kt still extrapolates through a 3 s gap', () => { + const samples = steadyVnas(19, 15) + const last = samples[samples.length - 1] + const frames = run(samples, last.receivedAt - T0 + 3000) + const past = frames.filter((f) => f.result.displayTime > last.observedAt) + expect(past.length, 'frames displayed past the last sample').toBeGreaterThan(60) + expect(past.filter((f) => !f.result.isExtrapolating).length, 'frames past the last sample not extrapolating').toBe(0) + for (const f of past) { + const expected = eastOf(last) + 15 * KTS * ((f.result.displayTime - last.observedAt) / 1000) + expect(Math.abs(eastOf(f.result) - expected)).toBeLessThanOrEqual(EXTRAPOLATION_TOLERANCE_M) + } +}) + +/** `samples` followed by one vNAS sample per entry of `stepsM`, 1 s apart, each that many metres east of the one before. */ +function thenSteps(samples: AircraftObservation[], stepsM: number[], groundspeeds: number[]): AircraftObservation[] { + const out = [...samples] + stepsM.forEach((step, i) => { + const prev = out[out.length - 1] + const t = (prev.observedAt - T0) / 1000 + 1 + out.push(sample('vnas', t, eastOf(prev) + step, groundspeeds[i])) + }) + return out +} + +it('a stopped vNAS aircraft whose windowed groundspeed still reads 3 kt is held after moving 1.5 m in its last 3 s (TC3D-51)', () => { + const samples = thenSteps(steadyVnas(15, 3), [0.5, 0.5, 0.5], [3, 3, 3]) + const last = samples[samples.length - 1] + const frames = run(samples, last.receivedAt - T0 + PARKED_SILENCE_MS) + expectHeldAt(frames, last) +}) + +it('a 15 kt vNAS taxi whose last sample repeats the position before it still extrapolates through a 3 s gap, with no jump on resuming (TC3D-51)', () => { + const samples = thenSteps(steadyVnas(18, 15), [0], [15]) + const last = samples[samples.length - 1] + const resumed = [22, 23, 24, 25].map((t) => sample('vnas', t, 15 * KTS * t, 15)) + const frames = run([...samples, ...resumed], resumed[resumed.length - 1].receivedAt - T0) + + const gap = frames.filter((f) => f.result.displayTime > last.observedAt && f.now < resumed[0].receivedAt) + expect(gap.length, 'frames displayed past the repeated sample before samples resume').toBeGreaterThan(60) + expect(gap.filter((f) => !f.result.isExtrapolating).length, 'gap frames not extrapolating').toBe(0) + + let maxStep = 0 + for (let i = 1; i < frames.length; i++) { + if (frames[i].now < last.receivedAt) continue + maxStep = Math.max(maxStep, Math.abs(eastOf(frames[i].result) - eastOf(frames[i - 1].result))) + } + expect(maxStep, `largest step between frames ${maxStep.toFixed(3)} m`).toBeLessThanOrEqual(RESUME_STEP_MAX_M) +}) + +/** + * Decelerating from 8 kt to a stop with one stationary sample: the last step is 0 kt, the step before 2.9 kt, and the + * last 3 s cover 4 m (2.6 kt), straddling the stop. + */ +function straddlingStop(): AircraftObservation[] { + return thenSteps(steadyVnas(10, 8), [3, 2.5, 1.5, 0], [6, 3, 1, 0]) +} + +it('a vNAS aircraft with one stationary step after a 2.9 kt step and a 2.6 kt window is not held yet: it extrapolates', () => { + const samples = straddlingStop() + const last = samples[samples.length - 1] + const past = pastSample(run(samples, last.receivedAt - T0 + PARKED_SILENCE_MS), last) + expect(past.length, 'frames displayed past the last sample').toBeGreaterThan(60) + expect(past.filter((f) => !f.result.isExtrapolating).length, 'frames past the last sample not extrapolating').toBe(0) +}) + +it('the same stop is held once a second stationary step follows (TC3D-51)', () => { + const samples = thenSteps(straddlingStop(), [0], [0]) + const last = samples[samples.length - 1] + expectHeldAt(run(samples, last.receivedAt - T0 + PARKED_SILENCE_MS), last) +}) + +it('control: a VATSIM aircraft whose last report is 1.5 kt still extrapolates and keeps its 15 s intervals', () => { + const samples = [0, 15, 30, 45].map((t) => sample('vatsim', t, 1.5 * KTS * t, 1.5)) + const last = samples[samples.length - 1] + const frames = run(samples, last.receivedAt - T0 + PARKED_SILENCE_MS) + const past = frames.filter((f) => f.result.displayTime > last.observedAt) + expect(past.length, 'frames displayed past the last report').toBeGreaterThan(60) + expect(past.filter((f) => !f.result.isExtrapolating).length, 'frames past the last report not extrapolating').toBe(0) + const lastFrame = past[past.length - 1] + expect(lastFrame.result.groundspeed).toBe(1.5) + expect(metresBetween(lastFrame.result, last), 'distance extrapolated past the last report').toBeGreaterThan(1) + expect(Math.max(...(lastFrame.delay?.intervalHistory ?? [])), 'longest interval kept in the statistics').toBe(15000) +}) + +it('a held vNAS-only aircraft is still there at 29 s of silence and pruned at 35 s, as before the hold', () => { + vi.useFakeTimers({ toFake: ['Date'] }) + const samples = steadyVnas(19, 1.5) + const last = samples[samples.length - 1] + const frames = run(samples, last.receivedAt - T0 + 29_000) + expectHeldAt(frames, last) + const store = tl.useAircraftTimelineStore + + vi.setSystemTime(last.receivedAt + 29_000) + store.getState().pruneStaleAircraft() + expect(store.getState().timelines.has(CS), 'present at 29 s of silence').toBe(true) + + vi.setSystemTime(last.receivedAt + 35_000) + store.getState().pruneStaleAircraft() + expect(store.getState().timelines.has(CS), 'present at 35 s of silence').toBe(false) +}) + +it('a vNAS aircraft that resumes taxiing at 15 kt after a 25 s stop and then goes quiet for 4 s extrapolates, and the 4 s counts (TC3D-51)', () => { + const parked = steadyVnas(19, 0) + const resumed = sample('vnas', 44, 4, 15) + const next = sample('vnas', 48, 4 + 15 * KTS * 4, 15) + const frames = run([...parked, resumed, next], next.receivedAt - T0 + 100) + + const gap = frames.filter((f) => f.result.displayTime > resumed.observedAt && f.now < next.receivedAt) + expect(gap.length, 'frames displayed past the resumed sample before the next').toBeGreaterThan(60) + expect(gap.filter((f) => !f.result.isExtrapolating).length, 'gap frames not extrapolating').toBe(0) + const history = frames[frames.length - 1].delay?.intervalHistory ?? [] + expect(Math.max(...history), 'longest interval kept in the statistics').toBe(4000) +}) + +it('a slow vNAS aircraft climbing at 500 fpm is not held in the air through a 4 s gap (TC3D-51)', () => { + const climbMps = (500 * 0.3048) / 60 + const samples: AircraftObservation[] = [] + for (let t = 0; t <= 9; t++) { + const height = 30 + climbMps * t + samples.push({ ...sample('vnas', t, KTS * t, 1), altitude: 10 + height, heightAboveGround: height, onGround: false }) + } + const last = samples[samples.length - 1] + const frames = run(samples, last.receivedAt - T0 + 4000) + const past = frames.filter((f) => f.result.displayTime > last.observedAt) + expect(past.length, 'frames displayed past the last sample').toBeGreaterThan(60) + expect(past.filter((f) => !f.result.isExtrapolating).length, 'frames past the last sample not extrapolating').toBe(0) + for (const f of past) { + expect(f.result.groundspeed).toBe(1) + expect(f.result.verticalRate).toBeCloseTo(500, 3) + } +}) + +it('a vNAS aircraft 1.5 s into a 3.5 kt/s roll extrapolates through a 2 s dropout, though its 3 s window reads 1.3 kt (TC3D-51)', () => { + const rollStartT = 20.5 + const rollEast = (t: number) => 0.5 * knotsToMps(3.5) * (t - rollStartT) ** 2 + const samples = [...steadyVnas(20, 0), sample('vnas', 21, rollEast(21), 1.75), sample('vnas', 22, rollEast(22), 5.25)] + const last = samples[samples.length - 1] + const frames = run(samples, last.receivedAt - T0 + 2000) + const past = frames.filter((f) => f.result.displayTime > last.observedAt) + expect(past.length, 'frames displayed past the last sample').toBeGreaterThan(30) + expect(past.filter((f) => !f.result.isExtrapolating).length, 'frames past the last sample not extrapolating').toBe(0) +}) + +it('a vNAS aircraft that stops and sends two stationary samples is held, though its 3 s window straddles the stop (TC3D-51)', () => { + const samples = thenSteps(steadyVnas(10, 15), [4, 0, 0], [8, 0, 0]) + const last = samples[samples.length - 1] + expectHeldAt(run(samples, last.receivedAt - T0 + PARKED_SILENCE_MS), last) +}) + +it('a vNAS aircraft hovering at 40 ft with the ground flag set and no height above ground is not held (TC3D-51)', () => { + const samples = steadyVnas(19, 0).map((s) => { + const { heightAboveGround: _height, ...rest } = s + return { ...rest, altitude: 10 + 40 * 0.3048, onGround: true } + }) + const last = samples[samples.length - 1] + const frames = run(samples, last.receivedAt - T0 + 5000) + const past = frames.filter((f) => f.result.displayTime > last.observedAt) + expect(past.length, 'frames displayed past the last sample').toBeGreaterThan(60) + expect(past.filter((f) => !f.result.isExtrapolating).length, 'frames past the last sample not extrapolating').toBe(0) +}) + +it('a late vNAS packet received out of order does not decide whether the newest sample is stopped (TC3D-51)', () => { + // Stopped at t = 10 after 15 kt; the stationary samples report a lagging 3 kt. The packet for t = 5 arrives between + // those for t = 19 and t = 20, so it sits just before t = 20 in the timeline. + const stopEast = 15 * KTS * 10 + const samples = [...steadyVnas(10, 15)] + for (let t = 11; t <= 19; t++) samples.push(sample('vnas', t, stopEast, 3)) + const late = { ...sample('vnas', 5, 15 * KTS * 5, 15), receivedAt: T0 + 19_500 } + const last = sample('vnas', 20, stopEast, 3) + samples.push(late, last) + expectHeldAt(run(samples, last.receivedAt - T0 + PARKED_SILENCE_MS), last) +}) + +it('a stop decided while the sample before it was missing is decided again when that sample arrives late (TC3D-51)', () => { + const store = tl.useAircraftTimelineStore + const stopped = [6, 7, 8, 10].map((t) => sample('vnas', t, 0, 0)) + for (const s of stopped) store.getState().addObservation(CS, s, METADATA) + const decide = () => { + const observations = store.getState().timelines.get(CS)?.observations ?? [] + return tl.isStoppedVnasSample(observations, observations.indexOf(stopped[3])) + } + expect(decide(), 'stopped while t = 9 is missing').toBe(true) + + const fastStep = { ...sample('vnas', 9, 8, 15), receivedAt: stopped[3].receivedAt + 500 } + store.getState().addObservation(CS, fastStep, METADATA) + expect(decide(), 'stopped once t = 9 arrives 8 m away').toBe(false) +}) + +it('one slow 3 s step from the only sample in range is not enough to hold (TC3D-51)', () => { + const samples = [...steadyVnas(16, 15), sample('vnas', 19, 15 * KTS * 16 + 1, 15)] + const last = samples[samples.length - 1] + const past = pastSample(run(samples, last.receivedAt - T0 + 5000), last) + expect(past.length, 'frames displayed past the last sample').toBeGreaterThan(60) + expect(past.filter((f) => !f.result.isExtrapolating).length, 'frames past the last sample not extrapolating').toBe(0) +}) + +it('an isolated vNAS sample reporting 1 kt, with no sample in the 6 s before it, is not held (TC3D-51)', () => { + const samples = [...steadyVnas(5, 0), sample('vnas', 12, 0, 1)] + const last = samples[samples.length - 1] + const past = pastSample(run(samples, last.receivedAt - T0 + 5000), last) + expect(past.length, 'frames displayed past the last sample').toBeGreaterThan(60) + expect(past.filter((f) => !f.result.isExtrapolating).length, 'frames past the last sample not extrapolating').toBe(0) +}) + +it('in a list that mixes sources, the interval from a stopped vNAS sample to a VATSIM report counts (TC3D-51)', () => { + const vnasSample = sample('vnas', 20, 0, 0) + const samples = [sample('vatsim', 0, 0, 0), sample('vatsim', 15, 0, 0), vnasSample, sample('vatsim', 30, 0, 0)] + const last = samples[samples.length - 1] + const frames = run(samples, last.receivedAt - T0 + 100) + const history = frames[frames.length - 1].delay?.intervalHistory ?? [] + expect(history, 'intervals kept in the statistics').toContain(last.receivedAt - vnasSample.receivedAt) +}) \ No newline at end of file From d805af6f2d76877a1b6caed076bb24c0df82d491 Mon Sep 17 00:00:00 2001 From: Leftos Aslanoglou Date: Wed, 21 Oct 2026 23:36:52 -0700 Subject: [PATCH 3/3] feat: animate spoilers, reversers and wheels on MSFS models FSLTL and AIG models now play their spoiler, thrust-reverser and wheel-spin glTF clips after the native gear animation. Spoilers deploy at every touchdown above 60 kt and stay out through the rollout; reversers deploy on deceleration and latch out until the rollout ends; wheels spin at groundspeed over a wingspan-scaled radius with a 5 s coast. Clips come from a name-pattern allowlist, steering clips are excluded, a wheel clip must be one revolution, and wheel clips on gear nodes are dropped. The controller runs on data time and carries the frame's replay flag; seeks, replay/live switches and first sight reset it. Heightless sources (VATSIM, RealTraffic) enter surface contact only once drawn on the terrain and hold it while the shared contact holds. Deceleration is measured over 0.5 s of data time. Pushback detection is shared with the phase detector. Refs: TC3D-54 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + CLAUDE.md | 2 +- MODDING.md | 6 + USER_GUIDE.md | 3 + docs/architecture.md | 12 + docs/msfs-model-conversion.md | 14 + docs/plans/aircraft-motion-review.md | 2 +- src/renderer/constants/flightPhase.ts | 3 + src/renderer/constants/rendering.ts | 63 ++ .../hooks/useAircraftInterpolation.ts | 16 +- src/renderer/hooks/useAircraftModels.ts | 19 +- src/renderer/types/vatsim.ts | 17 + .../utils/aircraft/flightPhaseDetector.ts | 17 +- src/renderer/utils/aircraft/pushback.ts | 21 + src/renderer/utils/gearAnimationController.ts | 29 +- src/renderer/utils/gltfAnimationParser.ts | 711 ++++++++++-------- .../utils/surfaceAnimationController.ts | 432 +++++++++++ tests/motion/harness.ts | 35 +- tests/motion/surfaceClipPlayback.test.ts | 251 +++++++ tests/motion/surfaceClips.test.ts | 154 ++++ tests/motion/surfaceRule.test.ts | 429 +++++++++++ tests/motion/surfaceScenario.test.ts | 280 +++++++ 22 files changed, 2165 insertions(+), 352 deletions(-) create mode 100644 src/renderer/utils/aircraft/pushback.ts create mode 100644 src/renderer/utils/surfaceAnimationController.ts create mode 100644 tests/motion/surfaceClipPlayback.test.ts create mode 100644 tests/motion/surfaceClips.test.ts create mode 100644 tests/motion/surfaceRule.test.ts create mode 100644 tests/motion/surfaceScenario.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f6cef5a7..8a3c1e0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to TowerCab 3D will be documented in this file. - New **Show Moon** setting under Settings → Graphics lets you hide the moon from the sky. - vNAS can join your CRC session on a YAAT training server: YAAT1, or YAAT Local when a local YAAT server is running. - 3D buildings now show rows of windows on their walls, some of them lit at night. +- FSLTL and AIG aircraft now deploy spoilers and thrust reversers on landing, and their wheels turn as they roll. ### Changed - 3D buildings and aircraft no longer have an olive-green cast on their walls and fuselages; their reflected ground light is now a neutral grey. diff --git a/CLAUDE.md b/CLAUDE.md index b49fec1a..71b66103 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -375,7 +375,7 @@ if (version < 7) { 1. Interpolation logic (60 Hz smooth motion): `useAircraftInterpolation.ts` 2. 3D model rendering: `CesiumViewer.tsx` (Cesium entities) 3. Datablock labels and leader lines: `useBabylonOverlay.ts` (Babylon.js GUI) -4. Regression suite: `tests/motion/` drives the interpolation hook, the timeline store, the phase detector and the gear controller with synthetic VATSIM (15 s) and vNAS (1 Hz) flights and compares each frame with truth (`pnpm vitest run tests/motion`; `MOTION_REPORT=1` writes `.tmp/motion/report.txt`). A motion fix lands with a red-first assertion there, turning its finding's `it.todo` into an `it`. +4. Regression suite: `tests/motion/` drives the interpolation hook, the timeline store, the phase detector and the gear and surface (spoiler, reverser, wheel) controllers with synthetic VATSIM (15 s) and vNAS (1 Hz) flights and compares each frame with truth (`pnpm vitest run tests/motion`; `MOTION_REPORT=1` writes `.tmp/motion/report.txt`). A motion fix lands with a red-first assertion there, turning its finding's `it.todo` into an `it`. ### Modifying Camera Behavior diff --git a/MODDING.md b/MODDING.md index b1663c27..d87c3205 100644 --- a/MODDING.md +++ b/MODDING.md @@ -177,6 +177,12 @@ One mod can match multiple aircraft types by listing them in `aircraftTypes`. 4. **File Size**: Keep models under 5MB for best performance 5. **Format**: GLB recommended; DAE and OBJ also supported +### Animation Clips + +Only converted FSLTL and AIG models play animation clips; a custom mod model is drawn static. The viewer picks a clip by its glTF animation name: gear clips contain `GEAR`; spoiler clips are names like `l_spoiler_key`, `r_spoiler_key` or `custom_anim_SPOILERS_LEFT_POSITION_n`; reverser clips are names like `custom_anim_REV_THRUST`, `reverse_thrust` or `thrust_rev_n`; wheel-spin clips are names like `custom_anim_CENTER_WHEEL_ROTATION_ANGLE_n`, `WHEEL_ROLL_left` or `c_tire_anim`. + +A wheel-spin clip must be exactly one revolution about a single axis, or it is not played, and it must not move a node a gear clip moves. Steering clips (any name containing `STEER`) are never played. The full list is in `docs/msfs-model-conversion.md`, "Animation Clips the Viewer Drives". + ## Tower Mods ### Manifest Format diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 2991c7fb..e8773542 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -940,6 +940,9 @@ TowerCab 3D matches aircraft to models in this order: ### Features - **Landing Gear Animation**: Gear is down on the ground and extends when descending below 2,000ft AGL; it retracts shortly after liftoff once the aircraft is climbing +- **Spoilers, Thrust Reversers and Wheels**: On models that have the matching animations, spoilers deploy at touchdown and stay out through the rollout, thrust reversers deploy while the aircraft decelerates and stay out until the rollout ends, and the wheels spin with the aircraft's groundspeed (backwards in a pushback) and coast to a stop after liftoff. A touch-and-go ends the rollout. + + For VATSIM and RealTraffic aircraft, which report no height, the surfaces wait until the aircraft is drawn fully on the ground - **Multiple Engine Variants**: Supports aircraft with GE, PW, or RR engine variants - **Texture Scaling**: Choose texture resolution (Full 4K, 2K, 1K, or 512) to balance quality and storage - **LRU Cache**: Automatic cleanup when cache limit is reached diff --git a/docs/architecture.md b/docs/architecture.md index 58ed8f67..ce0db234 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -85,6 +85,18 @@ Note: AircraftPanel uses useAircraftFiltering for 1Hz UI updates `useAircraftModels.ts` feeds `inContact` and the data-source time to `updateGearAnimation` (`utils/gearAnimationController.ts`; inputs typed in `types/gear.ts`). In contact the gear is down. After contact ends, the gear retracts on positive climb once the aircraft has been out of contact 2 s of data time, over 8 s with an ease-out; that retract fires once per contact, after which the gear holds and extends only when descending below 2000 ft. The 150 ft climb rule takes over once the aircraft passes 150 ft out of contact or after a backward seek in replay. An aircraft not in contact since it was drawn (an airborne spawn, a go-around without touchdown) uses the 150 ft rule. `tests/motion/` covers it (`contact`, `gearRule`, `heightAboveGround`, `heightlessVnas`). +Right after the gear, `useAircraftModels.ts` feeds `updateSurfaceAnimation` (`utils/surfaceAnimationController.ts`) and applies the pose with `applySurfaceAnimation` (`gearAnimationController.ts`), for FSLTL/AIG models only. The controller runs on the data-source time of the frame (`aircraft.timestamp`), so a paused replay stops the wheels and holds a reverser mid-travel; the gear's own transition stays on the wall clock. + +`surfaceFrameFor` builds the controller's frame from the aircraft state alone (its data time, `isReplay` and contact inputs), so the replay flag always matches the frame's data. A source with a height above ground (vNAS) uses the shared `inContact`. A source without one (VATSIM, RealTraffic) is in contact when the shared contact holds and either the blend weight did not come from the landing blend (`InterpolatedAircraftState.groundBlendDescending` false: the departure blend or the ground cases) or it is at or above `SURFACE_HEIGHTLESS_CONTACT_BLEND_WEIGHT` (0.99). The landing case needs the stricter weight because the shared contact passes about 30 m up on a landing; the other cases do not, because on the rollout and taxi the weight falls below 0.99 whenever the reported altitude sits more than about 5 m above the sampled terrain. The rule keeps no state between frames, so first sight and a seek onto a rollout or taxi keep contact. The gear keeps the shared contact. + +Spoilers deploy at a touchdown above 60 kt (`SURFACE_DEPLOY_MIN_GROUNDSPEED_KTS`) on every landing and stay out through the rollout. Reversers deploy 1.5 s after touchdown while the aircraft is above 60 kt and decelerating, and latch out until the rollout ends. Deceleration is the groundspeed change over a window of at least 0.5 s of data time (`SURFACE_DECELERATION_WINDOW_SECONDS`) measured by the controller itself, so it holds at low frame rates and in fast replay. + +The rollout ends at 60 kt or when the groundspeed rises 10 kt above its minimum since touchdown (a touch-and-go). A touchdown is a contact after at least 2 s out of contact, so a contact flicker is not a second one. Wheels spin at groundspeed over a radius set by the type's wingspan (`wheelRadiusForWingspan`), backwards in a pushback, and coast linearly to zero over 5 s after liftoff. The pushback rule (`isPushingBack`, `utils/aircraft/pushback.ts`) is shared with the phase detector. + +A seek snaps the spoilers and reversers to their targets and keeps the wheel phase. A step back in data time is a seek; so is a step forward of more than 5 s (`SURFACE_SEEK_SECONDS`) in replay, and a switch between replay and live; live, a longer forward gap just continues. + +After a seek, or on first sight, an aircraft in contact deploys nothing for that rollout; one first seen out of contact is armed, so its next contact is a touchdown. `tests/motion/` covers it (`surfaceRule`, `surfaceScenario`, `surfaceClips`, `surfaceClipPlayback`). + ### Weather Data Flow ``` diff --git a/docs/msfs-model-conversion.md b/docs/msfs-model-conversion.md index 0c2681a5..aee61a4a 100644 --- a/docs/msfs-model-conversion.md +++ b/docs/msfs-model-conversion.md @@ -748,6 +748,20 @@ fn is_attachment_gltf(filename: &str) -> bool { **Cache Invalidation**: Bumped `MODEL_CACHE_VERSION` to 9 and `CONVERTER_VERSION` to 8 to force re-indexing and re-conversion of affected models. +## Animation Clips the Viewer Drives + +The converter keeps the glTF animation clips; the viewer picks which ones to play by name (`classifyAnimationClip` in `utils/gltfAnimationParser.ts`). Gear progress drives gear clips, and the surface controller (`utils/surfaceAnimationController.ts`) drives spoiler, reverser and wheel-spin clips. + +- **Excluded first** (driven by nothing): any name containing `STEER`, `custom_anim_C_WHEEL_LR`, `c_wheel` and `c_wheel_key`, `custom_anim_ROTATION_VELOCITY_BODY_Y_*`, `custom_anim_{LEFT,RIGHT,CENTER}_WHEEL_RPM_*`, `fx_tire_contactPoint_*`, `custom_anim_GROUND_VELOCITY_*` and `spoiler_UTT787_aileron`. +- **Wheel spin**: `custom_anim_{CENTER,LEFT,RIGHT}_WHEEL_ROTATION_ANGLE_*`, `WHEEL_ROLL_{center,left,right}`, `{c,l,r}_tire` with the suffixes `_anim`, `_blurred_key`, `_still_key`, `_roll` or `_key`, `center_tire_roll`, `tire_anim` and `GEAR_{Center,Left,Right}_Tire`. +- **Spoiler**: `custom_anim_SPOILERS_{LEFT,RIGHT}_POSITION_*`, `{l,r}_spoiler_key`, `SPOILERS` (also spelled `SPOLIERS`, with an optional `_left` or `_right`), `HANDLING_Spoilers_{Ground,l,r}`, `*_Spoiler_{L,R,LR}[n]`, `Spoiler_{L,R}n`, `spoiler_Q_new`, the `TFS_777`, `TFS_A380` and `UTT_787` spoiler sets, and `AIA_Fokker_TailSpeedBrake`. +- **Reverser**: `custom_anim_REV_THRUST`, `reverse_thrust`, `REVERSER` and `REVERSERS`, `thrust_rev_n`, `engine_rev` and `custom_anim_TURB_ENG_REVERSE_NOZZLE_PERCENT_*`. +- **Gear**: every other clip with `GEAR` in its name (case-insensitive). + +The patterns are an allowlist checked against the converted FSLTL and AIG models, so a new model family adds its names to the pattern lists in `gltfAnimationParser.ts`. + +A wheel-spin clip plays only when every rotation track turns exactly one revolution (within 4 degrees), monotonic about one axis with no half-turn step; a clip that does not is left out. A wheel clip that targets a node a gear clip of the same model moves is dropped and logged once, since the two would overwrite each other's pose. + ## Embedded Source Metadata Converted GLB files contain embedded source metadata in `asset.extras.towercab3d`: diff --git a/docs/plans/aircraft-motion-review.md b/docs/plans/aircraft-motion-review.md index 38083a99..2560a65e 100644 --- a/docs/plans/aircraft-motion-review.md +++ b/docs/plans/aircraft-motion-review.md @@ -115,7 +115,7 @@ Ground aircraft use the same timeline path; the hook rate-limits heading at 20 d ### How it works today -No control surface, flap, spoiler or reverser is animated. The FSLTL converter bakes the neutral (or frame-0) pose of every clip into the static model and keeps the clips (`convert_fsltl_batch.py:713-789`). At runtime only gear is driven: `applyGearAnimationsPercent` sets every clip whose name contains `GEAR` to the gear progress (`gltfAnimationParser.ts:484-507`), FSLTL models only (`useAircraftModels.ts:498-528`). +The FSLTL converter bakes the neutral (or frame-0) pose of every clip into the static model and keeps the clips (`convert_fsltl_batch.py:713-789`). At runtime, on FSLTL and AIG models only, the gear, spoiler, thrust-reverser and wheel-spin clips are driven (TC3D-54; `docs/architecture.md` describes the surface controller); flaps and other control surfaces are not animated. ### Findings diff --git a/src/renderer/constants/flightPhase.ts b/src/renderer/constants/flightPhase.ts index e0094cea..26e82482 100644 --- a/src/renderer/constants/flightPhase.ts +++ b/src/renderer/constants/flightPhase.ts @@ -44,6 +44,9 @@ export const PATTERN_ALTITUDE_FT = 2000 /** Pushback maximum speed - pushback is typically 2-5 kts */ export const PUSHBACK_MAX_SPEED_KTS = 8 +/** Pushback minimum speed: below it the track defaults to the heading, so a standing aircraft is not pushing back */ +export const PUSHBACK_MIN_SPEED_KTS = 0.5 + /** Below this speed = truly stopped (accounts for GPS jitter) */ export const STOPPED_SPEED_KTS = 2 diff --git a/src/renderer/constants/rendering.ts b/src/renderer/constants/rendering.ts index 280bc7e5..a0e14499 100644 --- a/src/renderer/constants/rendering.ts +++ b/src/renderer/constants/rendering.ts @@ -662,3 +662,66 @@ export const DEPARTURE_BLEND_START_AGL = 5 * Default: 35 meters (~115 feet) - generous transition zone */ export const DEPARTURE_BLEND_END_AGL = 35 + +// ============================================================================ +// SPOILERS, REVERSERS AND WHEEL SPIN (utils/surfaceAnimationController.ts) +// ============================================================================ + +/** Groundspeed above which spoilers deploy at touchdown and reversers run; both stow at or below it (knots) */ +export const SURFACE_DEPLOY_MIN_GROUNDSPEED_KTS = 60 + +/** Seconds out of ground contact before a new contact counts as a touchdown; a shorter loss is a contact flicker */ +export const SURFACE_TOUCHDOWN_ARM_SECONDS = 2 + +/** Seconds for the spoilers to deploy fully */ +export const SPOILER_DEPLOY_SECONDS = 1 + +/** Seconds for the spoilers to stow fully */ +export const SPOILER_STOW_SECONDS = 1.5 + +/** Seconds after touchdown before the reversers deploy */ +export const REVERSER_DELAY_SECONDS = 1.5 + +/** Seconds for the reverser sleeves to travel fully, either way */ +export const REVERSER_TRAVEL_SECONDS = 2 + +/** Seconds over which the wheel spin coasts linearly to zero after liftoff */ +export const WHEEL_COAST_SECONDS = 5 + +/** + * In replay, a forward data-time step longer than this (seconds) is a seek; a backward step is a seek in any mode. + * A seek snaps the surfaces to their targets and keeps the wheel phase. Live, a longer gap just continues. + */ +export const SURFACE_SEEK_SECONDS = 5 + +/** Knots the groundspeed may rise above its minimum since touchdown before the rollout counts as a touch-and-go */ +export const SURFACE_TOUCH_AND_GO_SPEEDUP_KTS = 10 + +/** + * Seconds of data time over which the groundspeed change decides whether the aircraft is decelerating, for the + * reversers. Measured by the surface controller itself, so it holds on slow frames and fast replay. + */ +export const SURFACE_DECELERATION_WINDOW_SECONDS = 0.5 + +/** + * For a source without a height above ground (VATSIM, RealTraffic) being drawn down by the landing blend, the ground + * blend weight at or above which the spoilers, reversers and wheels count the aircraft in contact: drawn fully onto + * the terrain. The gear's contact is the looser "more than halfway" of `inContact`, which also stands for the surfaces + * in the departure blend and the ground cases. + */ +export const SURFACE_HEIGHTLESS_CONTACT_BLEND_WEIGHT = 0.99 + +/** Wheel radius of regional types (wingspan up to WHEEL_RADIUS_REGIONAL_MAX_WINGSPAN_M), meters */ +export const WHEEL_RADIUS_REGIONAL_M = 0.45 + +/** Wheel radius of narrowbody types, and of types whose wingspan is unknown, meters */ +export const WHEEL_RADIUS_NARROWBODY_M = 0.55 + +/** Wheel radius of widebody types (wingspan over WHEEL_RADIUS_NARROWBODY_MAX_WINGSPAN_M), meters */ +export const WHEEL_RADIUS_WIDEBODY_M = 0.7 + +/** Largest wingspan of a regional type for the wheel radius, meters */ +export const WHEEL_RADIUS_REGIONAL_MAX_WINGSPAN_M = 30 + +/** Largest wingspan of a narrowbody type for the wheel radius, meters */ +export const WHEEL_RADIUS_NARROWBODY_MAX_WINGSPAN_M = 42 diff --git a/src/renderer/hooks/useAircraftInterpolation.ts b/src/renderer/hooks/useAircraftInterpolation.ts index 18cc3f00..f50bd330 100644 --- a/src/renderer/hooks/useAircraftInterpolation.ts +++ b/src/renderer/hooks/useAircraftInterpolation.ts @@ -147,6 +147,7 @@ function timelineToInterpolatedState( now: number, orientationEnabled: boolean, orientationIntensity: number, + isReplay: boolean, ): InterpolatedAircraftState { const callsign = timeline.callsign @@ -389,6 +390,10 @@ function timelineToInterpolatedState( isOnGround: timeline.onGround, heightAboveGround: timeline.heightAboveGround, inContact, + // Written by the terrain pass each frame + groundBlendWeight: 0, + groundBlendDescending: false, + isReplay, isInterpolated: true, @@ -517,7 +522,9 @@ function runInterpolationFrame() { activeCallsigns.add(callsign) // Convert timeline result to InterpolatedAircraftState - const interpolated = timelineToInterpolatedState(timeline, now, orientationEnabled, orientationIntensity) + // The replay flag comes from the same data-source read as `now`, so the frame's data time and flag agree + const isReplay = source.playbackMode !== 'live' + const interpolated = timelineToInterpolatedState(timeline, now, orientationEnabled, orientationIntensity, isReplay) // Reuse existing entry or create new one const existing = statesMap.get(callsign) @@ -546,6 +553,9 @@ function runInterpolationFrame() { existing.isOnGround = interpolated.isOnGround existing.heightAboveGround = interpolated.heightAboveGround existing.inContact = interpolated.inContact + existing.groundBlendWeight = interpolated.groundBlendWeight + existing.groundBlendDescending = interpolated.groundBlendDescending + existing.isReplay = interpolated.isReplay } else { statesMap.set(callsign, interpolated) } @@ -641,6 +651,7 @@ function runInterpolationFrame() { // Determine ground blend weight (0 = fully airborne/reported, 1 = fully on terrain) let groundBlendWeight: number + let groundBlendDescending = false const isLowSpeed = entry.interpolatedGroundspeed < 5 if (isLowSpeed) { @@ -682,6 +693,7 @@ function runInterpolationFrame() { // At LANDING_BLEND_END_AGL (10m): full ground (weight = 1) const blendRange = LANDING_BLEND_START_AGL - LANDING_BLEND_END_AGL groundBlendWeight = Math.min(1.0, Math.max(0, (LANDING_BLEND_START_AGL - altitudeAGL) / blendRange)) + groundBlendDescending = true } else if (altitudeAGL < DEPARTURE_BLEND_END_AGL) { // GROUND/DEPARTURE: Low altitude aircraft - clamp to terrain or blend toward reported // This catches: @@ -710,6 +722,8 @@ function runInterpolationFrame() { if (entry.heightAboveGround === null) { entry.inContact = shouldClampToTerrain } + entry.groundBlendWeight = groundBlendWeight + entry.groundBlendDescending = groundBlendDescending // Reset smoothed terrain height for aircraft that are truly airborne if (groundBlendWeight === 0 && sampledTerrainHeight !== undefined && altitudeAGL > 50) { diff --git a/src/renderer/hooks/useAircraftModels.ts b/src/renderer/hooks/useAircraftModels.ts index 0897c5f5..4227bdb7 100644 --- a/src/renderer/hooks/useAircraftModels.ts +++ b/src/renderer/hooks/useAircraftModels.ts @@ -1,5 +1,12 @@ import * as Cesium from 'cesium' import { useCallback, useEffect, useRef } from 'react' +import { aircraftDimensionsService } from '@/services/AircraftDimensionsService' +import { + clearSurfaceState, + surfaceFrameFor, + updateSurfaceAnimation, + wheelRadiusForWingspan, +} from '@/utils/surfaceAnimationController' import { SUN_ELEVATION_DAY, SUN_ELEVATION_NIGHT } from '../constants/lighting' import { FSLTL_MODEL_HEIGHT_OFFSET, @@ -17,6 +24,7 @@ import type { InterpolatedAircraftState } from '../types/vatsim' import { neutralEnvironmentMapOptions } from '../utils/environmentMap' import { applyGearAnimation, + applySurfaceAnimation, clearGearState, gearHeightAboveGroundMeters, getCurrentGearProgress, @@ -540,6 +548,14 @@ export function useAircraftModels( // Pass known animation count from gltfCallback for reliable animation access const knownAnimCount = modelAnimationCountsRef.current.get(modelInfo.modelUrl) applyGearAnimation(model, gearProgress, aircraft.callsign, modelInfo.modelUrl, knownAnimCount) + + // Spoilers, reversers and wheel spin run on the data-source time of the frame, so a paused replay holds them + const wingspanM = aircraftDimensionsService.getDimensions(aircraft.aircraftType)?.wingspan ?? null + const surfacePose = updateSurfaceAnimation( + aircraft.callsign, + surfaceFrameFor(aircraft, wheelRadiusForWingspan(wingspanM)), + ) + applySurfaceAnimation(model, surfacePose, modelInfo.modelUrl) } } } @@ -549,8 +565,9 @@ export function useAircraftModels( // Use reverse lookup to find stale assignments - O(seenCallsigns) instead of O(poolSize) for (const [callsign, idx] of callsignToPoolIndexRef.current.entries()) { if (!seenCallsigns.has(callsign)) { - // Clean up gear animation state for this aircraft + // Clean up gear, spoiler, reverser and wheel animation state for this aircraft clearGearState(callsign) + clearSurfaceState(callsign) // Release this slot and hide the model modelPoolAssignments.current.set(idx, null) diff --git a/src/renderer/types/vatsim.ts b/src/renderer/types/vatsim.ts index 0ba1ea72..4cc2a497 100644 --- a/src/renderer/types/vatsim.ts +++ b/src/renderer/types/vatsim.ts @@ -235,6 +235,23 @@ export interface InterpolatedAircraftState extends AircraftState { * halfway onto the terrain). Null only before the aircraft's first drawn frame. */ inContact: boolean | null + /** + * How far the drawn height is blended onto the terrain this frame, 0..1 (0 = at the reported height, 1 = on the + * terrain). The spoilers, reversers and wheels read it for sources without a height above ground (`surfaceFrameFor`). + */ + groundBlendWeight: number + /** + * True when this frame's `groundBlendWeight` came from the landing blend (the descending branch: descending below + * 50 m without a height above ground); false for the departure blend, the ground cases, and sources with a height + * above ground. The surfaces read it to tell a landing drawn down onto the terrain from a rollout or taxi reported a + * few metres above it. + */ + groundBlendDescending: boolean + /** + * Whether this frame's data comes from a replay (buffer or imported) rather than live, from the same data-source + * read that set `timestamp`, so the two always agree. + */ + isReplay: boolean /** * Ground acceleration in knots per second. * Positive = accelerating, negative = decelerating. diff --git a/src/renderer/utils/aircraft/flightPhaseDetector.ts b/src/renderer/utils/aircraft/flightPhaseDetector.ts index 7e81fe3e..e9540de5 100644 --- a/src/renderer/utils/aircraft/flightPhaseDetector.ts +++ b/src/renderer/utils/aircraft/flightPhaseDetector.ts @@ -6,6 +6,7 @@ */ import { STOPPED_SPEED_KTS } from '@/constants' +import { isPushingBack } from '@/utils/aircraft/pushback' import { geoidService } from '../../services/GeoidService' import type { AircraftTimeline } from '../../types/aircraft-timeline' import type { Runway } from '../../types/airport' @@ -30,7 +31,6 @@ const PATTERN_DISTANCE_NM = 5 // false positives from low pattern work. Note: AGL is calculated from airport // elevation, not actual terrain - may be inaccurate for sloped terrain. const GROUND_ALTITUDE_AGL_FT = 100 -const PUSHBACK_MAX_SPEED_KTS = 8 // Pushback is typically 2-5 kts const RUNWAY_ALIGNMENT_DEG = 25 // Generous for wind correction/crab angle const HOLDING_SHORT_DISTANCE_FT = 500 const RUNWAY_WIDTH_BUFFER_FT = 150 @@ -648,18 +648,9 @@ function detectRawFlightPhase(aircraft: InterpolatedAircraftState, context: Smar } } - // Check for pushback: moving with track opposite to heading - // Pushback: aircraft faces one direction but moves backwards - // IMPORTANT: Only check pushback if there's actual movement (speed > 0.5 kts) - // When stationary (speed ≈ 0), track defaults to heading, making the diff ≈ 0 - // So stationary aircraft won't falsely trigger this check - if (speedKts > 0.5 && speedKts < PUSHBACK_MAX_SPEED_KTS) { - const trackHeadingDiff = Math.abs(headingDifference(track, heading)) - // If track is 90-180° from heading, aircraft is moving backwards or sideways - // Use 90° threshold to catch curved pushbacks (not just straight back) - if (trackHeadingDiff > 90) { - return { phase: 'pushback', runway: null, runwayDistance: null, nearestRunway: nearestRwy } - } + // Pushback: moving slowly with the track more than 90° from the heading + if (isPushingBack(speedKts, heading, track)) { + return { phase: 'pushback', runway: null, runwayDistance: null, nearestRunway: nearestRwy } } // If moving at all, it's taxi - stopped is the last resort diff --git a/src/renderer/utils/aircraft/pushback.ts b/src/renderer/utils/aircraft/pushback.ts new file mode 100644 index 00000000..22e5fba1 --- /dev/null +++ b/src/renderer/utils/aircraft/pushback.ts @@ -0,0 +1,21 @@ +/** + * Pushback detection, shared by the flight phase detector and the wheel spin. + */ + +import { PUSHBACK_MAX_SPEED_KTS, PUSHBACK_MIN_SPEED_KTS } from '@/constants/flightPhase' +import { headingDifference } from '@/utils/aircraft/geoMath' + +/** + * Whether an aircraft is pushing back: moving between 0.5 kt and pushback speed with its track more than 90° from + * its heading (backwards, or sideways in a curved push). Below 0.5 kt the track defaults to the heading, so a + * standing aircraft never reads as pushing back. + * + * @param speedKts - Groundspeed in knots + * @param headingDeg - Heading in degrees + * @param trackDeg - Ground track (direction of movement) in degrees + * @returns True when moving backwards + */ +export function isPushingBack(speedKts: number, headingDeg: number, trackDeg: number): boolean { + if (speedKts <= PUSHBACK_MIN_SPEED_KTS || speedKts >= PUSHBACK_MAX_SPEED_KTS) return false + return Math.abs(headingDifference(trackDeg, headingDeg)) > 90 +} diff --git a/src/renderer/utils/gearAnimationController.ts b/src/renderer/utils/gearAnimationController.ts index 2d2394f9..2feeafad 100644 --- a/src/renderer/utils/gearAnimationController.ts +++ b/src/renderer/utils/gearAnimationController.ts @@ -31,8 +31,14 @@ import type * as Cesium from 'cesium' import type { GearContact, GearFrameInput } from '@/types' +import { clearAllSurfaceStates, type SurfacePose } from '@/utils/surfaceAnimationController' import { geoidService } from '../services/GeoidService' -import { type AnimationSet, applyGearAnimationsPercent, parseAnimationSetFromUrl } from './gltfAnimationParser' +import { + type AnimationSet, + applyClipAnimationsPercent, + applyWheelSpin, + parseAnimationSetFromUrl, +} from './gltfAnimationParser' /** * Height of an aircraft above the ground, in one datum, for the gear rules. @@ -408,7 +414,25 @@ export function applyGearAnimation( } // Apply gear animations using direct node transforms (with caching by modelUrl) - applyGearAnimationsPercent(model, animSet, progress, modelUrl) + applyClipAnimationsPercent(model, animSet, 'gear', progress, modelUrl) +} + +/** + * Apply spoiler, reverser and wheel-spin clips to a Cesium model, from the animation set applyGearAnimation loaded. + * Spoilers and reversers use the progress cache; wheel spin is uncached. + * + * @param model - Cesium Model instance + * @param pose - Spoiler and reverser deployment and wheel phase (updateSurfaceAnimation) + * @param modelUrl - Model URL + */ +export function applySurfaceAnimation(model: Cesium.Model, pose: SurfacePose, modelUrl: string): void { + const animSet = model.ready ? animationSets.get(modelUrl) : undefined + if (!animSet) { + return // Not ready, or applyGearAnimation is still loading the animation set + } + applyClipAnimationsPercent(model, animSet, 'spoiler', pose.spoiler, modelUrl) + applyClipAnimationsPercent(model, animSet, 'reverser', pose.reverser, modelUrl) + applyWheelSpin(model, animSet, pose.wheelPhase) } /** @@ -430,6 +454,7 @@ export function clearGearState(callsign: string): void { */ export function clearAllGearStates(): void { gearStates.clear() + clearAllSurfaceStates() animationSets.clear() animationSetsLoading.clear() } diff --git a/src/renderer/utils/gltfAnimationParser.ts b/src/renderer/utils/gltfAnimationParser.ts index a3e40790..ef877c67 100644 --- a/src/renderer/utils/gltfAnimationParser.ts +++ b/src/renderer/utils/gltfAnimationParser.ts @@ -25,7 +25,15 @@ export type { NodeData, } from './gltf' -import type { Animation, AnimationSet, AnimationTrack, ModelGroundData, ModelWingData, NodeData } from './gltf' +import type { + Animation, + AnimationKey, + AnimationSet, + AnimationTrack, + ModelGroundData, + ModelWingData, + NodeData, +} from './gltf' import { computeMinYAtGearState, @@ -50,28 +58,31 @@ const pendingParses = new Map>() const failedAnimationUrls = new Set() // ============================================================================= -// GEAR ANIMATION TRANSFORM CACHE +// CLIP TRANSFORM CACHE (gear, spoilers, reversers) // ============================================================================= // Cache computed node transforms at discrete progress intervals to avoid // expensive quaternion slerp and matrix operations every frame. -// Since gear moves slowly, snapping to nearest 2% interval is imperceptible. +// Since gear, spoilers and reversers move slowly, snapping to nearest 2% interval is imperceptible. +// Wheel spin is not cached: 2% of a revolution is a visible step at taxi speed. /** Number of cached progress steps (50 = 2% intervals from 0.0 to 1.0) */ const GEAR_CACHE_STEPS = 50 -/** Gear progress step size (0.02 = 2%) */ +/** Progress step size (0.02 = 2%) */ const GEAR_CACHE_STEP_SIZE = 1.0 / GEAR_CACHE_STEPS -/** Cached gear transforms: Map>> */ +/** One node's cached pose delta */ interface CachedNodeTransform { translation: { x: number; y: number; z: number } rotation: { x: number; y: number; z: number; w: number } scale: { x: number; y: number; z: number } } -const gearTransformCache = new Map>>() + +/** Cached transforms: Map>>> */ +const clipTransformCache = new Map>>>() /** - * Get the nearest cached progress index for a gear progress value + * Get the nearest cached progress index for a progress value */ function getProgressCacheIndex(progress: number): number { return Math.round(progress / GEAR_CACHE_STEP_SIZE) @@ -307,397 +318,435 @@ function parseAnimations(gltfJson: any, binData: ArrayBuffer): Animation[] { // Animation Application (Cesium-specific) // ============================================================================= +/** A node's pose relative to its rest pose, as a clip's keys give it at one time */ +interface NodePoseDelta { + translation: Cesium.Cartesian3 + rotation: Cesium.Quaternion + scale: Cesium.Cartesian3 +} + +/** A model node with the matrix it had before any clip was applied */ +type PosedNode = Cesium.ModelNode & { _originalMatrix?: Cesium.Matrix4 } + +/** Linear interpolation of Vec3 keys at a time, or null for a track without keys */ +function vec3AtTime(keys: AnimationKey[], time: number): Cesium.Cartesian3 | null { + const pair = getKeysAtTime(keys, time) + if (!pair) return null + const [k0, k1] = pair + const start = new Cesium.Cartesian3(k0.value[0], k0.value[1], k0.value[2]) + if (k0.time === k1.time) return start + const end = new Cesium.Cartesian3(k1.value[0], k1.value[1], k1.value[2]) + return Cesium.Cartesian3.lerp(start, end, (time - k0.time) / (k1.time - k0.time), new Cesium.Cartesian3()) +} + +/** Translation from the node's rest translation, in the node's local space */ +function translationDelta(track: AnimationTrack, nodeData: NodeData, time: number): Cesium.Cartesian3 { + const value = vec3AtTime(track.translationKeys, time) + if (!value) return new Cesium.Cartesian3(0, 0, 0) + const delta = new Cesium.Cartesian3( + value.x - nodeData.translation[0], + value.y - nodeData.translation[1], + value.z - nodeData.translation[2], + ) + return Cesium.Matrix3.multiplyByVector(nodeData.invRotationMatrix, delta, delta) +} + +/** Scale relative to the node's rest scale */ +function scaleDelta(track: AnimationTrack, nodeData: NodeData, time: number): Cesium.Cartesian3 { + const value = vec3AtTime(track.scaleKeys, time) + if (!value) return new Cesium.Cartesian3(1, 1, 1) + return new Cesium.Cartesian3(value.x / nodeData.scale[0], value.y / nodeData.scale[1], value.z / nodeData.scale[2]) +} + +/** Rotation from the node's rest rotation, in the node's local space */ +function rotationDelta(track: AnimationTrack, nodeData: NodeData, time: number): Cesium.Quaternion { + const pair = getKeysAtTime(track.rotationKeys, time) + if (!pair) return new Cesium.Quaternion(0, 0, 0, 1) + const [k0, k1] = pair + let result = new Cesium.Quaternion(k0.value[0], k0.value[1], k0.value[2], k0.value[3]) + if (k0.time !== k1.time) { + const end = new Cesium.Quaternion(k1.value[0], k1.value[1], k1.value[2], k1.value[3]) + result = Cesium.Quaternion.slerp(result, end, (time - k0.time) / (k1.time - k0.time), new Cesium.Quaternion()) + } + + const resultAxis = new Cesium.Cartesian3(1, 0, 0) + const resultAngle = Cesium.Quaternion.computeAngle(result) + if (Math.abs(resultAngle) > Cesium.Math.EPSILON5) { + Cesium.Quaternion.computeAxis(result, resultAxis) + } + Cesium.Matrix3.multiplyByVector(nodeData.invRotationMatrix, resultAxis, resultAxis) + Cesium.Quaternion.fromAxisAngle(resultAxis, resultAngle, result) + return Cesium.Quaternion.multiply(result, nodeData.invRotation, new Cesium.Quaternion()) +} + +/** Set a node's matrix to its original matrix times a pose delta */ +function setNodePose(node: PosedNode, pose: NodePoseDelta): void { + if (!node._originalMatrix) { + node._originalMatrix = node.matrix.clone() + } + const transformMatrix = Cesium.Matrix4.fromTranslationQuaternionRotationScale( + pose.translation, + pose.rotation, + pose.scale, + new Cesium.Matrix4(), + ) + node.matrix = Cesium.Matrix4.multiply(node._originalMatrix, transformMatrix, new Cesium.Matrix4()) +} + +/** The motion that drives an animation clip */ +export type AnimationClipClass = 'gear' | 'spoiler' | 'reverser' | 'wheelSpin' + +/** A clip-name suffix of per-model numbers, as FSLTL `custom_anim_*` names carry (`_09`, `_1_08`) */ +const NUMBERED = '(?:_\\d+)+' + +/** Clip names that look like a class but are not driven: steering, wheel RPM, tyre contact points, flap swings */ +const EXCLUDED_CLIP_PATTERNS: RegExp[] = [ + /STEER/i, + /^custom_anim_C_WHEEL_LR$/i, + /^c_wheel(?:_key)?$/i, + new RegExp(`^custom_anim_ROTATION_VELOCITY_BODY_Y${NUMBERED}$`, 'i'), + new RegExp(`^custom_anim_(?:LEFT|RIGHT|CENTER)_WHEEL_RPM${NUMBERED}$`, 'i'), + /^fx_tire_contactPoint_/i, + new RegExp(`^custom_anim_GROUND_VELOCITY${NUMBERED}$`, 'i'), + /^spoiler_UTT787_aileron$/i, +] + +/** Ground spoilers and speedbrakes */ +const SPOILER_CLIP_PATTERNS: RegExp[] = [ + new RegExp(`^custom_anim_SPOILERS_(?:LEFT|RIGHT)_POSITION${NUMBERED}$`, 'i'), + /^[lr]_spoiler_key$/i, + /^SPO(?:IL|LI)ERS(?:_(?:left|right))?$/i, + /^HANDLING_Spoilers_(?:Ground|l|r)$/i, + /^[\w-]+_Spoiler_(?:L|R|LR)\d*$/i, + /^Spoiler_[LR]\d+(?:[LR]\d+)?$/i, + /^spoiler_Q_new$/i, + /^TFS_777_Spoiler_(?:Inner_[LR]|Out_(?:L|R|LR)[\d-]+)$/i, + /^TFS_A380_spoiler_(?:inner|[LR]_outer)$/i, + /^UTT_787_spoiler_[lr]_(?:in|out\d)$/i, + /^AIA_Fokker_TailSpeedBrake$/i, +] + +/** Thrust reverser sleeves and buckets */ +const REVERSER_CLIP_PATTERNS: RegExp[] = [ + /^custom_anim_REV_THRUST$/i, + /^reverse_thrust$/i, + /^REVERSERS?$/i, + /^thrust_rev_\d+$/i, + /^engine_rev$/i, + new RegExp(`^custom_anim_TURB_ENG_REVERSE_NOZZLE_PERCENT${NUMBERED}$`, 'i'), +] + +/** Wheel rotation about the axle */ +const WHEEL_SPIN_CLIP_PATTERNS: RegExp[] = [ + new RegExp(`^custom_anim_(?:CENTER|LEFT|RIGHT)_WHEEL_ROTATION_ANGLE${NUMBERED}$`, 'i'), + /^WHEEL_ROLL_(?:center|left|right)$/i, + /^[clr]_tire(?:_anim|_blurred_key|_still_key|_roll|_key)?$/i, + /^center_tire_roll$/i, + /^tire_anim$/i, + /^GEAR_(?:Center|Left|Right)_Tire$/i, +] + +const matchesAny = (patterns: RegExp[], name: string): boolean => patterns.some((p) => p.test(name)) + /** - * Apply animation at a specific percent (0-1) to a Cesium Model + * The motion that drives an animation clip, from its name. Spoiler, reverser and wheel-spin clips come from a checked + * allowlist of the names in the converted FSLTL and AIG models; every other clip with GEAR in its name is gear. + * Steering clips (GEAR_STEER, custom_anim_C_WHEEL_LR and the like) and the near misses in EXCLUDED_CLIP_PATTERNS are + * driven by nothing. * - * @param model - Cesium Model primitive - * @param animationSet - Parsed animation set - * @param animationName - Name of animation to apply (or substring match) - * @param percent - Animation progress (0 = start, 1 = end) + * @param name - The glTF animation name + * @returns The clip's class, or null for a clip nothing drives */ -export function applyAnimationPercent( - model: Cesium.Model, - animationSet: AnimationSet, - animationName: string, - percent: number, -): boolean { - // Find matching animation - const animation = animationSet.animations.find((a) => a.name.toUpperCase().includes(animationName.toUpperCase())) +export function classifyAnimationClip(name: string): AnimationClipClass | null { + if (matchesAny(EXCLUDED_CLIP_PATTERNS, name)) return null + if (matchesAny(WHEEL_SPIN_CLIP_PATTERNS, name)) return 'wheelSpin' + if (matchesAny(SPOILER_CLIP_PATTERNS, name)) return 'spoiler' + if (matchesAny(REVERSER_CLIP_PATTERNS, name)) return 'reverser' + return /GEAR/i.test(name) ? 'gear' : null +} - if (!animation) { - return false - } +/** + * Whether gear progress drives this animation clip. + * MSFS models name nose-wheel steering clips with GEAR too (GEAR_STEER and the like), and some name wheel spins + * GEAR_*_Tire; neither is driven by gear progress. + * + * @param name - The glTF animation name + * @returns True for gear extension/retraction clips + */ +export function isGearClip(name: string): boolean { + return classifyAnimationClip(name) === 'gear' +} - // Clamp percent - const clampedPercent = Math.max(0, Math.min(1, percent)) - const targetTime = animation.duration * clampedPercent +/** A clip class driven by a progress from 0 to 1, through the progress cache */ +export type ProgressClipClass = Exclude - // Apply transforms to each track - for (const [nodeName, track] of animation.tracks) { - const nodeData = animationSet.nodes.get(nodeName) - if (!nodeData) continue +/** Degrees a wheel clip may miss one revolution by, step off its axis by, or come within of a half-turn step */ +const WHEEL_REVOLUTION_TOLERANCE_DEG = 4 - // Get Cesium model node - const node = model.getNode(nodeName) - if (!node) continue +/** A rotation step smaller than this, in radians, is a held key (float32 noise on a repeated key is far below it) */ +const HELD_KEY_RADIANS = 1e-5 - // Store original matrix if not already stored - // biome-ignore lint/suspicious/noExplicitAny: glTF JSON parsing - const nodeWithOriginal = node as any - if (!nodeWithOriginal._originalMatrix) { - nodeWithOriginal._originalMatrix = node.matrix.clone() - } - const originalMatrix = nodeWithOriginal._originalMatrix as Cesium.Matrix4 - - // Interpolate translation - let translation = new Cesium.Cartesian3(0, 0, 0) - const transKeys = getKeysAtTime(track.translationKeys, targetTime) - if (transKeys) { - const [k0, k1] = transKeys - if (k0.time === k1.time) { - translation = new Cesium.Cartesian3( - k0.value[0] - nodeData.translation[0], - k0.value[1] - nodeData.translation[1], - k0.value[2] - nodeData.translation[2], - ) - } else { - const t = (targetTime - k0.time) / (k1.time - k0.time) - const start = new Cesium.Cartesian3(k0.value[0], k0.value[1], k0.value[2]) - const end = new Cesium.Cartesian3(k1.value[0], k1.value[1], k1.value[2]) - const lerped = Cesium.Cartesian3.lerp(start, end, t, new Cesium.Cartesian3()) - translation = new Cesium.Cartesian3( - lerped.x - nodeData.translation[0], - lerped.y - nodeData.translation[1], - lerped.z - nodeData.translation[2], - ) - } - // Transform to local node space - Cesium.Matrix3.multiplyByVector(nodeData.invRotationMatrix, translation, translation) +/** Clips per class for each parsed animation set; wheel-spin clips only when they make one revolution */ +const clipsByClass = new WeakMap>() + +/** + * The clips of one class in an animation set, classified once per set. Wheel-spin clips that do not make one + * revolution (isOneRevolutionClip) are left out. + */ +function clipsOfClass(animationSet: AnimationSet, clipClass: AnimationClipClass): Animation[] { + let byClass = clipsByClass.get(animationSet) + if (!byClass) { + byClass = new Map() + for (const animation of animationSet.animations) { + const found = classifyAnimationClip(animation.name) + if (found === null || (found === 'wheelSpin' && !isOneRevolutionClip(animation))) continue + byClass.set(found, [...(byClass.get(found) ?? []), animation]) } + dropWheelClipsOnGearNodes(byClass) + clipsByClass.set(animationSet, byClass) + } + return byClass.get(clipClass) ?? [] +} - // Interpolate rotation - const rotation = new Cesium.Quaternion(0, 0, 0, 1) - const rotKeys = getKeysAtTime(track.rotationKeys, targetTime) - if (rotKeys) { - const [k0, k1] = rotKeys - let result: Cesium.Quaternion - if (k0.time === k1.time) { - result = new Cesium.Quaternion(k0.value[0], k0.value[1], k0.value[2], k0.value[3]) - } else { - const t = (targetTime - k0.time) / (k1.time - k0.time) - const start = new Cesium.Quaternion(k0.value[0], k0.value[1], k0.value[2], k0.value[3]) - const end = new Cesium.Quaternion(k1.value[0], k1.value[1], k1.value[2], k1.value[3]) - result = Cesium.Quaternion.slerp(start, end, t, new Cesium.Quaternion()) - } +/** + * Leave out wheel-spin clips that target a node a gear clip of the same model moves: the two would overwrite each + * other's pose. The converted FSLTL and AIG models have none; a model that does is logged once. + */ +function dropWheelClipsOnGearNodes(byClass: Map): void { + const gearNodes = new Set((byClass.get('gear') ?? []).flatMap((a) => [...a.tracks.keys()])) + const wheels = byClass.get('wheelSpin') ?? [] + const shared = wheels.filter((a) => [...a.tracks.keys()].some((node) => gearNodes.has(node))) + if (shared.length === 0) return + byClass.set( + 'wheelSpin', + wheels.filter((a) => !shared.includes(a)), + ) + console.warn(`[AnimParser] Not spinning wheel clips that move a gear node: ${shared.map((a) => a.name).join(', ')}`) +} - // Transform to local node space and compute delta from original - const resultAxis = new Cesium.Cartesian3(1, 0, 0) - const resultAngle = Cesium.Quaternion.computeAngle(result) - if (Math.abs(resultAngle) > Cesium.Math.EPSILON5) { - Cesium.Quaternion.computeAxis(result, resultAxis) - } - Cesium.Matrix3.multiplyByVector(nodeData.invRotationMatrix, resultAxis, resultAxis) - Cesium.Quaternion.fromAxisAngle(resultAxis, resultAngle, result) - Cesium.Quaternion.multiply(result, nodeData.invRotation, rotation) - } +/** First and last key time over every track of a clip; [0, duration] for a clip without keys */ +function clipKeyRange(animation: Animation): { start: number; end: number } { + let range = keyRanges.get(animation) + if (!range) { + range = computeClipKeyRange(animation) + keyRanges.set(animation, range) + } + return range +} - // Interpolate scale - let scale = new Cesium.Cartesian3(1, 1, 1) - const scaleKeys = getKeysAtTime(track.scaleKeys, targetTime) - if (scaleKeys) { - const [k0, k1] = scaleKeys - if (k0.time === k1.time) { - scale = new Cesium.Cartesian3( - k0.value[0] / nodeData.scale[0], - k0.value[1] / nodeData.scale[1], - k0.value[2] / nodeData.scale[2], - ) - } else { - const t = (targetTime - k0.time) / (k1.time - k0.time) - const start = new Cesium.Cartesian3(k0.value[0], k0.value[1], k0.value[2]) - const end = new Cesium.Cartesian3(k1.value[0], k1.value[1], k1.value[2]) - const lerped = Cesium.Cartesian3.lerp(start, end, t, new Cesium.Cartesian3()) - scale = new Cesium.Cartesian3( - lerped.x / nodeData.scale[0], - lerped.y / nodeData.scale[1], - lerped.z / nodeData.scale[2], - ) - } +/** Key ranges per clip, computed once */ +const keyRanges = new WeakMap() + +function computeClipKeyRange(animation: Animation): { start: number; end: number } { + let start = Number.POSITIVE_INFINITY + let end = Number.NEGATIVE_INFINITY + for (const track of animation.tracks.values()) { + for (const keys of [track.translationKeys, track.rotationKeys, track.scaleKeys]) { + if (keys.length === 0) continue + start = Math.min(start, keys[0].time) + end = Math.max(end, keys[keys.length - 1].time) } + } + return start <= end ? { start, end } : { start: 0, end: animation.duration } +} - // Build transform matrix and apply to node - const transformMatrix = Cesium.Matrix4.fromTranslationQuaternionRotationScale( - translation, - rotation, - scale, - new Cesium.Matrix4(), - ) +/** + * The clip time for a progress, over the clip's own key range: some clips start mid-timeline (E170/E190 at 4.17 s, + * ATR 72 wheels at 100 s), so progress 0 is the first key, not time 0. + * + * @param animation - The clip + * @param percent - Progress, clamped to 0..1 + * @returns Clip time in seconds + */ +export function clipTimeAtPercent(animation: Animation, percent: number): number { + const { start, end } = clipKeyRange(animation) + return start + (end - start) * Math.max(0, Math.min(1, percent)) +} - node.matrix = Cesium.Matrix4.multiply(originalMatrix, transformMatrix, new Cesium.Matrix4()) - } +/** The rotation from quaternion a to quaternion b, as an angle in radians (0..π, the shortest way) and a unit axis */ +function rotationStep(a: number[], b: number[]): { angle: number; axis: [number, number, number] } { + const [ax, ay, az, aw] = [-a[0], -a[1], -a[2], a[3]] + const [bx, by, bz, bw] = b + let w = aw * bw - ax * bx - ay * by - az * bz + let x = aw * bx + ax * bw + ay * bz - az * by + let y = aw * by - ax * bz + ay * bw + az * bx + let z = aw * bz + ax * by - ay * bx + az * bw + if (w < 0) [w, x, y, z] = [-w, -x, -y, -z] + const sinHalf = Math.hypot(x, y, z) + const angle = 2 * Math.atan2(sinHalf, w) + return { angle, axis: sinHalf > 0 ? [x / sinHalf, y / sinHalf, z / sinHalf] : [1, 0, 0] } +} - return true +/** Whether rotation keys turn one revolution, every step the same way about one axis and short of a half-turn */ +function turnsOnceAboutOneAxis(keys: AnimationKey[]): boolean { + const tolerance = Cesium.Math.toRadians(WHEEL_REVOLUTION_TOLERANCE_DEG) + let axis: [number, number, number] | null = null + let total = 0 + for (let i = 1; i < keys.length; i++) { + const step = rotationStep(keys[i - 1].value, keys[i].value) + if (step.angle < HELD_KEY_RADIANS) continue + if (step.angle > Math.PI - tolerance) return false + axis ??= step.axis + const alignment = step.axis[0] * axis[0] + step.axis[1] * axis[1] + step.axis[2] * axis[2] + if (alignment < Math.cos(tolerance)) return false + total += step.angle + } + return Math.abs(total - 2 * Math.PI) <= tolerance } /** - * Whether gear progress drives this animation clip. - * MSFS models name nose-wheel steering clips with GEAR too (GEAR_STEER and the like); - * driving those by gear progress turns the nose wheel, so STEER clips are excluded. + * Whether a wheel clip can be driven by a spin phase: every rotation track turns one revolution, monotonic about one + * axis, within WHEEL_REVOLUTION_TOLERANCE_DEG (4°) of 360°, with no step of a half-turn or more. Clips that reverse + * partway, stop short of a turn or step 180° (where the direction between keys is undefined) fail. * - * @param name - The glTF animation name - * @returns True for gear extension/retraction clips + * @param animation - A wheel-spin clip + * @returns True when the clip makes one revolution */ -export function isGearClip(name: string): boolean { - const upper = name.toUpperCase() - return upper.includes('GEAR') && !upper.includes('STEER') +export function isOneRevolutionClip(animation: Animation): boolean { + const rotating = [...animation.tracks.values()].filter((t) => t.rotationKeys.length >= 2) + return rotating.length > 0 && rotating.every((t) => turnsOnceAboutOneAxis(t.rotationKeys)) +} + +/** The progress cache of one model URL and clip class */ +function progressCacheFor( + modelUrl: string, + clipClass: ProgressClipClass, +): Map> { + let urlCache = clipTransformCache.get(modelUrl) + if (!urlCache) { + urlCache = new Map() + clipTransformCache.set(modelUrl, urlCache) + } + let classCache = urlCache.get(clipClass) + if (!classCache) { + classCache = new Map() + urlCache.set(clipClass, classCache) + } + return classCache } /** - * Apply gear animations to a model at a specific percent - * Applies every animation isGearClip accepts + * Apply every clip of one class (gear, spoiler or reverser) to a model at a progress. * - * Uses cached transforms at discrete 2% intervals to avoid expensive - * quaternion slerp and matrix operations every frame. + * Uses transforms cached per model URL and class at discrete 2% intervals, to avoid quaternion slerp and matrix + * operations every frame. * * @param model - Cesium Model primitive * @param animationSet - Parsed animation set - * @param percent - Gear progress (0 = retracted, 1 = extended) - * @param modelUrl - Model URL for cache lookup (optional, enables caching) - * @returns Number of gear animations applied + * @param clipClass - The class of clips to apply + * @param percent - Progress (0 = stowed or retracted, 1 = deployed or extended) + * @param modelUrl - Model URL, the cache key + * @returns Number of nodes the pose covers (written now, or already in place from the last frame at the same 2% step); + * 0 when the model has no node of this class */ -export function applyGearAnimationsPercent( +export function applyClipAnimationsPercent( model: Cesium.Model, animationSet: AnimationSet, + clipClass: ProgressClipClass, percent: number, - modelUrl?: string, + modelUrl: string, ): number { - let appliedCount = 0 - - // Snap to nearest cached progress index (2% intervals) - const cacheIndex = getProgressCacheIndex(percent) - const snappedPercent = cacheIndex * GEAR_CACHE_STEP_SIZE - - // Try to use cached transforms if modelUrl is provided - if (modelUrl) { - let urlCache = gearTransformCache.get(modelUrl) - if (!urlCache) { - urlCache = new Map() - gearTransformCache.set(modelUrl, urlCache) - } + const cacheIndex = getProgressCacheIndex(Math.max(0, Math.min(1, percent))) + const classCache = progressCacheFor(modelUrl, clipClass) + const cached = classCache.get(cacheIndex) + const lastByClass = lastAppliedIndex.get(model) ?? new Map() + lastAppliedIndex.set(model, lastByClass) + if (cached && lastByClass.get(clipClass) === cacheIndex) { + return cached.size + } + lastByClass.set(clipClass, cacheIndex) + if (cached) { + return applyCachedTransforms(model, animationSet, cached) + } - let progressCache = urlCache.get(cacheIndex) - if (progressCache) { - // Apply cached transforms directly - much faster than recomputing - return applyCachedGearTransforms(model, animationSet, progressCache) - } + const progressCache = new Map() + for (const animation of clipsOfClass(animationSet, clipClass)) { + const time = clipTimeAtPercent(animation, cacheIndex * GEAR_CACHE_STEP_SIZE) + applyClipAtTime(model, animationSet, animation, time, progressCache) + } + classCache.set(cacheIndex, progressCache) + return progressCache.size +} - // No cache for this progress - compute and cache - progressCache = new Map() +/** The cache index last applied per model and clip class, so a pose held between frames is not rewritten */ +const lastAppliedIndex = new WeakMap>() - for (const animation of animationSet.animations) { - if (isGearClip(animation.name)) { - const success = applyAnimationPercentWithCache( - model, - animationSet, - animation.name, - snappedPercent, - progressCache, - ) - if (success) appliedCount++ - } - } +/** The wheel phase last applied per model, so parked or held wheels are not rewritten */ +const lastWheelTurn = new WeakMap() - // Store computed transforms in cache - urlCache.set(cacheIndex, progressCache) - return appliedCount +/** + * Turn every one-revolution wheel clip of a model to a spin phase, uncached so the wheel turns smoothly at taxi speed. + * + * @param model - Cesium Model primitive + * @param animationSet - Parsed animation set + * @param phase - Wheel phase in revolutions; only the fraction counts + * @returns Number of wheel clips applied + */ +export function applyWheelSpin(model: Cesium.Model, animationSet: AnimationSet, phase: number): number { + const turn = phase - Math.floor(phase) + const clips = clipsOfClass(animationSet, 'wheelSpin') + if (lastWheelTurn.get(model) === turn) { + return clips.length } - - // No URL provided - fall back to non-cached path - for (const animation of animationSet.animations) { - if (isGearClip(animation.name)) { - const success = applyAnimationPercent(model, animationSet, animation.name, snappedPercent) - if (success) appliedCount++ - } + lastWheelTurn.set(model, turn) + for (const animation of clips) { + applyClipAtTime(model, animationSet, animation, clipTimeAtPercent(animation, turn), null) } - - return appliedCount + return clips.length } /** * Apply cached transforms directly to model nodes - O(nodes) with minimal computation */ -function applyCachedGearTransforms( +function applyCachedTransforms( model: Cesium.Model, animationSet: AnimationSet, cache: Map, ): number { let appliedCount = 0 - for (const [nodeName, transform] of cache) { - const nodeData = animationSet.nodes.get(nodeName) - if (!nodeData) continue - + if (!animationSet.nodes.has(nodeName)) continue const node = model.getNode(nodeName) if (!node) continue - - // Store original matrix if not already stored - // biome-ignore lint/suspicious/noExplicitAny: glTF JSON parsing - const nodeWithOriginal = node as any - if (!nodeWithOriginal._originalMatrix) { - nodeWithOriginal._originalMatrix = node.matrix.clone() - } - const originalMatrix = nodeWithOriginal._originalMatrix as Cesium.Matrix4 - - // Build transform from cached values - const translation = new Cesium.Cartesian3(transform.translation.x, transform.translation.y, transform.translation.z) - const rotation = new Cesium.Quaternion( - transform.rotation.x, - transform.rotation.y, - transform.rotation.z, - transform.rotation.w, - ) - const scale = new Cesium.Cartesian3(transform.scale.x, transform.scale.y, transform.scale.z) - - const transformMatrix = Cesium.Matrix4.fromTranslationQuaternionRotationScale( - translation, - rotation, - scale, - new Cesium.Matrix4(), - ) - - node.matrix = Cesium.Matrix4.multiply(originalMatrix, transformMatrix, new Cesium.Matrix4()) + const { translation: t, rotation: r, scale: s } = transform + setNodePose(node, { + translation: new Cesium.Cartesian3(t.x, t.y, t.z), + rotation: new Cesium.Quaternion(r.x, r.y, r.z, r.w), + scale: new Cesium.Cartesian3(s.x, s.y, s.z), + }) appliedCount++ } - return appliedCount } /** - * Apply animation and cache the computed transforms for reuse + * Pose every node a clip animates at a clip time, recording each pose in the cache when one is given */ -function applyAnimationPercentWithCache( +function applyClipAtTime( model: Cesium.Model, animationSet: AnimationSet, - animationName: string, - percent: number, - cache: Map, -): boolean { - const animation = animationSet.animations.find((a) => a.name.toUpperCase().includes(animationName.toUpperCase())) - - if (!animation) { - return false - } - - const clampedPercent = Math.max(0, Math.min(1, percent)) - const targetTime = animation.duration * clampedPercent - + animation: Animation, + time: number, + cache: Map | null, +): void { for (const [nodeName, track] of animation.tracks) { const nodeData = animationSet.nodes.get(nodeName) if (!nodeData) continue - const node = model.getNode(nodeName) if (!node) continue - - // biome-ignore lint/suspicious/noExplicitAny: glTF JSON parsing - const nodeWithOriginal = node as any - if (!nodeWithOriginal._originalMatrix) { - nodeWithOriginal._originalMatrix = node.matrix.clone() + const pose: NodePoseDelta = { + translation: translationDelta(track, nodeData, time), + rotation: rotationDelta(track, nodeData, time), + scale: scaleDelta(track, nodeData, time), } - const originalMatrix = nodeWithOriginal._originalMatrix as Cesium.Matrix4 - - // Interpolate translation - let translation = new Cesium.Cartesian3(0, 0, 0) - const transKeys = getKeysAtTime(track.translationKeys, targetTime) - if (transKeys) { - const [k0, k1] = transKeys - if (k0.time === k1.time) { - translation = new Cesium.Cartesian3( - k0.value[0] - nodeData.translation[0], - k0.value[1] - nodeData.translation[1], - k0.value[2] - nodeData.translation[2], - ) - } else { - const t = (targetTime - k0.time) / (k1.time - k0.time) - const start = new Cesium.Cartesian3(k0.value[0], k0.value[1], k0.value[2]) - const end = new Cesium.Cartesian3(k1.value[0], k1.value[1], k1.value[2]) - const lerped = Cesium.Cartesian3.lerp(start, end, t, new Cesium.Cartesian3()) - translation = new Cesium.Cartesian3( - lerped.x - nodeData.translation[0], - lerped.y - nodeData.translation[1], - lerped.z - nodeData.translation[2], - ) - } - Cesium.Matrix3.multiplyByVector(nodeData.invRotationMatrix, translation, translation) - } - - // Interpolate rotation - const rotation = new Cesium.Quaternion(0, 0, 0, 1) - const rotKeys = getKeysAtTime(track.rotationKeys, targetTime) - if (rotKeys) { - const [k0, k1] = rotKeys - let result: Cesium.Quaternion - if (k0.time === k1.time) { - result = new Cesium.Quaternion(k0.value[0], k0.value[1], k0.value[2], k0.value[3]) - } else { - const t = (targetTime - k0.time) / (k1.time - k0.time) - const start = new Cesium.Quaternion(k0.value[0], k0.value[1], k0.value[2], k0.value[3]) - const end = new Cesium.Quaternion(k1.value[0], k1.value[1], k1.value[2], k1.value[3]) - result = Cesium.Quaternion.slerp(start, end, t, new Cesium.Quaternion()) - } - - const resultAxis = new Cesium.Cartesian3(1, 0, 0) - const resultAngle = Cesium.Quaternion.computeAngle(result) - if (Math.abs(resultAngle) > Cesium.Math.EPSILON5) { - Cesium.Quaternion.computeAxis(result, resultAxis) - } - Cesium.Matrix3.multiplyByVector(nodeData.invRotationMatrix, resultAxis, resultAxis) - Cesium.Quaternion.fromAxisAngle(resultAxis, resultAngle, result) - Cesium.Quaternion.multiply(result, nodeData.invRotation, rotation) - } - - // Interpolate scale - let scale = new Cesium.Cartesian3(1, 1, 1) - const scaleKeys = getKeysAtTime(track.scaleKeys, targetTime) - if (scaleKeys) { - const [k0, k1] = scaleKeys - if (k0.time === k1.time) { - scale = new Cesium.Cartesian3( - k0.value[0] / nodeData.scale[0], - k0.value[1] / nodeData.scale[1], - k0.value[2] / nodeData.scale[2], - ) - } else { - const t = (targetTime - k0.time) / (k1.time - k0.time) - const start = new Cesium.Cartesian3(k0.value[0], k0.value[1], k0.value[2]) - const end = new Cesium.Cartesian3(k1.value[0], k1.value[1], k1.value[2]) - const lerped = Cesium.Cartesian3.lerp(start, end, t, new Cesium.Cartesian3()) - scale = new Cesium.Cartesian3( - lerped.x / nodeData.scale[0], - lerped.y / nodeData.scale[1], - lerped.z / nodeData.scale[2], - ) - } - } - - // Cache the computed transform values (as plain objects, not Cesium types) - cache.set(nodeName, { - translation: { x: translation.x, y: translation.y, z: translation.z }, - rotation: { x: rotation.x, y: rotation.y, z: rotation.z, w: rotation.w }, - scale: { x: scale.x, y: scale.y, z: scale.z }, + const { translation: t, rotation: r, scale: s } = pose + cache?.set(nodeName, { + translation: { x: t.x, y: t.y, z: t.z }, + rotation: { x: r.x, y: r.y, z: r.z, w: r.w }, + scale: { x: s.x, y: s.y, z: s.z }, }) - - // Build transform matrix and apply to node - const transformMatrix = Cesium.Matrix4.fromTranslationQuaternionRotationScale( - translation, - rotation, - scale, - new Cesium.Matrix4(), - ) - - node.matrix = Cesium.Matrix4.multiply(originalMatrix, transformMatrix, new Cesium.Matrix4()) + setNodePose(node, pose) } - - return true } // ============================================================================= @@ -712,7 +761,7 @@ export function clearAnimationCache(url?: string): void { animationSetCache.delete(url) groundDataCache.delete(url) wingDataCache.delete(url) - gearTransformCache.delete(url) + clipTransformCache.delete(url) failedGroundDataUrls.delete(url) failedWingDataUrls.delete(url) failedAnimationUrls.delete(url) @@ -720,7 +769,7 @@ export function clearAnimationCache(url?: string): void { animationSetCache.clear() groundDataCache.clear() wingDataCache.clear() - gearTransformCache.clear() + clipTransformCache.clear() failedGroundDataUrls.clear() failedWingDataUrls.clear() failedAnimationUrls.clear() diff --git a/src/renderer/utils/surfaceAnimationController.ts b/src/renderer/utils/surfaceAnimationController.ts new file mode 100644 index 00000000..90d7fa49 --- /dev/null +++ b/src/renderer/utils/surfaceAnimationController.ts @@ -0,0 +1,432 @@ +/** + * Spoiler, Reverser and Wheel-Spin Controller + * + * Decides, per aircraft and per frame, how far the spoilers and thrust reversers are deployed and how far the wheels + * have turned. Pure state: applying the result to a model is `applySurfaceAnimation` in gearAnimationController.ts. + * + * ## Rules + * - Spoilers deploy at a touchdown above 60 kt and stay out while in contact above 60 kt. + * - Reversers deploy from 1.5 s after the touchdown while in contact, above 60 kt and decelerating (the groundspeed + * fell over the last 0.5 s of data time, measured here, so slow frames and fast replay still see it), and once out + * stay out until the rollout ends. + * - Contact is the shared contact, except that a source without a height above ground being drawn down by the landing + * blend is in contact only once drawn fully onto the terrain (so a VATSIM arrival deploys nothing in the air); in + * the departure blend and the ground cases the shared contact stands (so a rollout or taxi reported a few metres + * above the terrain, first seen or seeked onto, keeps it). + * - Both stow on contact loss and when the rollout ends: at 60 kt, or when the groundspeed rises 10 kt above its + * minimum since touchdown (a touch-and-go). A touchdown is a contact after at least 2 s out of contact, so a contact + * flicker on the rollout is not a second touchdown; an aircraft first seen out of contact counts as airborne long + * enough, and one first seen in contact (spawned on the rollout, or rolling for takeoff) deploys nothing. + * - Wheels turn at groundspeed over the wheel circumference in contact, backwards in a pushback, and coast linearly to + * zero over 5 s after liftoff. + * + * ## Time base + * Everything runs on the data-source time of the frame (`aircraft.timestamp`), so a paused replay stops the wheels and + * holds a reverser mid-travel. A seek snaps the spoilers and reversers to their targets and keeps the wheel phase: a + * data-time step backwards, in replay a step forwards by more than 5 s, or a switch between replay and live either + * way. Live, a longer gap just continues. The gear's own transition stays on the wall clock. + */ + +import { KNOTS_TO_MS } from '@/constants/precipitation' +import { + REVERSER_DELAY_SECONDS, + REVERSER_TRAVEL_SECONDS, + SPOILER_DEPLOY_SECONDS, + SPOILER_STOW_SECONDS, + SURFACE_DECELERATION_WINDOW_SECONDS, + SURFACE_DEPLOY_MIN_GROUNDSPEED_KTS, + SURFACE_HEIGHTLESS_CONTACT_BLEND_WEIGHT, + SURFACE_SEEK_SECONDS, + SURFACE_TOUCH_AND_GO_SPEEDUP_KTS, + SURFACE_TOUCHDOWN_ARM_SECONDS, + WHEEL_COAST_SECONDS, + WHEEL_RADIUS_NARROWBODY_M, + WHEEL_RADIUS_NARROWBODY_MAX_WINGSPAN_M, + WHEEL_RADIUS_REGIONAL_M, + WHEEL_RADIUS_REGIONAL_MAX_WINGSPAN_M, + WHEEL_RADIUS_WIDEBODY_M, +} from '@/constants/rendering' +import type { InterpolatedAircraftState } from '@/types/vatsim' +import { isPushingBack } from '@/utils/aircraft/pushback' + +/** One frame of aircraft state for the spoilers, reversers and wheels */ +export interface SurfaceFrameInput { + /** The shared ground contact (`InterpolatedAircraftState.inContact`) */ + inContact: boolean + /** + * Ground blend weight (0..1) for a source without a height above ground, which in the landing blend is in contact + * for the surfaces only at SURFACE_HEIGHTLESS_CONTACT_BLEND_WEIGHT; null for a source with that height, whose shared + * contact stands + */ + groundBlendWeight: number | null + /** + * Whether the blend weight came from the landing blend (`InterpolatedAircraftState.groundBlendDescending`); null + * for a source with a height above ground + */ + groundBlendDescending: boolean | null + /** Groundspeed in knots */ + groundspeedKts: number + /** Heading in degrees */ + headingDeg: number + /** Ground track (direction of movement) in degrees */ + trackDeg: number + /** Data-source time of this frame in milliseconds, the clock every transition and the wheel spin run on */ + dataTime: number + /** Wheel radius in meters (wheelRadiusForWingspan) */ + wheelRadiusM: number + /** + * Whether the frame comes from a replay, where a forward data-time jump over 5 s is a seek (live it is a gap); a + * frame whose replay flag differs from the previous frame's is a seek too + */ + isReplay: boolean +} + +/** The spoiler, reverser and wheel pose of one aircraft */ +export interface SurfacePose { + /** Spoiler deployment (0 = stowed, 1 = deployed) */ + spoiler: number + /** Reverser deployment (0 = stowed, 1 = deployed) */ + reverser: number + /** Wheel phase in revolutions, 0..1 */ + wheelPhase: number + /** Wheel spin in revolutions per second (negative = backwards) */ + wheelRevPerSec: number +} + +/** What the spoiler and reverser rules see of one frame */ +export interface SurfaceRuleInput { + inContact: boolean + groundspeedKts: number + /** Groundspeed change over the last 0.5 s of data time, knots per second (negative = decelerating) */ + accelerationKtsPerSec: number + /** Seconds since the touchdown the current rollout began with, or null when no rollout is running */ + secondsSinceTouchdown: number | null + /** Whether the reversers have deployed in this rollout: once out they stay out until the rollout ends */ + reverserLatched: boolean +} + +/** What the wheel-spin rule sees of one frame */ +export interface WheelRuleInput { + inContact: boolean + groundspeedKts: number + pushingBack: boolean + wheelRadiusM: number + /** Wheel spin at the last liftoff, revolutions per second */ + liftoffRevPerSec: number + /** Seconds since the last liftoff, or null when out of contact without a liftoff seen */ + secondsSinceLiftoff: number | null +} + +/** Per-aircraft state */ +interface SurfaceState { + /** Data time of the last frame (ms) */ + dataTime: number + /** The surface contact of the last frame, for seeing a touchdown or a liftoff on this one */ + inContact: boolean + /** Data time contact was lost, or the aircraft was first seen out of contact; null while in contact */ + contactLostAt: number | null + /** Data time of the touchdown the current rollout began with; null when no rollout is running */ + touchdownAt: number | null + /** Lowest groundspeed since the touchdown (knots), for the touch-and-go end of the rollout */ + minGroundspeedKts: number + /** Whether the reversers have deployed in this rollout */ + reverserLatched: boolean + /** Whether the last frame came from a replay */ + isReplay: boolean + /** Data time (ms) and groundspeed (knots) of the frame the current deceleration window started at */ + speedRefAt: number + speedRefKts: number + /** Groundspeed change over the last completed deceleration window, knots per second */ + windowAccelKtsPerSec: number + /** Data time of the last liftoff; null in contact or without a liftoff seen */ + liftoffAt: number | null + liftoffRevPerSec: number + spoiler: number + reverser: number + wheelPhase: number + wheelRevPerSec: number +} + +const surfaceStates = new Map() + +/** The fields of an interpolated aircraft a surface frame is built from */ +export type SurfaceAircraft = Pick< + InterpolatedAircraftState, + | 'inContact' + | 'heightAboveGround' + | 'groundBlendWeight' + | 'groundBlendDescending' + | 'interpolatedGroundspeed' + | 'interpolatedHeading' + | 'track' + | 'timestamp' + | 'isReplay' +> + +/** + * Build this frame's surface input from the interpolated aircraft, so the data time and the replay flag come from the + * same data-source read. + * + * @param aircraft - The interpolated aircraft of this frame + * @param wheelRadiusM - Wheel radius in meters (wheelRadiusForWingspan) + * @returns The frame for updateSurfaceAnimation + */ +export function surfaceFrameFor(aircraft: SurfaceAircraft, wheelRadiusM: number): SurfaceFrameInput { + const heightless = aircraft.heightAboveGround === null + return { + inContact: aircraft.inContact === true, + groundBlendWeight: heightless ? aircraft.groundBlendWeight : null, + groundBlendDescending: heightless ? aircraft.groundBlendDescending : null, + groundspeedKts: aircraft.interpolatedGroundspeed, + headingDeg: aircraft.interpolatedHeading, + trackDeg: aircraft.track, + dataTime: aircraft.timestamp, + wheelRadiusM, + isReplay: aircraft.isReplay, + } +} + +/** + * Ground contact for the spoilers, reversers and wheels, from this frame alone. A source with a height above ground + * keeps the shared contact. One without it (VATSIM, RealTraffic) being drawn down by the landing blend is in contact + * only once drawn fully onto the terrain, since the shared contact ("more than halfway") passes about 30 m up on a + * landing; in the departure blend and the ground cases the shared contact stands, since the departure blend keeps the + * weight under 0.99 on a rollout or taxi reported a few metres above the terrain. + * + * @param frame - This frame's shared contact, blend weight and which blend produced it + * @returns Whether the surfaces see the aircraft in contact + */ +function surfaceContactOf(frame: SurfaceFrameInput): boolean { + if (!frame.inContact) return false + if (frame.groundBlendWeight === null || !frame.groundBlendDescending) return true + return frame.groundBlendWeight >= SURFACE_HEIGHTLESS_CONTACT_BLEND_WEIGHT +} + +/** + * Wheel radius for a type, by its wingspan: regional up to 30 m, narrowbody up to 42 m, widebody above. + * + * @param wingspanM - Wingspan in meters, or null when the type's dimensions are unknown + * @returns Wheel radius in meters (narrowbody when unknown) + */ +export function wheelRadiusForWingspan(wingspanM: number | null): number { + if (wingspanM === null) return WHEEL_RADIUS_NARROWBODY_M + if (wingspanM <= WHEEL_RADIUS_REGIONAL_MAX_WINGSPAN_M) return WHEEL_RADIUS_REGIONAL_M + if (wingspanM <= WHEEL_RADIUS_NARROWBODY_MAX_WINGSPAN_M) return WHEEL_RADIUS_NARROWBODY_M + return WHEEL_RADIUS_WIDEBODY_M +} + +/** Whether a rollout is running in contact above the deploy speed */ +function isRollingOut(input: SurfaceRuleInput): boolean { + return ( + input.inContact && input.secondsSinceTouchdown !== null && input.groundspeedKts > SURFACE_DEPLOY_MIN_GROUNDSPEED_KTS + ) +} + +/** + * Spoiler target: deployed from the touchdown while in contact above 60 kt, stowed otherwise. + * + * @param input - This frame's contact, speed and time since touchdown + * @returns 1 for deployed, 0 for stowed + */ +export function spoilerTarget(input: SurfaceRuleInput): number { + return isRollingOut(input) ? 1 : 0 +} + +/** + * Reverser target: deploys from 1.5 s after the touchdown while in contact, above 60 kt and decelerating; once + * deployed (latched) it stays out while the rollout runs, whatever the acceleration. + * + * @param input - This frame's contact, speed, acceleration, time since touchdown and latch + * @returns 1 for deployed, 0 for stowed + */ +export function reverserTarget(input: SurfaceRuleInput): number { + if (!isRollingOut(input)) return 0 + if (input.reverserLatched) return 1 + const delayed = (input.secondsSinceTouchdown ?? 0) >= REVERSER_DELAY_SECONDS + return delayed && input.accelerationKtsPerSec < 0 ? 1 : 0 +} + +/** + * Wheel spin: groundspeed over the circumference in contact (backwards in a pushback); out of contact, the liftoff + * spin coasting linearly to zero over 5 s. + * + * @param input - This frame's contact, speed, pushback, wheel radius and liftoff + * @returns Revolutions per second (negative = backwards) + */ +export function wheelSpinRate(input: WheelRuleInput): number { + if (input.inContact) { + const rate = (input.groundspeedKts * KNOTS_TO_MS) / (2 * Math.PI * input.wheelRadiusM) + return input.pushingBack ? -rate : rate + } + if (input.secondsSinceLiftoff === null) return 0 + return input.liftoffRevPerSec * Math.max(0, 1 - input.secondsSinceLiftoff / WHEEL_COAST_SECONDS) +} + +/** Move a deployment toward its target over `seconds`, at the deploy or stow travel time */ +function travel(value: number, target: number, seconds: number, deploySeconds: number, stowSeconds: number): number { + if (target > value) return Math.min(target, value + seconds / deploySeconds) + return Math.max(target, value - seconds / stowSeconds) +} + +/** + * Forget touchdown and liftoff, as when the aircraft is first seen (or after a seek). One seen out of contact counts + * as airborne long enough, so its next contact is a touchdown. `contact` is this frame's surface contact. + */ +function rememberFirstSight(state: SurfaceState, frame: SurfaceFrameInput, contact: boolean): void { + state.inContact = contact + state.contactLostAt = contact ? null : Number.NEGATIVE_INFINITY + state.touchdownAt = null + state.minGroundspeedKts = frame.groundspeedKts + state.reverserLatched = false + state.isReplay = frame.isReplay + state.speedRefAt = frame.dataTime + state.speedRefKts = frame.groundspeedKts + state.windowAccelKtsPerSec = 0 + state.liftoffAt = null + state.liftoffRevPerSec = 0 +} + +/** Close the deceleration window once it spans 0.5 s of data time, and start the next one at this frame */ +function measureAcceleration(state: SurfaceState, frame: SurfaceFrameInput): void { + const elapsedSeconds = (frame.dataTime - state.speedRefAt) / 1000 + if (elapsedSeconds < SURFACE_DECELERATION_WINDOW_SECONDS) return + state.windowAccelKtsPerSec = (frame.groundspeedKts - state.speedRefKts) / elapsedSeconds + state.speedRefAt = frame.dataTime + state.speedRefKts = frame.groundspeedKts +} + +/** End the rollout at 60 kt, or when the groundspeed rises 10 kt above its minimum since touchdown (touch-and-go) */ +function endRolloutWhenOver(state: SurfaceState, frame: SurfaceFrameInput): void { + if (state.touchdownAt === null) return + state.minGroundspeedKts = Math.min(state.minGroundspeedKts, frame.groundspeedKts) + const stopped = frame.groundspeedKts <= SURFACE_DEPLOY_MIN_GROUNDSPEED_KTS + const goingAgain = frame.groundspeedKts > state.minGroundspeedKts + SURFACE_TOUCH_AND_GO_SPEEDUP_KTS + if (stopped || goingAgain) { + state.touchdownAt = null + state.reverserLatched = false + } +} + +/** Track touchdowns, liftoffs and the end of the rollout, from this frame's surface contact */ +function rememberContact(state: SurfaceState, frame: SurfaceFrameInput, contact: boolean): void { + if (contact && !state.inContact) { + const outOfContactMs = state.contactLostAt === null ? 0 : frame.dataTime - state.contactLostAt + if (outOfContactMs >= SURFACE_TOUCHDOWN_ARM_SECONDS * 1000) { + state.touchdownAt = frame.dataTime + state.minGroundspeedKts = frame.groundspeedKts + } + state.contactLostAt = null + state.liftoffAt = null + } else if (!contact && state.inContact) { + state.contactLostAt = frame.dataTime + state.liftoffAt = frame.dataTime + state.liftoffRevPerSec = state.wheelRevPerSec + state.reverserLatched = false + } + endRolloutWhenOver(state, frame) + state.inContact = contact +} + +/** Seconds from a remembered data time to this frame, or null when nothing is remembered */ +const secondsSince = (from: number | null, frame: SurfaceFrameInput): number | null => + from === null ? null : (frame.dataTime - from) / 1000 + +const poseOf = (state: SurfaceState): SurfacePose => ({ + spoiler: state.spoiler, + reverser: state.reverser, + wheelPhase: state.wheelPhase, + wheelRevPerSec: state.wheelRevPerSec, +}) + +/** A first-seen aircraft's state, stowed and still */ +function firstSightState(frame: SurfaceFrameInput): SurfaceState { + const state: SurfaceState = { + dataTime: frame.dataTime, + inContact: false, + contactLostAt: null, + touchdownAt: null, + minGroundspeedKts: frame.groundspeedKts, + reverserLatched: false, + isReplay: frame.isReplay, + speedRefAt: frame.dataTime, + speedRefKts: frame.groundspeedKts, + windowAccelKtsPerSec: 0, + liftoffAt: null, + liftoffRevPerSec: 0, + spoiler: 0, + reverser: 0, + wheelPhase: 0, + wheelRevPerSec: 0, + } + rememberFirstSight(state, frame, surfaceContactOf(frame)) + return state +} + +/** + * Update an aircraft's spoilers, reversers and wheel spin for this frame, on data time. + * + * @param callsign - Aircraft callsign + * @param frame - This frame's contact, speeds, heading, track, data time and wheel radius + * @returns The pose to draw + */ +export function updateSurfaceAnimation(callsign: string, frame: SurfaceFrameInput): SurfacePose { + const state = surfaceStates.get(callsign) + if (!state) { + const seen = firstSightState(frame) + surfaceStates.set(callsign, seen) + return poseOf(seen) + } + + const seconds = (frame.dataTime - state.dataTime) / 1000 + const modeSwitched = frame.isReplay !== state.isReplay + const seek = seconds < 0 || modeSwitched || (frame.isReplay && seconds > SURFACE_SEEK_SECONDS) + const contact = surfaceContactOf(frame) + if (seek) rememberFirstSight(state, frame, contact) + else rememberContact(state, frame, contact) + measureAcceleration(state, frame) + + const rule: SurfaceRuleInput = { + inContact: contact, + groundspeedKts: frame.groundspeedKts, + accelerationKtsPerSec: state.windowAccelKtsPerSec, + secondsSinceTouchdown: secondsSince(state.touchdownAt, frame), + reverserLatched: state.reverserLatched, + } + const spoilerTo = spoilerTarget(rule) + const reverserTo = reverserTarget(rule) + state.reverserLatched = reverserTo === 1 + state.wheelRevPerSec = wheelSpinRate({ + inContact: contact, + groundspeedKts: frame.groundspeedKts, + pushingBack: isPushingBack(frame.groundspeedKts, frame.headingDeg, frame.trackDeg), + wheelRadiusM: frame.wheelRadiusM, + liftoffRevPerSec: state.liftoffRevPerSec, + secondsSinceLiftoff: secondsSince(state.liftoffAt, frame), + }) + + if (seek) { + state.spoiler = spoilerTo + state.reverser = reverserTo + } else { + state.spoiler = travel(state.spoiler, spoilerTo, seconds, SPOILER_DEPLOY_SECONDS, SPOILER_STOW_SECONDS) + state.reverser = travel(state.reverser, reverserTo, seconds, REVERSER_TRAVEL_SECONDS, REVERSER_TRAVEL_SECONDS) + const phase = state.wheelPhase + state.wheelRevPerSec * seconds + state.wheelPhase = phase - Math.floor(phase) + } + state.dataTime = frame.dataTime + return poseOf(state) +} + +/** + * Clear an aircraft's spoiler, reverser and wheel state (when it leaves the scene) + */ +export function clearSurfaceState(callsign: string): void { + surfaceStates.delete(callsign) +} + +/** + * Clear every aircraft's spoiler, reverser and wheel state + */ +export function clearAllSurfaceStates(): void { + surfaceStates.clear() +} diff --git a/tests/motion/harness.ts b/tests/motion/harness.ts index a93538b6..8f82df9f 100644 --- a/tests/motion/harness.ts +++ b/tests/motion/harness.ts @@ -15,6 +15,7 @@ import { angleDiff, arrivalFlight, departureFlight, type TruthSample, truthAt } type TimelineModule = typeof import('@/stores/aircraftTimelineStore') type InterpModule = typeof import('@/hooks/useAircraftInterpolation') type GearModule = typeof import('@/utils/gearAnimationController') +type SurfaceModule = typeof import('@/utils/surfaceAnimationController') type SmartModule = typeof import('@/utils/smartSort') type AircraftMap = ReturnType type AircraftEntry = NonNullable> @@ -68,6 +69,12 @@ export interface ScenarioOptions { vnasWithoutHeight?: boolean /** vNAS observations carry no 50/80 ft ground flag (null), as VATSIM's do. */ vnasWithoutGroundFlag?: boolean + /** + * Scenario second, per callsign, before which that flight's VATSIM reports are dropped, so the aircraft is first + * seen at the first 3 + 15 n report at or after it (mid-taxi, on the takeoff roll, on short final). Flights not + * named report from their start. + */ + vatsimFromS?: Partial> } /** What each vNAS observation carries besides its position. */ @@ -95,6 +102,12 @@ export interface FrameRecord { inContact: boolean | null gearApp: number gearEllipsoidal: number + /** Spoiler deployment, as useAircraftModels draws it (B738 wheel radius) */ + spoiler: number + /** Reverser deployment */ + reverser: number + /** Wheel spin in revolutions per second (negative = backwards) */ + wheelRevPerSec: number displayDelayMs: number /** The delay the display delay is easing toward, from the receive intervals; null without a delay state. */ targetDelayMs: number | null @@ -137,6 +150,7 @@ interface FrameDeps { tl: TimelineModule interp: InterpModule gear: GearModule + surface: SurfaceModule groundEllip: number flightByCs: Map } @@ -241,7 +255,8 @@ function buildObservations(flights: Flight[], opts: ScenarioOptions, groundEllip const out: Observation[] = [] for (const f of flights) { const times = vatsimTimes(f, opts.durationS) - const vatsimStart = opts.useVnas && opts.vnasFirst ? f.offset + 5 : 0 + const vnasFirstStart = opts.useVnas && opts.vnasFirst ? f.offset + 5 : 0 + const vatsimStart = Math.max(vnasFirstStart, opts.vatsimFromS?.[f.callsign] ?? 0) out.push( ...vatsimObservations( f, @@ -354,6 +369,11 @@ function recordFrame(deps: FrameDeps, feed: FeedState, e: AircraftEntry, now: nu deps.gear.initializeGearState(`${e.callsign}#ell`, aglFtEll, vrFpm, onGroundApp) } + const surfaces = deps.surface.updateSurfaceAnimation( + e.callsign, + deps.surface.surfaceFrameFor(e, deps.surface.wheelRadiusForWingspan(35.78)), + ) + const list = feed.frames.get(e.callsign) ?? [] list.push({ frameTime: (now - T0) / 1000, @@ -381,6 +401,9 @@ function recordFrame(deps: FrameDeps, feed: FeedState, e: AircraftEntry, now: nu { altitudeFt: aglFtEll, verticalRateFpm: vrFpm, inContact: onGroundApp, dataTime: e.timestamp }, now, ), + spoiler: surfaces.spoiler, + reverser: surfaces.reverser, + wheelRevPerSec: surfaces.wheelRevPerSec, displayDelayMs: ts.displayDelay, targetDelayMs: deps.tl.useAircraftTimelineStore.getState().dynamicDelays.get(e.callsign)?.targetDelayMs ?? null, extrapolating: ts.isExtrapolating, @@ -413,13 +436,21 @@ export async function runScenario(opts: ScenarioOptions): Promise [f.callsign, f])) } + const deps: FrameDeps = { + tl, + interp, + gear, + surface, + groundEllip, + flightByCs: new Map(flights.map((f) => [f.callsign, f])), + } const feed: FeedState = { frames: new Map(), phases: new Map(), diff --git a/tests/motion/surfaceClipPlayback.test.ts b/tests/motion/surfaceClipPlayback.test.ts new file mode 100644 index 00000000..95f658ed --- /dev/null +++ b/tests/motion/surfaceClipPlayback.test.ts @@ -0,0 +1,251 @@ +// @vitest-environment node +/** + * How spoiler, reverser and wheel clips play. A wheel clip is driven only when its rotation keys make one revolution + * in one direction about one axis; every clip plays over its own key range, which in some models starts mid-timeline; + * and the gear's progress cache does not answer for another class of clip on the same model. + */ +import * as Cesium from 'cesium' +import { describe, expect, it, vi } from 'vitest' +import { + type Animation, + type AnimationSet, + applyClipAnimationsPercent, + applyWheelSpin, + clipTimeAtPercent, + isOneRevolutionClip, +} from '@/utils/gltfAnimationParser' + +/** A quaternion turning `deg` degrees about the Y axis, after a base orientation of `baseDeg` about X. */ +function yRotation(deg: number, baseDeg = 0): number[] { + const base = Cesium.Quaternion.fromAxisAngle(Cesium.Cartesian3.UNIT_X, Cesium.Math.toRadians(baseDeg)) + const spin = Cesium.Quaternion.fromAxisAngle(Cesium.Cartesian3.UNIT_Y, Cesium.Math.toRadians(deg)) + const q = Cesium.Quaternion.multiply(base, spin, new Cesium.Quaternion()) + return [q.x, q.y, q.z, q.w] +} + +/** A clip on one node whose rotation keys pass through the given angles about Y, one key per `step` seconds. */ +function rotationClip(name: string, node: string, anglesDeg: number[], start = 0, step = 0.25, baseDeg = 0): Animation { + const rotationKeys = anglesDeg.map((deg, i) => ({ time: start + i * step, value: yRotation(deg, baseDeg) })) + const end = rotationKeys[rotationKeys.length - 1].time + return { + name, + duration: end, + tracks: new Map([[node, { translationKeys: [], rotationKeys, scaleKeys: [] }]]), + } +} + +const quarterSteps = (from: number, to: number, step = 90): number[] => { + const out: number[] = [] + for (let a = from; a <= to + 1e-9; a += step) out.push(a) + return out +} + +describe('isOneRevolutionClip', () => { + it('accepts one revolution in 90 degree keys', () => { + expect(isOneRevolutionClip(rotationClip('c_tire_blurred_key', 'n', [0, 90, 180, 270, 360]))).toBe(true) + }) + + it('accepts one revolution in many small keys about a tilted axle', () => { + const angles = Array.from({ length: 2401 }, (_, i) => (360 * i) / 2400) + expect(isOneRevolutionClip(rotationClip('WHEEL_ROLL_left', 'n', angles, 0, 1 / 24, 30))).toBe(true) + }) + + it('accepts a clip whose wheel turns the other way, one revolution', () => { + expect(isOneRevolutionClip(rotationClip('l_tire', 'n', [0, -90, -180, -270, -360]))).toBe(true) + }) + + it('rejects a clip that reverses partway (Dash8 r_tire: forward 180, back 270, forward 90)', () => { + expect(isOneRevolutionClip(rotationClip('r_tire', 'n', [0, 90, 180, 90, 0, -90, 0]))).toBe(false) + }) + + it('rejects a clip that stops 23 degrees short of a turn (B78X WHEEL_ROLL at 337 degrees)', () => { + const angles = Array.from({ length: 101 }, (_, i) => (337 * i) / 100) + expect(isOneRevolutionClip(rotationClip('WHEEL_ROLL_left', 'n', angles))).toBe(false) + }) + + it('rejects a clip with a 180 degree step, whose direction between keys is undefined', () => { + expect(isOneRevolutionClip(rotationClip('r_tire_anim', 'n', [0, 90, 270, 360]))).toBe(false) + }) + + it('rejects two revolutions', () => { + expect(isOneRevolutionClip(rotationClip('c_tire', 'n', quarterSteps(0, 720)))).toBe(false) + }) + + it('rejects a clip without rotation keys', () => { + const clip: Animation = { + name: 'c_tire', + duration: 1, + tracks: new Map([['n', { translationKeys: [{ time: 0, value: [0, 0, 0] }], rotationKeys: [], scaleKeys: [] }]]), + } + expect(isOneRevolutionClip(clip)).toBe(false) + }) +}) + +describe('clipTimeAtPercent', () => { + it('maps progress onto the clip key range, so a clip starting at 4.17 s plays from its first key', () => { + const clip = rotationClip('spoilers', 'n', [0, 20, 40], 4.17, 0.5) + expect(clipTimeAtPercent(clip, 0)).toBeCloseTo(4.17, 9) + expect(clipTimeAtPercent(clip, 0.5)).toBeCloseTo(4.67, 9) + expect(clipTimeAtPercent(clip, 1)).toBeCloseTo(5.17, 9) + }) + + it('clamps progress outside 0..1', () => { + const clip = rotationClip('engine_rev', 'n', [0, 20], 100, 1) + expect(clipTimeAtPercent(clip, -0.5)).toBe(100) + expect(clipTimeAtPercent(clip, 1.5)).toBe(101) + }) +}) + +/** A model whose nodes start at the identity, with the node data the parser would hold for them. */ +function fakeModel(nodeNames: string[]): { + model: Cesium.Model + set: (animations: Animation[]) => AnimationSet + writes: (node: string) => number +} { + const writeCounts = new Map() + const nodes = new Map( + nodeNames.map((n) => { + let matrix = Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY) + const node = { + get matrix() { + return matrix + }, + set matrix(value: Cesium.Matrix4) { + matrix = value + writeCounts.set(n, (writeCounts.get(n) ?? 0) + 1) + }, + } + return [n, node] as const + }), + ) + const model = { getNode: (name: string) => nodes.get(name) } as unknown as Cesium.Model + const nodeData = new Map( + nodeNames.map((name) => [ + name, + { + name, + translation: [0, 0, 0], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + invRotation: new Cesium.Quaternion(0, 0, 0, 1), + invRotationMatrix: Cesium.Matrix3.clone(Cesium.Matrix3.IDENTITY), + }, + ]), + ) + return { + model, + set: (animations) => ({ animations, nodes: nodeData }), + writes: (node) => writeCounts.get(node) ?? 0, + } +} + +/** The rotation angle, in degrees, of a node's matrix in a fake model. */ +function nodeAngleDeg(model: Cesium.Model, node: string): number { + const m = Cesium.Matrix4.getMatrix3(model.getNode(node).matrix, new Cesium.Matrix3()) + const q = Cesium.Quaternion.fromRotationMatrix(m) + return Cesium.Math.toDegrees(Cesium.Quaternion.computeAngle(q)) +} + +describe('applyClipAnimationsPercent', () => { + it('keeps one progress cache per class, so spoilers at the gear progress are not drawn from the gear cache', () => { + const { model, set } = fakeModel(['GearNode', 'SpoilerNode']) + const animSet = set([ + rotationClip('c_gear', 'GearNode', [0, 90]), + rotationClip('l_spoiler_key', 'SpoilerNode', [0, 50]), + ]) + const url = 'test://cache-namespaces.glb' + expect(applyClipAnimationsPercent(model, animSet, 'gear', 0.5, url)).toBe(1) + expect(nodeAngleDeg(model, 'GearNode')).toBeCloseTo(45, 3) + expect(nodeAngleDeg(model, 'SpoilerNode')).toBeCloseTo(0, 3) + expect(applyClipAnimationsPercent(model, animSet, 'spoiler', 0.5, url)).toBe(1) + expect(nodeAngleDeg(model, 'SpoilerNode')).toBeCloseTo(25, 3) + // The cached gear pose is still the gear's own + applyClipAnimationsPercent(model, animSet, 'gear', 1, url) + applyClipAnimationsPercent(model, animSet, 'gear', 0.5, url) + expect(nodeAngleDeg(model, 'GearNode')).toBeCloseTo(45, 3) + }) + + it('plays a mid-timeline reverser clip over its own keys', () => { + const { model, set } = fakeModel(['Sleeve']) + const animSet = set([rotationClip('engine_rev', 'Sleeve', [0, 40], 4.17, 1)]) + applyClipAnimationsPercent(model, animSet, 'reverser', 0.5, 'test://mid-timeline.glb') + expect(nodeAngleDeg(model, 'Sleeve')).toBeCloseTo(20, 3) + }) +}) + +describe('applyClipAnimationsPercent return and repeat', () => { + /** A gear clip moving two nodes, the strut and its door, with one track. */ + const twoNodeGear = (): Animation => { + const clip = rotationClip('c_gear', 'GearNode', [0, 90]) + clip.tracks.set('DoorNode', clip.tracks.get('GearNode') as Animation['tracks'] extends Map ? T : never) + return clip + } + + it('returns the nodes written, the same on a cache miss and a hit, and 0 for a class the model lacks', () => { + const { model, set } = fakeModel(['GearNode', 'DoorNode']) + const animSet = set([twoNodeGear()]) + const url = 'test://return-value.glb' + expect(applyClipAnimationsPercent(model, animSet, 'gear', 0.3, url)).toBe(2) + const second = fakeModel(['GearNode', 'DoorNode']).model + expect(applyClipAnimationsPercent(second, animSet, 'gear', 0.3, url)).toBe(2) + expect(applyClipAnimationsPercent(model, animSet, 'reverser', 0.3, url)).toBe(0) + }) + + it('writes a node once for two frames at the same progress', () => { + const { model, set, writes } = fakeModel(['GearNode', 'DoorNode']) + const animSet = set([twoNodeGear()]) + const url = 'test://repeat.glb' + applyClipAnimationsPercent(model, animSet, 'gear', 0.5, url) + applyClipAnimationsPercent(model, animSet, 'gear', 0.5, url) + expect(writes('GearNode')).toBe(1) + applyClipAnimationsPercent(model, animSet, 'gear', 0.6, url) + expect(writes('GearNode')).toBe(2) + }) +}) + +describe('applyWheelSpin', () => { + it('does not rewrite the wheels while the phase is unchanged (parked or held)', () => { + const { model, set, writes } = fakeModel(['Wheel']) + const animSet = set([rotationClip('c_tire', 'Wheel', [0, 90, 180, 270, 360])]) + applyWheelSpin(model, animSet, 0.25) + applyWheelSpin(model, animSet, 0.25) + expect(writes('Wheel')).toBe(1) + applyWheelSpin(model, animSet, 0.5) + expect(writes('Wheel')).toBe(2) + }) + + it('does not drive a wheel clip on a node a gear clip moves, and says so once per model', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { model, set } = fakeModel(['Strut', 'LWheel']) + const animSet = set([ + rotationClip('c_gear', 'Strut', [0, 90]), + rotationClip('c_tire', 'Strut', [0, 90, 180, 270, 360]), + rotationClip('l_tire', 'LWheel', [0, 90, 180, 270, 360]), + ]) + expect(applyWheelSpin(model, animSet, 0.25)).toBe(1) + applyWheelSpin(model, animSet, 0.5) + expect(nodeAngleDeg(model, 'Strut')).toBeCloseTo(0, 6) + expect(warn).toHaveBeenCalledTimes(1) + warn.mockRestore() + }) + + it('turns a one-revolution wheel clip to the phase, over its own key range', () => { + const { model, set } = fakeModel(['Wheel']) + const animSet = set([rotationClip('c_tire_anim', 'Wheel', [0, 90, 180, 270, 360], 4.17)]) + applyWheelSpin(model, animSet, 0.25) + expect(nodeAngleDeg(model, 'Wheel')).toBeCloseTo(90, 3) + applyWheelSpin(model, animSet, 0.5) + expect(nodeAngleDeg(model, 'Wheel')).toBeCloseTo(180, 3) + }) + + it('leaves a reversing wheel clip and the steering clip alone', () => { + const { model, set } = fakeModel(['RockingWheel', 'NoseSteer']) + const animSet = set([ + rotationClip('r_tire', 'RockingWheel', [0, 90, 180, 90, 0, -90, 0]), + rotationClip('custom_anim_C_WHEEL_LR', 'NoseSteer', [0, 90, 180, 270, 360]), + ]) + expect(applyWheelSpin(model, animSet, 0.25)).toBe(0) + expect(nodeAngleDeg(model, 'RockingWheel')).toBeCloseTo(0, 6) + expect(nodeAngleDeg(model, 'NoseSteer')).toBeCloseTo(0, 6) + }) +}) diff --git a/tests/motion/surfaceClips.test.ts b/tests/motion/surfaceClips.test.ts new file mode 100644 index 00000000..25f228af --- /dev/null +++ b/tests/motion/surfaceClips.test.ts @@ -0,0 +1,154 @@ +// @vitest-environment node +/** + * Which class of motion drives each glTF animation clip: gear progress, spoilers, reversers or wheel spin. The names + * are every gear, spoiler, reverser, wheel, tyre and steering clip name found in the 502 FSLTL and AIG source glTFs + * (TC3D-54 clip census), with the per-model numeric suffixes the census found, and the near misses it named. + */ +import { describe, expect, it } from 'vitest' +import { classifyAnimationClip, isGearClip } from '@/utils/gltfAnimationParser' + +const words = (list: string): string[] => list.trim().split(/\s+/) + +const SPOILER = words(` + AIA_Fokker_TailSpeedBrake + AIG_A330_Spoiler_L AIG_A330_Spoiler_R AIG_A340_Spoiler_L AIG_A340_Spoiler_R + AIG_B757_Spoiler_L1 AIG_B757_Spoiler_L2 AIG_B757_Spoiler_L3 AIG_B757_Spoiler_R1 AIG_B757_Spoiler_R2 AIG_B757_Spoiler_R3 + custom_anim_SPOILERS_LEFT_POSITION_00 custom_anim_SPOILERS_LEFT_POSITION_03 custom_anim_SPOILERS_LEFT_POSITION_04 + custom_anim_SPOILERS_LEFT_POSITION_06 custom_anim_SPOILERS_LEFT_POSITION_07 custom_anim_SPOILERS_LEFT_POSITION_08 + custom_anim_SPOILERS_LEFT_POSITION_09 custom_anim_SPOILERS_LEFT_POSITION_10 custom_anim_SPOILERS_LEFT_POSITION_11 + custom_anim_SPOILERS_LEFT_POSITION_13 custom_anim_SPOILERS_LEFT_POSITION_21 + custom_anim_SPOILERS_RIGHT_POSITION_01 custom_anim_SPOILERS_RIGHT_POSITION_05 custom_anim_SPOILERS_RIGHT_POSITION_06 + custom_anim_SPOILERS_RIGHT_POSITION_08 custom_anim_SPOILERS_RIGHT_POSITION_09 custom_anim_SPOILERS_RIGHT_POSITION_11 + custom_anim_SPOILERS_RIGHT_POSITION_12 custom_anim_SPOILERS_RIGHT_POSITION_13 custom_anim_SPOILERS_RIGHT_POSITION_14 + custom_anim_SPOILERS_RIGHT_POSITION_34 + Dash8-100_Spoiler_L Dash8-100_Spoiler_R Dash8-200-300_Spoiler_L Dash8-200-300_Spoiler_R + Dash8-400_Spoiler_L Dash8-400_Spoiler_R + HANDLING_Spoilers_Ground HANDLING_Spoilers_l HANDLING_Spoilers_r + l_spoiler_key r_spoiler_key + Spoiler_L1 Spoiler_L2 Spoiler_L3L5 Spoiler_L4 Spoiler_R1 Spoiler_R2 Spoiler_R3R5 Spoiler_R4 spoiler_Q_new + spoilers SPOILERS SPOILERS_left SPOILERS_right SPOLIERS_right SPOLIERS_left + TFS_777_Spoiler_Inner_L TFS_777_Spoiler_Inner_R TFS_777_Spoiler_Out_L1-3 TFS_777_Spoiler_Out_L2 + TFS_777_Spoiler_Out_L4 TFS_777_Spoiler_Out_LR5 TFS_777_Spoiler_Out_R1-3 TFS_777_Spoiler_Out_R2 TFS_777_Spoiler_Out_R4 + TFS_A380_spoiler_inner TFS_A380_spoiler_L_outer TFS_A380_spoiler_R_outer + UTT_787_spoiler_l_in UTT_787_spoiler_l_out1 UTT_787_spoiler_l_out2 UTT_787_spoiler_l_out3 UTT_787_spoiler_l_out4 + UTT_787_spoiler_r_in UTT_787_spoiler_r_out1 UTT_787_spoiler_r_out2 UTT_787_spoiler_r_out3 UTT_787_spoiler_r_out4 + UTT_A350_Spoiler_L2 UTT_A350_Spoiler_L3 UTT_A350_Spoiler_LR1 UTT_A350_Spoiler_R2 UTT_A350_Spoiler_R3 +`) + +const REVERSER = words(` + custom_anim_REV_THRUST reverse_thrust Reverse_Thrust Reverser REVERSERS thrust_rev_1 thrust_rev_2 engine_rev + custom_anim_TURB_ENG_REVERSE_NOZZLE_PERCENT_1_39 custom_anim_TURB_ENG_REVERSE_NOZZLE_PERCENT_2_40 +`) + +const WHEEL_SPIN = words(` + c_tire c_tire_anim c_tire_blurred_key c_tire_key c_tire_roll c_tire_still_key center_tire_roll + l_tire l_tire_anim l_tire_blurred_key l_tire_key l_tire_roll l_tire_still_key + r_tire r_tire_anim r_tire_blurred_key r_tire_key r_tire_roll r_tire_still_key tire_anim + custom_anim_CENTER_WHEEL_ROTATION_ANGLE_01 custom_anim_CENTER_WHEEL_ROTATION_ANGLE_02 + custom_anim_CENTER_WHEEL_ROTATION_ANGLE_04 custom_anim_CENTER_WHEEL_ROTATION_ANGLE_06 + custom_anim_CENTER_WHEEL_ROTATION_ANGLE_07 custom_anim_CENTER_WHEEL_ROTATION_ANGLE_08 + custom_anim_CENTER_WHEEL_ROTATION_ANGLE_11 custom_anim_CENTER_WHEEL_ROTATION_ANGLE_12 + custom_anim_CENTER_WHEEL_ROTATION_ANGLE_14 custom_anim_CENTER_WHEEL_ROTATION_ANGLE_15 + custom_anim_CENTER_WHEEL_ROTATION_ANGLE_19 + custom_anim_LEFT_WHEEL_ROTATION_ANGLE_01 custom_anim_LEFT_WHEEL_ROTATION_ANGLE_02 custom_anim_LEFT_WHEEL_ROTATION_ANGLE_03 + custom_anim_LEFT_WHEEL_ROTATION_ANGLE_08 custom_anim_LEFT_WHEEL_ROTATION_ANGLE_09 custom_anim_LEFT_WHEEL_ROTATION_ANGLE_10 + custom_anim_LEFT_WHEEL_ROTATION_ANGLE_11 custom_anim_LEFT_WHEEL_ROTATION_ANGLE_12 custom_anim_LEFT_WHEEL_ROTATION_ANGLE_13 + custom_anim_LEFT_WHEEL_ROTATION_ANGLE_17 custom_anim_LEFT_WHEEL_ROTATION_ANGLE_22 custom_anim_LEFT_WHEEL_ROTATION_ANGLE_23 + custom_anim_RIGHT_WHEEL_ROTATION_ANGLE_03 custom_anim_RIGHT_WHEEL_ROTATION_ANGLE_05 + custom_anim_RIGHT_WHEEL_ROTATION_ANGLE_08 custom_anim_RIGHT_WHEEL_ROTATION_ANGLE_09 + custom_anim_RIGHT_WHEEL_ROTATION_ANGLE_10 custom_anim_RIGHT_WHEEL_ROTATION_ANGLE_12 + custom_anim_RIGHT_WHEEL_ROTATION_ANGLE_13 custom_anim_RIGHT_WHEEL_ROTATION_ANGLE_14 + custom_anim_RIGHT_WHEEL_ROTATION_ANGLE_15 custom_anim_RIGHT_WHEEL_ROTATION_ANGLE_19 + custom_anim_RIGHT_WHEEL_ROTATION_ANGLE_35 custom_anim_RIGHT_WHEEL_ROTATION_ANGLE_36 + GEAR_Center_Tire GEAR_Left_Tire GEAR_Right_Tire + WHEEL_ROLL_center WHEEL_ROLL_left WHEEL_ROLL_right +`) + +const GEAR = words(` + c_gear l_gear r_gear MainGearDoor_EarlyDeploy UTT_787_gear_789EDO + c_gear_door1 c_gear_door2 f_gear_door_1 f_gear_door_2 l_gear_door l_gear_door1 l_gear_door2 l_gear_door3 + r_gear_door r_gear_door1 r_gear_door2 r_gear_door3 + custom_anim_GEAR_ANIMATION_POSITION_0_00 custom_anim_GEAR_ANIMATION_POSITION_0_03 + custom_anim_GEAR_ANIMATION_POSITION_0_05 custom_anim_GEAR_ANIMATION_POSITION_0_06 + custom_anim_GEAR_ANIMATION_POSITION_0_07 custom_anim_GEAR_ANIMATION_POSITION_0_14 + custom_anim_GEAR_ANIMATION_POSITION_0_17 custom_anim_GEAR_ANIMATION_POSITION_1_00 + custom_anim_GEAR_ANIMATION_POSITION_1_01 custom_anim_GEAR_ANIMATION_POSITION_1_02 + custom_anim_GEAR_ANIMATION_POSITION_1_03 custom_anim_GEAR_ANIMATION_POSITION_1_07 + custom_anim_GEAR_ANIMATION_POSITION_1_08 custom_anim_GEAR_ANIMATION_POSITION_1_09 + custom_anim_GEAR_ANIMATION_POSITION_1_10 custom_anim_GEAR_ANIMATION_POSITION_1_12 + custom_anim_GEAR_ANIMATION_POSITION_1_16 custom_anim_GEAR_ANIMATION_POSITION_1_19 + custom_anim_GEAR_ANIMATION_POSITION_2_02 custom_anim_GEAR_ANIMATION_POSITION_2_04 + custom_anim_GEAR_ANIMATION_POSITION_2_05 custom_anim_GEAR_ANIMATION_POSITION_2_07 + custom_anim_GEAR_ANIMATION_POSITION_2_08 custom_anim_GEAR_ANIMATION_POSITION_2_10 + custom_anim_GEAR_ANIMATION_POSITION_2_11 custom_anim_GEAR_ANIMATION_POSITION_2_12 + custom_anim_GEAR_ANIMATION_POSITION_2_13 custom_anim_GEAR_ANIMATION_POSITION_2_14 + custom_anim_GEAR_ANIMATION_POSITION_2_18 custom_anim_GEAR_ANIMATION_POSITION_2_32 + GEAR_center GEAR_Center GEAR_left GEAR_Left GEAR_right GEAR_Right + gear_left.002 gear_left_suspension gear_right_suspension gearRight.001 + main_gear_c main_gear_l main_gear_left main_gear_r main_gear_right + nose_gear nose_gear_1 nose_gear_2 nose_gear_3 nose_gear_4 +`) + +/** Steering, wheel RPM, tyre contact points, ground-velocity flap swings, roll spoilers and motionless generic clips. */ +const NONE = words(` + custom_anim_C_WHEEL_LR c_wheel c_wheel_key GEAR_STEER GEAR_Steering center_gear_steer front_gear_steer + nose_steer nose_steering custom_anim_GEAR_CENTER_STEER_ANGLE_03 custom_anim_GEAR_CENTER_STEER_ANGLE_09 + custom_anim_ROTATION_VELOCITY_BODY_Y_08 custom_anim_ROTATION_VELOCITY_BODY_Y_10 + custom_anim_LEFT_WHEEL_RPM_07 custom_anim_RIGHT_WHEEL_RPM_08 custom_anim_CENTER_WHEEL_RPM_09 + fx_tire_contactpoint_center_left fx_tire_contactPoint_center_left fx_tire_contactPoint_center_leftAction + fx_tire_contactpoint_center_right fx_tire_contactPoint_center_right fx_tire_contactPoint_center_rightAction + fx_tire_contactpoint_left_in fx_tire_contactPoint_left_in fx_tire_contactPoint_left_in_in + fx_tire_contactPoint_left_in_out fx_tire_contactPoint_left_inAction + fx_tire_contactpoint_left_out fx_tire_contactPoint_left_out fx_tire_contactPoint_left_out_in + fx_tire_contactPoint_left_out_out fx_tire_contactPoint_left_outAction + fx_tire_contactPoint_rearcenter_lf fx_tire_contactPoint_rearcenter_lr fx_tire_contactPoint_rearcenter_rf + fx_tire_contactPoint_rearcenter_rr + fx_tire_contactpoint_right_in fx_tire_contactPoint_right_in fx_tire_contactPoint_right_in_in + fx_tire_contactPoint_right_in_out fx_tire_contactPoint_right_inAction + fx_tire_contactpoint_right_out fx_tire_contactPoint_right_out fx_tire_contactPoint_right_out_in + fx_tire_contactPoint_right_out_out fx_tire_contactPoint_right_outAction + custom_anim_GROUND_VELOCITY_02 custom_anim_GROUND_VELOCITY_03 + spoiler_UTT787_aileron + custom_anim_FLAPS_AIRLINER custom_anim_SLATS_AIRLINER Flaps Aileron_L AI_Rudder_wind_deflect + node123 Cube.004 Cylinder.012 +`) + +const table: [string, ReturnType][] = [ + ...SPOILER.map((n): [string, 'spoiler'] => [n, 'spoiler']), + ...REVERSER.map((n): [string, 'reverser'] => [n, 'reverser']), + ...WHEEL_SPIN.map((n): [string, 'wheelSpin'] => [n, 'wheelSpin']), + ...GEAR.map((n): [string, 'gear'] => [n, 'gear']), + ...NONE.map((n): [string, null] => [n, null]), +] + +describe('classifyAnimationClip', () => { + it.each(table)('%s is %s', (name, expected) => { + expect(classifyAnimationClip(name)).toBe(expected) + }) + + it('matches regardless of case', () => { + expect(classifyAnimationClip('L_SPOILER_KEY')).toBe('spoiler') + expect(classifyAnimationClip('Custom_Anim_Rev_Thrust')).toBe('reverser') + expect(classifyAnimationClip('C_TIRE_BLURRED_KEY')).toBe('wheelSpin') + }) + + it('anchors each name: a spoiler, reverser or tyre name inside a longer name is not claimed', () => { + expect(classifyAnimationClip('l_spoiler_key_extra')).toBe(null) + expect(classifyAnimationClip('pre_engine_rev')).toBe(null) + expect(classifyAnimationClip('c_tire_blurred_key_old')).toBe(null) + }) +}) + +describe('isGearClip', () => { + it.each(['GEAR_Center_Tire', 'GEAR_Left_Tire', 'GEAR_Right_Tire'])('no longer drives the wheel spin %s', (name) => { + expect(isGearClip(name)).toBe(false) + }) + + it.each(GEAR)('still drives %s', (name) => { + expect(isGearClip(name)).toBe(true) + }) + + it.each([...SPOILER, ...REVERSER, ...WHEEL_SPIN, ...NONE])('does not drive %s', (name) => { + expect(isGearClip(name)).toBe(false) + }) +}) diff --git a/tests/motion/surfaceRule.test.ts b/tests/motion/surfaceRule.test.ts new file mode 100644 index 00000000..26bfb202 --- /dev/null +++ b/tests/motion/surfaceRule.test.ts @@ -0,0 +1,429 @@ +// @vitest-environment node +/** + * The spoiler, reverser and wheel-spin rules (TC3D-54). Spoilers deploy at a touchdown above 60 kt and reversers + * 1.5 s after it while decelerating (the groundspeed falling over the last 0.5 s of data time); both stow at 60 kt or + * on contact loss; a contact flicker on the rollout, an aircraft first seen in contact and a takeoff roll deploy + * nothing. Wheels spin at groundspeed over the wheel circumference, backwards in a pushback, and coast to a stop over + * 5 s after liftoff. Everything runs on data time: a paused replay holds every pose, and a seek (including a switch + * between replay and live) snaps the surfaces to their targets. + */ +import { beforeEach, describe, expect, it } from 'vitest' +import { KNOTS_TO_MS } from '@/constants/precipitation' +import { isPushingBack } from '@/utils/aircraft/pushback' +import { + clearAllSurfaceStates, + reverserTarget, + type SurfaceFrameInput, + type SurfacePose, + spoilerTarget, + surfaceFrameFor, + updateSurfaceAnimation, + wheelRadiusForWingspan, + wheelSpinRate, +} from '@/utils/surfaceAnimationController' + +const rule = (inContact: boolean, groundspeedKts: number, accelerationKtsPerSec: number, sst: number | null) => ({ + inContact, + groundspeedKts, + accelerationKtsPerSec, + secondsSinceTouchdown: sst, + reverserLatched: false, +}) + +describe('spoilerTarget', () => { + it.each([ + ['deploys at the touchdown above 60 kt', rule(true, 130, -3, 0), 1], + ['stays deployed on the rollout above 60 kt, accelerating or not', rule(true, 61, 0, 12), 1], + ['stows at 60 kt', rule(true, 60, -3, 15), 0], + ['stays stowed in contact without a touchdown (first seen in contact, takeoff roll)', rule(true, 130, 2, null), 0], + ['stows on contact loss', rule(false, 130, 0, 3), 0], + ])('%s', (_label, input, expected) => { + expect(spoilerTarget(input)).toBe(expected) + }) +}) + +describe('reverserTarget', () => { + it.each([ + ['stays stowed for the first 1.5 s after touchdown', rule(true, 130, -3, 1.49), 0], + ['deploys 1.5 s after touchdown while decelerating', rule(true, 128, -3, 1.5), 1], + ['stays stowed while accelerating', rule(true, 128, 0.5, 3), 0], + ['stays deployed once latched, accelerating or not', { ...rule(true, 128, 0.5, 3), reverserLatched: true }, 1], + ['a latch ends at 60 kt', { ...rule(true, 60, -3, 15), reverserLatched: true }, 0], + ['a latch ends on contact loss', { ...rule(false, 128, -3, 5), reverserLatched: true }, 0], + ['stays stowed at a steady speed', rule(true, 128, 0, 3), 0], + ['stows at 60 kt', rule(true, 60, -3, 15), 0], + ['stays stowed without a touchdown (takeoff roll, first seen in contact)', rule(true, 130, -3, null), 0], + ['stows on contact loss', rule(false, 130, -3, 3), 0], + ])('%s', (_label, input, expected) => { + expect(reverserTarget(input)).toBe(expected) + }) +}) + +describe('wheelSpinRate', () => { + const wheel = (inContact: boolean, gs: number, pushingBack: boolean, liftoff: number, sinceLiftoff: number | null) => ({ + inContact, + groundspeedKts: gs, + pushingBack, + wheelRadiusM: 0.55, + liftoffRevPerSec: liftoff, + secondsSinceLiftoff: sinceLiftoff, + }) + const revPerSec = (kts: number) => (kts * KNOTS_TO_MS) / (2 * Math.PI * 0.55) + + it.each([ + ['rolls at groundspeed over the circumference', wheel(true, 100, false, 0, null), revPerSec(100)], + ['turns backwards in a pushback', wheel(true, 3, true, 0, null), -revPerSec(3)], + ['stands still when stopped or held (groundspeed 0)', wheel(true, 0, false, 0, null), 0], + ['keeps the liftoff spin at liftoff', wheel(false, 150, false, 40, 0), 40], + ['coasts down linearly after liftoff', wheel(false, 150, false, 40, 2.5), 20], + ['has stopped 5 s after liftoff', wheel(false, 150, false, 40, 5), 0], + ['stays stopped after that', wheel(false, 150, false, 40, 30), 0], + ['stands still in the air without a liftoff seen', wheel(false, 140, false, 0, null), 0], + ])('%s', (_label, input, expected) => { + expect(wheelSpinRate(input)).toBeCloseTo(expected, 9) + }) +}) + +describe('isPushingBack', () => { + it.each([ + ['moving opposite to the heading at 3 kt', 3, 90, 270, true], + ['moving more than 90 degrees off the heading in a curved push', 3, 90, 185, true], + ['moving along the heading', 3, 90, 100, false], + ['faster than a pushback', 10, 90, 270, false], + ['below the 0.5 kt floor', 0.4, 90, 270, false], + ])('%s is %s', (_label, gs, heading, track, expected) => { + expect(isPushingBack(gs, heading, track)).toBe(expected) + }) +}) + +describe('wheelRadiusForWingspan', () => { + it.each([ + [28.0, 0.45], + [30.0, 0.45], + [35.8, 0.55], + [42.0, 0.55], + [64.8, 0.7], + [null, 0.55], + ])('wingspan %s m gives a %s m wheel', (wingspan, radius) => { + expect(wheelRadiusForWingspan(wingspan)).toBe(radius) + }) +}) + +describe('updateSurfaceAnimation', () => { + beforeEach(() => { + clearAllSurfaceStates() + }) + + const T0 = 1_000_000 + /** One frame: contact, groundspeed (kt) and seconds from T0 of data time. */ + type Step = [boolean, number, number] + const input = ([inContact, gs, s]: Step, heading = 90, track = 90): SurfaceFrameInput => ({ + inContact, + groundBlendWeight: null, + groundBlendDescending: null, + groundspeedKts: gs, + headingDeg: heading, + trackDeg: track, + dataTime: T0 + Math.round(s * 1000), + wheelRadiusM: 0.55, + isReplay: false, + }) + /** + * Feeds each step's frame and 10 Hz frames up to the next step (with the earlier step's contact and the groundspeed + * running linearly to the next step's), and returns the last pose. A frame's targets apply over the interval that + * ends at it. + */ + const fly = (callsign: string, steps: Step[], isReplay = false): SurfacePose => { + let pose: SurfacePose | null = null + for (let i = 0; i < steps.length; i++) { + const [inContact, gs, s] = steps[i] + const next = steps[i + 1] + pose = updateSurfaceAnimation(callsign, { ...input(steps[i]), isReplay }) + if (!next) break + const fromMs = Math.round(s * 1000) + const toMs = Math.round(next[2] * 1000) + for (let ms = fromMs + 100; ms < toMs; ms += 100) { + const speed = gs + ((next[1] - gs) * (ms - fromMs)) / (toMs - fromMs) + pose = updateSurfaceAnimation(callsign, { ...input([inContact, speed, ms / 1000]), isReplay }) + } + } + return pose as SurfacePose + } + /** Final for 3 s, then a touchdown at 130 kt at data time 3 s. */ + const FINAL_AND_TOUCHDOWN: Step[] = [ + [false, 140, 0], + [true, 130, 3], + ] + + it('a landing: spoilers fully out 1 s after touchdown, reversers from 1.5 s after it over 2 s', () => { + expect(fly('LAND', [...FINAL_AND_TOUCHDOWN, [true, 127, 4]])).toMatchObject({ spoiler: 1, reverser: 0 }) + expect(fly('LAND', [[true, 125, 4.4]]).reverser).toBe(0) + // Deploying from the frame at TD+1.5 s, whose interval began at TD+1.4 s: 1.1 s of a 2 s travel by TD+2.5 s + expect( + fly('LAND', [ + [true, 124, 4.5], + [true, 124, 5.5], + ]).reverser, + ).toBeCloseTo(0.55, 9) + expect(fly('LAND', [[true, 120, 6.3]]).reverser).toBeCloseTo(0.95, 9) + expect(fly('LAND', [[true, 120, 6.4]]).reverser).toBeCloseTo(1, 9) + }) + + it('a landing: both stow at 60 kt, spoilers over 1.5 s and reversers over 2 s', () => { + const atStow = fly('STOW', [...FINAL_AND_TOUCHDOWN, [true, 100, 10], [true, 61, 19.9]]) + expect(atStow).toMatchObject({ spoiler: 1, reverser: 1 }) + const stowing = fly('STOW', [ + [true, 60, 20], + [true, 59, 20.65], + ]) + expect(stowing.spoiler).toBeCloseTo(0.5, 9) + expect(stowing.reverser).toBeCloseTo(0.625, 9) + expect(fly('STOW', [[true, 55, 22.1]])).toMatchObject({ spoiler: 0, reverser: 0 }) + }) + + it('a landing at a steady groundspeed deploys the spoilers but no reversers', () => { + expect(fly('STEADY', [...FINAL_AND_TOUCHDOWN, [true, 130, 8]])).toMatchObject({ spoiler: 1, reverser: 0 }) + }) + + it('a landing driven at 150 ms frame steps deploys the reversers', () => { + updateSurfaceAnimation('SLOWFPS', input([false, 140, 0])) + let pose = updateSurfaceAnimation('SLOWFPS', input([true, 130, 3])) + for (let k = 1; k <= 30; k++) pose = updateSurfaceAnimation('SLOWFPS', input([true, 130 - k * 0.45, 3 + k * 0.15])) + expect(pose.reverser).toBeGreaterThan(0) + }) + + it('a touchdown at or below 60 kt deploys nothing', () => { + expect( + fly('SLOW', [ + [false, 58, 0], + [true, 58, 3], + [true, 50, 8], + ]), + ).toMatchObject({ spoiler: 0, reverser: 0 }) + }) + + it('an aircraft first seen in contact on the rollout deploys nothing', () => { + expect( + fly('SPAWN', [ + [true, 120, 0], + [true, 100, 6], + ]), + ).toMatchObject({ spoiler: 0, reverser: 0 }) + }) + + it('a contact flicker under 2 s on a rollout without a touchdown seen is not a touchdown', () => { + expect( + fly('FLICKER', [ + [true, 120, 0], + [false, 118, 2], + [true, 115, 3.9], + [true, 110, 6], + ]), + ).toMatchObject({ spoiler: 0, reverser: 0 }) + }) + + it('a contact flicker on the rollout stows toward 0 and resumes the same rollout, with no new reverser delay', () => { + fly('ROLL', [...FINAL_AND_TOUCHDOWN, [true, 115, 8]]) + const inFlicker = fly('ROLL', [[false, 113, 8.5]]) + expect(inFlicker.spoiler).toBeLessThan(1) + expect(inFlicker.reverser).toBeLessThan(1) + const after = fly('ROLL', [ + [true, 112, 9], + [true, 110, 10], + ]) + expect(after.spoiler).toBe(1) + expect(after.reverser).toBe(1) + }) + + it('a takeoff roll deploys nothing and the wheels coast to a stop 5 s after liftoff', () => { + const roll = fly('TKOF', [ + [true, 0, 0], + [true, 60, 2], + [true, 150, 32], + ]) + expect(roll).toMatchObject({ spoiler: 0, reverser: 0 }) + const liftoffRate = roll.wheelRevPerSec + expect(liftoffRate).toBeCloseTo((150 * KNOTS_TO_MS) / (2 * Math.PI * 0.55), 9) + const coasting = fly('TKOF', [ + [false, 152, 32.1], + [false, 155, 34.6], + ]) + expect(coasting).toMatchObject({ spoiler: 0, reverser: 0 }) + expect(coasting.wheelRevPerSec).toBeCloseTo(liftoffRate / 2, 9) + expect(fly('TKOF', [[false, 160, 37.1]]).wheelRevPerSec).toBe(0) + }) + + it('a pushback turns the wheels backwards', () => { + updateSurfaceAnimation('PUSH', input([true, 0, 0], 90, 90)) + const pose = updateSurfaceAnimation('PUSH', input([true, 3, 1], 90, 270)) + expect(pose.wheelRevPerSec).toBeLessThan(0) + expect(pose.wheelPhase).toBeGreaterThan(0.5) + }) + + it('a paused replay (constant data time) holds the reverser mid-travel and the wheels still', () => { + const before = fly('PAUSE', [...FINAL_AND_TOUCHDOWN, [true, 124, 5.5]]) + expect(before.reverser).toBeCloseTo(0.55, 9) + for (let i = 0; i < 30; i++) updateSurfaceAnimation('PAUSE', input([true, 124, 5.5])) + const held = updateSurfaceAnimation('PAUSE', input([true, 124, 5.5])) + expect(held.reverser).toBe(before.reverser) + expect(held.spoiler).toBe(1) + expect(held.wheelPhase).toBe(before.wheelPhase) + }) + + it('a seek back snaps the surfaces to their targets and keeps the wheel phase', () => { + const before = fly('SEEKB', [...FINAL_AND_TOUCHDOWN, [true, 124, 5.5]]) + const after = updateSurfaceAnimation('SEEKB', input([false, 140, 1])) + expect(after).toMatchObject({ spoiler: 0, reverser: 0 }) + expect(after.wheelPhase).toBe(before.wheelPhase) + }) + + it('a replay seek forward over 5 s snaps the surfaces to their targets and keeps the wheel phase', () => { + const before = fly('SEEKF', [...FINAL_AND_TOUCHDOWN, [true, 124, 5.5]], true) + const after = updateSurfaceAnimation('SEEKF', { ...input([true, 50, 30]), isReplay: true }) + expect(after).toMatchObject({ spoiler: 0, reverser: 0 }) + expect(after.wheelPhase).toBe(before.wheelPhase) + }) + + it('replay scrubbed to an airborne moment, then live on a takeoff roll above 60 kt, deploys nothing', () => { + fly( + 'LIVEA', + [ + [false, 140, 0], + [false, 140, 1], + ], + true, + ) + expect( + fly('LIVEA', [ + [true, 80, 600], + [true, 85, 601], + ]), + ).toMatchObject({ spoiler: 0, reverser: 0 }) + }) + + it('replay mid-rollout, then live on a takeoff roll, stows the surfaces and leaves the reversers unlatched', () => { + expect(fly('LIVEB', [...FINAL_AND_TOUCHDOWN, [true, 115, 8]], true)).toMatchObject({ spoiler: 1, reverser: 1 }) + expect(updateSurfaceAnimation('LIVEB', input([true, 70, 600]))).toMatchObject({ spoiler: 0, reverser: 0 }) + expect( + fly('LIVEB', [ + [true, 75, 601], + [true, 65, 604], + ]), + ).toMatchObject({ spoiler: 0, reverser: 0 }) + }) + + it("takes the frame's replay flag from the aircraft state: replay mid-rollout, then a live state, is a seek", () => { + /** A vNAS aircraft in contact at `gs` knots, `s` seconds of data time from T0 */ + const aircraftAt = (gs: number, s: number, isReplay: boolean) => ({ + inContact: true, + heightAboveGround: 0, + groundBlendWeight: 1, + groundBlendDescending: false, + interpolatedGroundspeed: gs, + interpolatedHeading: 90, + track: 90, + timestamp: T0 + s * 1000, + isReplay, + }) + expect(surfaceFrameFor(aircraftAt(115, 8, true), 0.55).isReplay).toBe(true) + fly('STATEB', [...FINAL_AND_TOUCHDOWN, [true, 115, 8]], true) + const live = surfaceFrameFor(aircraftAt(70, 600, false), 0.55) + expect(live.isReplay).toBe(false) + expect(updateSurfaceAnimation('STATEB', live)).toMatchObject({ spoiler: 0, reverser: 0 }) + }) + + it('live: a 6 s data gap mid-rollout continues the rollout instead of snapping', () => { + fly('GAP', [...FINAL_AND_TOUCHDOWN, [true, 115, 8]]) + expect(updateSurfaceAnimation('GAP', input([true, 100, 14]))).toMatchObject({ spoiler: 1, reverser: 1 }) + }) + + it('a touch-and-go: re-accelerating 10 kt above the rollout minimum stows both before liftoff', () => { + fly('TGO', [...FINAL_AND_TOUCHDOWN, [true, 110, 10]]) + const stowed = fly('TGO', [ + [true, 115, 10.1], + [true, 121, 11], + [true, 125, 13.5], + ]) + expect(stowed).toMatchObject({ spoiler: 0, reverser: 0 }) + }) + + it('an aircraft first seen out of contact 1 s before touchdown deploys the spoilers at touchdown', () => { + const pose = fly('LATE', [ + [false, 140, 0], + [true, 130, 1], + [true, 128, 2.1], + ]) + expect(pose.spoiler).toBe(1) + }) + + it('once deployed, the reversers stay out through groundspeed noise around a steady speed', () => { + expect(fly('NOISE', [...FINAL_AND_TOUCHDOWN, [true, 120, 6.5]]).reverser).toBe(1) + let pose = fly('NOISE', [[true, 119, 6.6]]) + for (let k = 1; k <= 20; k++) { + pose = updateSurfaceAnimation('NOISE', input([true, k % 2 === 0 ? 118.2 : 117.8, 6.6 + k * 0.1])) + } + expect(pose.reverser).toBe(1) + }) + + describe('surfaceFrameFor', () => { + /** An aircraft in the shared contact at a 0.9 landing-blend weight, with or without a height above ground */ + const aircraft = (heightAboveGround: number | null) => ({ + inContact: true, + heightAboveGround, + groundBlendWeight: 0.9, + groundBlendDescending: true, + interpolatedGroundspeed: 100, + interpolatedHeading: 90, + track: 90, + timestamp: T0, + isReplay: false, + }) + + it('passes the blend weight and whether it came from the landing blend through for a source without a height above ground', () => { + expect(surfaceFrameFor(aircraft(null), 0.55)).toMatchObject({ groundBlendWeight: 0.9, groundBlendDescending: true }) + }) + + it('passes null for both with a height above ground (vNAS)', () => { + expect(surfaceFrameFor(aircraft(2), 0.55)).toMatchObject({ groundBlendWeight: null, groundBlendDescending: null }) + }) + }) + + describe('surface contact for a source without a height above ground', () => { + /** A frame at 100 kt with the shared contact, blend weight and landing-blend flag given, `s` seconds from T0 */ + const heightless = (inContact: boolean, descending: boolean, weight: number, s: number): SurfaceFrameInput => ({ + ...input([inContact, 100, s]), + groundBlendWeight: weight, + groundBlendDescending: descending, + }) + + it.each([ + ['in the landing blend at a 0.9 weight is out of contact', true, true, 0.9, false], + ['in the landing blend at a 0.99 weight is in contact', true, true, 0.99, true], + ['in the departure blend at a 0.9 weight is in contact', true, false, 0.9, true], + ['out of the shared contact at a 1.0 weight is out of contact', false, false, 1, false], + ['out of the shared contact in the landing blend at a 1.0 weight is out of contact', false, true, 1, false], + ])('%s', (_label, inContact, descending, weight, contact) => { + updateSurfaceAnimation('HEIGHTLESS', heightless(inContact, descending, weight, 0)) + const pose = updateSurfaceAnimation('HEIGHTLESS', heightless(inContact, descending, weight, 0.1)) + expect(pose.wheelRevPerSec > 0).toBe(contact) + }) + + it('first seen above 5 kt in the departure blend at a 0.9 weight (RealTraffic-like, no held frames) is in contact', () => { + updateSurfaceAnimation('RTSPAWN', heightless(true, false, 0.9, 0)) + const pose = updateSurfaceAnimation('RTSPAWN', heightless(true, false, 0.9, 0.1)) + expect(pose.wheelRevPerSec).toBeGreaterThan(0) + expect(pose).toMatchObject({ spoiler: 0, reverser: 0 }) + }) + + it('a replay seek onto a rollout in the departure blend at a 0.9 weight turns the wheels and deploys nothing', () => { + updateSurfaceAnimation('SEEKROLL', { ...heightless(true, false, 1, 0), isReplay: true }) + for (let k = 1; k <= 3; k++) { + updateSurfaceAnimation('SEEKROLL', { ...heightless(true, false, 0.9, k * 0.1), isReplay: true }) + } + updateSurfaceAnimation('SEEKROLL', { ...heightless(true, false, 0.9, 0.1), isReplay: true }) + const pose = updateSurfaceAnimation('SEEKROLL', { ...heightless(true, false, 0.9, 0.2), isReplay: true }) + expect(pose.wheelRevPerSec).toBeGreaterThan(0) + expect(pose).toMatchObject({ spoiler: 0, reverser: 0 }) + }) + }) +}) diff --git a/tests/motion/surfaceScenario.test.ts b/tests/motion/surfaceScenario.test.ts new file mode 100644 index 00000000..325432b8 --- /dev/null +++ b/tests/motion/surfaceScenario.test.ts @@ -0,0 +1,280 @@ +// @vitest-environment node +/** + * Spoilers, reversers and wheel spin over the synthetic vNAS flights (TC3D-54): the arrival deploys spoilers at + * touchdown and reversers after it and stows both at 60 kt, with the wheels rolling at groundspeed; the departure + * deploys nothing, turns its wheels backwards in the pushback and lets them coast to a stop after liftoff; the + * go-around never touches down and deploys nothing. + */ +import { beforeAll, describe, expect, it, vi } from 'vitest' +import { KNOTS_TO_MS } from '@/constants/precipitation' +import { buildFlights, type Flight, type FrameRecord, runScenario, type ScenarioResult } from './harness' +import { truthAt } from './sim' + +vi.mock('react', () => { + const api = { + useEffect: (fn: () => unknown) => { + fn() + }, + useState: (v: T) => [v, () => {}], + } + return { ...api, default: api } +}) + +const WHEEL_RADIUS_M = 0.55 // the harness flies a B738 +const revPerSec = (kts: number) => (kts * KNOTS_TO_MS) / (2 * Math.PI * WHEEL_RADIUS_M) + +let result: ScenarioResult + +beforeAll(async () => { + result = await runScenario({ fps: 60, useVnas: true, vnasFirst: false, durationS: 720 }) +}, 300_000) + +const framesOf = (callsign: string): FrameRecord[] => result.frames.get(callsign) ?? [] + +/** The displayed instant (truth local seconds) of the first frame in contact after one out of contact. */ +function touchdownAt(recs: FrameRecord[]): number { + const i = recs.findIndex((r, k) => k > 0 && r.inContact === true && recs[k - 1].inContact === false) + expect(i).toBeGreaterThan(0) + return recs[i].tLocal +} + +/** The displayed instant of the first frame out of contact after one in contact. */ +function liftoffAt(recs: FrameRecord[]): number { + const i = recs.findIndex((r, k) => k > 0 && r.inContact === false && recs[k - 1].inContact === true) + expect(i).toBeGreaterThan(0) + return recs[i].tLocal +} + +const between = (recs: FrameRecord[], from: number, to: number) => recs.filter((r) => r.tLocal >= from && r.tLocal < to) + +describe('the arrival', () => { + it('keeps everything stowed and the wheels still on final', () => { + const recs = framesOf('ARR1') + const final = between(recs, Number.NEGATIVE_INFINITY, touchdownAt(recs)) + expect(final.length).toBeGreaterThan(100) + expect(final.every((r) => r.spoiler === 0 && r.reverser === 0 && r.wheelRevPerSec === 0)).toBe(true) + }) + + it('deploys the spoilers fully within 1 s of touchdown', () => { + const recs = framesOf('ARR1') + const td = touchdownAt(recs) + expect(between(recs, td + 1.05, td + 1.2).every((r) => r.spoiler === 1)).toBe(true) + }) + + it('deploys the reversers from 1.5 s after touchdown, fully 2 s later', () => { + const recs = framesOf('ARR1') + const td = touchdownAt(recs) + expect(between(recs, td, td + 1.45).every((r) => r.reverser === 0)).toBe(true) + expect(between(recs, td + 3.6, td + 4).every((r) => r.reverser === 1)).toBe(true) + }) + + it('stows both at 60 kt: spoilers within 1.5 s and reversers within 2 s', () => { + const recs = framesOf('ARR1') + const td = touchdownAt(recs) + const slow = recs.find((r) => r.tLocal > td && r.gs <= 60) as FrameRecord + expect(slow).toBeDefined() + const deployed = between(recs, td + 4, slow.tLocal) + expect(deployed.length).toBeGreaterThan(100) + expect(deployed.every((r) => r.spoiler === 1 && r.reverser === 1)).toBe(true) + expect(between(recs, slow.tLocal + 1.55, slow.tLocal + 1.7).every((r) => r.spoiler === 0)).toBe(true) + expect(between(recs, slow.tLocal + 2.05, Number.POSITIVE_INFINITY).every((r) => r.reverser === 0)).toBe(true) + }) + + it('rolls the wheels at groundspeed over the circumference in contact', () => { + const rolling = framesOf('ARR1').filter((r) => r.inContact === true) + expect(rolling.length).toBeGreaterThan(1000) + for (const r of rolling) expect(r.wheelRevPerSec).toBeCloseTo(revPerSec(r.gs), 9) + }) +}) + +describe('the departure', () => { + it('deploys neither spoilers nor reversers through the pushback, taxi and takeoff roll', () => { + const recs = framesOf('DEP1') + expect(recs.every((r) => r.spoiler === 0 && r.reverser === 0)).toBe(true) + }) + + it('turns its wheels backwards in the straight pushback', () => { + const push = framesOf('DEP1').filter((r) => r.truth.segment === 'push_straight' && r.gs > 1) + expect(push.length).toBeGreaterThan(100) + expect(push.every((r) => r.wheelRevPerSec < 0)).toBe(true) + }) + + it('lets the wheels coast linearly from the liftoff spin to a stop 5 s after liftoff', () => { + const recs = framesOf('DEP1') + const lo = liftoffAt(recs) + const atLiftoff = recs.find((r) => r.tLocal >= lo) as FrameRecord + const lastRolling = recs[recs.indexOf(atLiftoff) - 1] + const halfway = recs.find((r) => r.tLocal >= lo + 2.5) as FrameRecord + expect(atLiftoff.wheelRevPerSec).toBeCloseTo(lastRolling.wheelRevPerSec, 9) + // The coast counts on data time, which the truth instant tLocal follows to within a frame (1/60 s, 0.0033 here) + const coastRatio = halfway.wheelRevPerSec / lastRolling.wheelRevPerSec + expect(Math.abs(coastRatio - (1 - (halfway.tLocal - lo) / 5))).toBeLessThan(0.004) + expect(between(recs, lo + 5, Number.POSITIVE_INFINITY).every((r) => r.wheelRevPerSec === 0)).toBe(true) + }) +}) + +describe('the go-around', () => { + it('never touches down and deploys nothing', () => { + const recs = framesOf('GA1') + expect(recs.length).toBeGreaterThan(100) + expect(recs.every((r) => r.spoiler === 0 && r.reverser === 0 && r.wheelRevPerSec === 0)).toBe(true) + }) +}) + +/** Drawn height above the terrain (metres) at or below which the drawn aircraft is on it (the 0.1 m terrain offset). */ +const ON_TERRAIN_M = 0.5 + +describe('a VATSIM arrival (no height above ground)', () => { + let vatsim: ScenarioResult + + beforeAll(async () => { + vatsim = await runScenario({ fps: 60, useVnas: false, vnasFirst: false, durationS: 720 }) + }, 300_000) + + it('keeps the spoilers stowed while the drawn aircraft is above the terrain and deploys them after the drawn touchdown', () => { + const recs = vatsim.frames.get('ARR1') ?? [] + const drawnTouchdown = recs.findIndex((r) => r.agl <= ON_TERRAIN_M) + expect(drawnTouchdown).toBeGreaterThan(0) + expect(recs.slice(0, drawnTouchdown).every((r) => r.spoiler === 0)).toBe(true) + expect(recs.slice(drawnTouchdown).some((r) => r.spoiler === 1)).toBe(true) + }) +}) + +describe('a VATSIM arrival reported 8 m above the drawn terrain', () => { + const TERRAIN_OFFSET_M = -8 + let high: ScenarioResult + + beforeAll(async () => { + high = await runScenario({ + fps: 60, + useVnas: false, + vnasFirst: false, + durationS: 720, + terrainOffsetM: TERRAIN_OFFSET_M, + }) + }, 300_000) + + it('deploys the spoilers fully within 2 s of the drawn touchdown and keeps them out through the rollout to 60 kt', () => { + const recs = high.frames.get('ARR1') ?? [] + const drawnTouchdown = recs.find((r) => r.agl - TERRAIN_OFFSET_M <= ON_TERRAIN_M) as FrameRecord + expect(drawnTouchdown).toBeDefined() + // Contact for the surfaces begins at a 0.99 blend weight, a few tenths of a second after the drawn 0.5 m + const deployed = recs.find((r) => r.spoiler === 1) as FrameRecord + expect(deployed).toBeDefined() + expect(deployed.tLocal - drawnTouchdown.tLocal).toBeLessThan(2) + const slow = recs.find((r) => r.tLocal > deployed.tLocal && r.gs <= 60) as FrameRecord + expect(slow).toBeDefined() + const rollout = between(recs, deployed.tLocal, slow.tLocal) + expect(rollout.length).toBeGreaterThan(100) + expect(rollout.every((r) => r.spoiler === 1)).toBe(true) + }) + + it('turns the wheels through the taxi in', () => { + const taxi = (high.frames.get('ARR1') ?? []).filter((r) => r.truth.segment === 'taxi_in' && r.gs > 1) + expect(taxi.length).toBeGreaterThan(100) + expect(taxi.every((r) => r.wheelRevPerSec > 0)).toBe(true) + }) +}) + +/** The flights as the harness builds them, for choosing when a feed starts. */ +const FLIGHTS = buildFlights().flights +const flightOf = (callsign: string) => FLIGHTS.find((f) => f.callsign === callsign) as Flight + +/** The first scenario second of the 15 s VATSIM report grid (3 + 15 n) at or after `s`. */ +const reportAtOrAfter = (s: number) => 3 + 15 * Math.ceil((s - 3) / 15) + +/** The first VATSIM report of `f` whose truth is on the glide between 12 m and 30 m AGL: short final, in the landing blend. */ +function shortFinalReport(f: Flight): number { + for (let tg = 3; tg <= 720; tg += 15) { + const truth = truthAt(f.samples, tg - f.offset) + if (truth && truth.segment === 'glide' && truth.agl > 12 && truth.agl < 30) return tg + } + throw new Error('no VATSIM report falls on the short final') +} + +describe('a VATSIM aircraft first seen 8 m above the drawn terrain', () => { + const TERRAIN_OFFSET_M = -8 + + describe('taxiing in', () => { + let taxiing: ScenarioResult + + beforeAll(async () => { + const arr = flightOf('ARR1') + taxiing = await runScenario({ + fps: 60, + useVnas: false, + vnasFirst: false, + durationS: 420, + terrainOffsetM: TERRAIN_OFFSET_M, + vatsimFromS: { ARR1: reportAtOrAfter(arr.offset + arr.events.taxi_in + 5) }, + }) + }, 300_000) + + it('spins its wheels on every taxi frame', () => { + const recs = taxiing.frames.get('ARR1') ?? [] + expect(recs.every((r) => r.truth.segment === 'taxi_in' || r.truth.segment === 'taxi_in_stop')).toBe(true) + const taxi = recs.filter((r) => r.truth.segment === 'taxi_in' && r.gs > 1) + expect(taxi.length).toBeGreaterThan(100) + expect(taxi.every((r) => r.wheelRevPerSec > 0)).toBe(true) + }) + }) + + describe('on a takeoff roll above 60 kt', () => { + let rolling: ScenarioResult + + beforeAll(async () => { + const dep = flightOf('DEP1') + rolling = await runScenario({ + fps: 60, + useVnas: false, + vnasFirst: false, + durationS: 300, + terrainOffsetM: TERRAIN_OFFSET_M, + vatsimFromS: { DEP1: reportAtOrAfter(dep.offset + dep.events.takeoff_roll + 18) }, + }) + }, 300_000) + + it('deploys nothing through liftoff', () => { + const recs = rolling.frames.get('DEP1') ?? [] + expect(recs[0]?.truth.segment).toBe('takeoff_roll') + expect(recs.some((r) => r.truth.segment === 'initial_climb')).toBe(true) + expect(recs.every((r) => r.spoiler === 0 && r.reverser === 0)).toBe(true) + }) + }) +}) + +describe('a VATSIM arrival first seen on short final below 30 m', () => { + let late: ScenarioResult + + beforeAll(async () => { + late = await runScenario({ + fps: 60, + useVnas: false, + vnasFirst: false, + durationS: 420, + vatsimFromS: { ARR1: shortFinalReport(flightOf('ARR1')) }, + }) + }, 300_000) + + it('deploys the spoilers after the drawn touchdown', () => { + const recs = late.frames.get('ARR1') ?? [] + expect(recs[0]?.truth.segment).toBe('glide') + const drawnTouchdown = recs.findIndex((r) => r.gs > 5 && r.agl <= ON_TERRAIN_M) + expect(drawnTouchdown).toBeGreaterThan(0) + expect(recs.slice(0, drawnTouchdown).every((r) => r.spoiler === 0)).toBe(true) + expect(recs.slice(drawnTouchdown).some((r) => r.spoiler === 1)).toBe(true) + }) +}) + +describe('an arrival drawn at 150 ms frame steps', () => { + let slow: ScenarioResult + + beforeAll(async () => { + slow = await runScenario({ fps: 1000 / 150, useVnas: true, vnasFirst: false, durationS: 720 }) + }, 300_000) + + it('deploys the reversers on the rollout', () => { + const recs = slow.frames.get('ARR1') ?? [] + expect(recs.some((r) => r.reverser === 1)).toBe(true) + }) +})