diff --git a/src/cockpitMath.js b/src/cockpitMath.js index 53daa63d1..7c81c2b4c 100644 --- a/src/cockpitMath.js +++ b/src/cockpitMath.js @@ -68,6 +68,42 @@ export function cockpitSurfaceWaitExpired(nowMs, startedMs, timeoutMs = 5000) { return nowMs - startedMs >= timeoutMs; } +/** + * How fast the clearance a ground clamp adds under an application subject's + * eye comes and goes (m/s). + */ +export const COCKPIT_SUBJECT_LIFT_MPS = 1.5; + +/** + * The lift the ground clamp gives an application subject's eye this update. + * The floor is read from coarse cells that refine as tiles load, so the lift + * it asks for jumps; a ridden eye takes the clearance part of it at + * `rateMps`, while what it needs to stay out of the floor itself (`hardM`) + * applies at once. + * @param {number|null} previousM The lift given last update; null on entry. + * @param {number} targetM The lift the floor and clearance ask for (m). + * @param {number} hardM The lift that keeps the eye out of the floor (m). + * @param {number} dtSec Seconds since the last update. + * @param {number} [rateMps] + * @returns {number} At least 0, at least `hardM`. + */ +export function easeCockpitLift( + previousM, + targetM, + hardM, + dtSec, + rateMps = COCKPIT_SUBJECT_LIFT_MPS, +) { + const target = Number.isFinite(targetM) ? Math.max(0, targetM) : 0; + const hard = Number.isFinite(hardM) ? Math.max(0, hardM) : 0; + if (!Number.isFinite(previousM)) return Math.max(target, hard); + const step = + Math.max(0, Number.isFinite(rateMps) ? rateMps : 0) * + Math.max(0, Number.isFinite(dtSec) ? dtSec : 0); + const eased = previousM + Math.max(-step, Math.min(step, target - previousM)); + return Math.max(0, hard, eased); +} + /** * Keep the cockpit camera above the shared rendered-surface floor. * Unknown floors preserve the proposed camera height. diff --git a/src/cockpitMath.test.mjs b/src/cockpitMath.test.mjs index a59a4b029..845bcce42 100644 --- a/src/cockpitMath.test.mjs +++ b/src/cockpitMath.test.mjs @@ -10,6 +10,8 @@ import { cockpitGroundSafeHeight, cockpitSurfaceWaitExpired, cockpitUiUpdateDue, + COCKPIT_SUBJECT_LIFT_MPS, + easeCockpitLift, compassDivisions, compassMarkerSlot, scrollingCompassDivisions, @@ -45,7 +47,11 @@ test('cockpit anchor correction cannot turn a forward step into a reversal', () const speedMps = 200; const dtSec = 0.1; const forwardStepM = speedMps * dtSec; - const backwardCorrectionM = cockpitAnchorCorrectionStep(1000, speedMps, dtSec); + const backwardCorrectionM = cockpitAnchorCorrectionStep( + 1000, + speedMps, + dtSec, + ); assert.ok(backwardCorrectionM > 0); assert.ok(backwardCorrectionM < forwardStepM); }); @@ -81,10 +87,7 @@ test('cockpit Context scope distinguishes radius-complete feeds from viewport in }); test('cockpit Context scope preserves zero and replaces missing values intentionally', () => { - assert.equal( - formatCockpitContextScope('', 0), - '— · 0 KM AIR/SEA WINDOW', - ); + assert.equal(formatCockpitContextScope('', 0), '— · 0 KM AIR/SEA WINDOW'); assert.equal( formatCockpitContextScope(undefined, Number.NaN, {}), '— · — KM AIR/SEA WINDOW', @@ -221,26 +224,29 @@ test('relative bearing stays on the shortest signed arc', () => { }); test('HUD rail layout centers between intersecting upper and lower obstacles', () => { - assert.deepEqual(resolveHudRailLayout({ - viewportHeight: 1000, - panelHeight: 200, - laneLeft: 50, - laneRight: 410, - baseTop: 280, - baseBottom: 940, - gap: 10, - obstacles: [ - { left: 40, right: 240, top: 320, bottom: 460 }, - { left: 300, right: 1000, top: 780, bottom: 930 }, - { left: 900, right: 1100, top: 300, bottom: 700 }, - ], - }), { - top: 520, - maxHeight: 300, - safeTop: 470, - safeBottom: 770, - constrained: false, - }); + assert.deepEqual( + resolveHudRailLayout({ + viewportHeight: 1000, + panelHeight: 200, + laneLeft: 50, + laneRight: 410, + baseTop: 280, + baseBottom: 940, + gap: 10, + obstacles: [ + { left: 40, right: 240, top: 320, bottom: 460 }, + { left: 300, right: 1000, top: 780, bottom: 930 }, + { left: 900, right: 1100, top: 300, bottom: 700 }, + ], + }), + { + top: 520, + maxHeight: 300, + safeTop: 470, + safeBottom: 770, + constrained: false, + }, + ); }); test('HUD rail layout constrains an oversized panel to the safe corridor', () => { @@ -294,7 +300,10 @@ function contextSnapshot(subject, extra = {}) { } test('Contact panel hides only when there is no snapshot at all', () => { - const readout = resolveCockpitContextReadout({ snapshot: null, info: TRACKED_INFO }); + const readout = resolveCockpitContextReadout({ + snapshot: null, + info: TRACKED_INFO, + }); assert.equal(readout.visible, false); assert.equal(readout.mode, 'standby'); assert.equal( @@ -310,14 +319,22 @@ test('Contact panel survives NEXT onto a vessel or installation subject', () => // camera: "click next... whole left panel disappears"). for (const subject of [ { layerId: 'ais-live-vessels', id: '353136000', label: 'MAERSK DETROIT' }, - { layerId: 'military-installations', id: 'fort-hood', label: 'FORT CAVAZOS' }, + { + layerId: 'military-installations', + id: 'fort-hood', + label: 'FORT CAVAZOS', + }, { layerId: 'military', id: 'ae01ce', label: 'RCH451' }, ]) { const readout = resolveCockpitContextReadout({ snapshot: contextSnapshot(subject), info: TRACKED_INFO, }); - assert.equal(readout.visible, true, `${subject.layerId} subject must keep the panel up`); + assert.equal( + readout.visible, + true, + `${subject.layerId} subject must keep the panel up`, + ); assert.equal(readout.mode, 'foreign'); assert.equal(readout.subjectMatchesTracked, false); } @@ -325,7 +342,11 @@ test('Contact panel survives NEXT onto a vessel or installation subject', () => test('a foreign subject dashes the aircraft-relative fields and keeps the rest live', () => { const readout = resolveCockpitContextReadout({ - snapshot: contextSnapshot({ layerId: 'ais-live-vessels', id: '353136000', label: 'MAERSK DETROIT' }), + snapshot: contextSnapshot({ + layerId: 'ais-live-vessels', + id: '353136000', + label: 'MAERSK DETROIT', + }), info: TRACKED_INFO, }); assert.equal(readout.visible, true); @@ -334,12 +355,20 @@ test('a foreign subject dashes the aircraft-relative fields and keeps the rest l // frame while every other row value is measured from the subject; rendering // both live would present one mixed-frame reading as a single measurement. assert.equal(readout.aircraftRelative, false); - assert.equal(readout.contactLost, false, 'subject-frame values still refresh'); + assert.equal( + readout.contactLost, + false, + 'subject-frame values still refresh', + ); }); test('the tracked aircraft as subject keeps every field in its own frame', () => { const readout = resolveCockpitContextReadout({ - snapshot: contextSnapshot({ layerId: 'flights', id: 'aaa077', label: 'SWA1234' }), + snapshot: contextSnapshot({ + layerId: 'flights', + id: 'aaa077', + label: 'SWA1234', + }), info: TRACKED_INFO, }); assert.equal(readout.visible, true); @@ -358,7 +387,11 @@ test('a fast-culled subject keeps the panel up as CONTACT LOST instead of collap snapshot: contextSnapshot(subject, { subjectPresent: false }), info: TRACKED_INFO, }); - assert.equal(readout.visible, true, 'a lost contact must not take the panel down'); + assert.equal( + readout.visible, + true, + 'a lost contact must not take the panel down', + ); assert.equal(readout.mode, 'lost'); assert.equal(readout.contactLost, true, 'last-known values stay on screen'); } @@ -366,7 +399,10 @@ test('a fast-culled subject keeps the panel up as CONTACT LOST instead of collap test('an explicitly present subject is never reported lost', () => { const present = resolveCockpitContextReadout({ - snapshot: contextSnapshot({ layerId: 'flights', id: 'aaa077' }, { subjectPresent: true }), + snapshot: contextSnapshot( + { layerId: 'flights', id: 'aaa077' }, + { subjectPresent: true }, + ), info: TRACKED_INFO, }); assert.equal(present.contactLost, false); @@ -385,20 +421,36 @@ test('the tracked flight layer is resolved by normalized tracked identity', () = // Both layers describe a tracked aircraft during a cross-layer handoff; // civilian-first precedence would hand the cockpit the wrong aircraft. assert.equal( - resolveTrackedAircraftInfo({ civilian, military, trackedId: 'military:ae01ce' }).layerId, + resolveTrackedAircraftInfo({ + civilian, + military, + trackedId: 'military:ae01ce', + }).layerId, 'military', ); assert.equal( - resolveTrackedAircraftInfo({ civilian, military, trackedId: 'military:ae01ce' }).icao24, + resolveTrackedAircraftInfo({ + civilian, + military, + trackedId: 'military:ae01ce', + }).icao24, 'ae01ce', ); assert.equal( - resolveTrackedAircraftInfo({ civilian, military, trackedId: 'flights:aaa077' }).layerId, + resolveTrackedAircraftInfo({ + civilian, + military, + trackedId: 'flights:aaa077', + }).layerId, 'flights', ); // Case-insensitive: layers stamp lowercase hex, callers may not. assert.equal( - resolveTrackedAircraftInfo({ civilian, military, trackedId: 'MILITARY:AE01CE' }).layerId, + resolveTrackedAircraftInfo({ + civilian, + military, + trackedId: 'MILITARY:AE01CE', + }).layerId, 'military', ); }); @@ -408,30 +460,85 @@ test('tracked-identity resolution falls back to layer precedence', () => { const military = { icao24: 'ae01ce' }; // No stamped identity (or one that matches neither layer) keeps the historic // civilian-first order so tracking paths without gevTrackedId still work. - assert.equal(resolveTrackedAircraftInfo({ civilian, military }).layerId, 'flights'); assert.equal( - resolveTrackedAircraftInfo({ civilian, military, trackedId: 'satellites:25544' }).layerId, + resolveTrackedAircraftInfo({ civilian, military }).layerId, 'flights', ); - assert.equal(resolveTrackedAircraftInfo({ military, trackedId: '' }).layerId, 'military'); - assert.equal(resolveTrackedAircraftInfo({ trackedId: 'flights:aaa077' }), null); + assert.equal( + resolveTrackedAircraftInfo({ + civilian, + military, + trackedId: 'satellites:25544', + }).layerId, + 'flights', + ); + assert.equal( + resolveTrackedAircraftInfo({ military, trackedId: '' }).layerId, + 'military', + ); + assert.equal( + resolveTrackedAircraftInfo({ trackedId: 'flights:aaa077' }), + null, + ); assert.equal(resolveTrackedAircraftInfo(), null); }); test('cockpit emergency wording states the broadcast code, not a conclusion', () => { assert.equal( - formatCockpitEmergency({ label: 'General emergency', squawk: '7700', source: 'squawk', severity: 'emergency' }), + formatCockpitEmergency({ + label: 'General emergency', + squawk: '7700', + source: 'squawk', + severity: 'emergency', + }), 'SQUAWK 7700 · GENERAL EMERGENCY', ); assert.equal( - formatCockpitEmergency({ label: 'Downed aircraft', squawk: '1200', source: 'ads-b', severity: 'emergency' }), + formatCockpitEmergency({ + label: 'Downed aircraft', + squawk: '1200', + source: 'ads-b', + severity: 'emergency', + }), 'ADS-B EMERGENCY · DOWNED AIRCRAFT', 'an unrelated squawk is not quoted beside an ADS-B status', ); assert.equal( - formatCockpitEmergency({ label: 'Minimum fuel', squawk: null, source: 'ads-b', severity: 'priority' }), + formatCockpitEmergency({ + label: 'Minimum fuel', + squawk: null, + source: 'ads-b', + severity: 'priority', + }), 'ADS-B PRIORITY · MINIMUM FUEL', ); assert.equal(formatCockpitEmergency(null), null); assert.equal(formatCockpitEmergency({ label: '' }), null); }); + +test('an application subject’s ground lift eases its clearance, never the floor', () => { + // On entry the lift asked for applies at once. + assert.equal(easeCockpitLift(null, 1.5, 0, 0.016), 1.5); + // A floor cell that rises by a metre is met at the lift rate... + let lift = 0; + const frames = []; + for (let i = 0; i < 60; i += 1) { + lift = easeCockpitLift(lift, 1, 0, 0.016); + frames.push(lift); + } + assert.ok(frames[0] <= COCKPIT_SUBJECT_LIFT_MPS * 0.016 + 1e-9); + for (let i = 1; i < frames.length; i += 1) + assert.ok( + frames[i] - frames[i - 1] <= COCKPIT_SUBJECT_LIFT_MPS * 0.016 + 1e-9, + ); + assert.ok(Math.abs(frames.at(-1) - 1) < 1e-9, 'reached in under a second'); + // ...and given back the same way. + assert.ok(Math.abs(easeCockpitLift(1, 0, 0, 0.1) - 0.85) < 1e-9); + // What keeps the eye out of the floor itself is never eased. + assert.equal(easeCockpitLift(0, 201.5, 200, 0.016), 200); + assert.equal( + easeCockpitLift(0.2, -3, -3, 0.016), + 0.2 - COCKPIT_SUBJECT_LIFT_MPS * 0.016, + ); + assert.equal(easeCockpitLift(0, Number.NaN, Number.NaN, 0.016), 0); +}); diff --git a/src/ui/cockpitCamera.js b/src/ui/cockpitCamera.js index b6da530b7..ce2a8dae5 100644 --- a/src/ui/cockpitCamera.js +++ b/src/ui/cockpitCamera.js @@ -3,6 +3,7 @@ import * as Cesium from 'cesium'; import { cockpitAnchorCorrectionStep, cockpitGroundSafeHeight, + easeCockpitLift, cockpitSurfaceWaitExpired, cockpitUiUpdateDue, slewHeading, @@ -412,6 +413,7 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { if (!this.cockpitAnchorValid) { Cesium.Cartesian3.clone(target, this.cockpitAnchor); this.cockpitAnchorValid = true; + this.subjectLiftM = null; } // First-person motion cannot use the delayed feed correction as a raw @@ -595,7 +597,14 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { cockpitFloorM = targetCartographic.height - this.services.GROUND_FLOOR_LIFT_M; } - if (groundClamp && anchorCartographic && Number.isFinite(cockpitFloorM)) { + // An application subject's anchor is where its provider puts it; only its + // eye is kept out of the floor, below. + if ( + groundClamp && + info.generic !== true && + anchorCartographic && + Number.isFinite(cockpitFloorM) + ) { const minimumAnchorHeightM = cockpitGroundSafeHeight( anchorCartographic.height, cockpitFloorM, @@ -686,11 +695,26 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { cameraFloorM, ); } - const safeHeightM = cockpitGroundSafeHeight( + let safeHeightM = cockpitGroundSafeHeight( cameraCartographic.height, cockpitFloorM, profile.minClearanceM, ); + // An application subject's eye rides its provider's smoothed height: + // the clearance over the coarse, refining floor eases in and out + // rather than jumping at each cell (easeCockpitLift). + if (info.generic === true) { + const hardM = + cockpitGroundSafeHeight(cameraCartographic.height, cockpitFloorM, 0) - + cameraCartographic.height; + this.subjectLiftM = easeCockpitLift( + this.subjectLiftM, + safeHeightM - cameraCartographic.height, + hardM, + dtSec, + ); + safeHeightM = cameraCartographic.height + this.subjectLiftM; + } if (safeHeightM !== cameraCartographic.height) { cameraCartographic.height = safeHeightM; Cesium.Ellipsoid.WGS84.cartographicToCartesian( @@ -698,6 +722,9 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { this.scratchCamera, ); } + } else if (info.generic === true) { + // Inside a structure there is no clearance to give back. + this.subjectLiftM = 0; } this.viewer.camera.setView({ diff --git a/src/ui/cockpitSubjectView.test.mjs b/src/ui/cockpitSubjectView.test.mjs index f7a9de2c4..ed0236044 100644 --- a/src/ui/cockpitSubjectView.test.mjs +++ b/src/ui/cockpitSubjectView.test.mjs @@ -406,10 +406,18 @@ test('the eye stands its height above the frame, and the ground clamp can be lif cockpit.update(); assert.equal(shots.length, before + 1); // Inside a structure the frame stands below the rendered floor: clamped, - // the eye is pushed up to the floor; with groundClamp false it stays. + // the eye is pushed out of the floor at once and its clearance eases in; + // with groundClamp false it stays. setFloor(FLOOR_M + 200); clock.advance(16); cockpit.update(); + const lifted = shotHeight(shots); + assert.ok(lifted >= FLOOR_M + 200 - 0.01, 'out of the floor at once'); + assert.ok(lifted < FLOOR_M + 200 + 1.5 - 0.01, 'the clearance eases in'); + for (let i = 0; i < 80; i += 1) { + clock.advance(16); + cockpit.update(); + } assert.ok(shotHeight(shots) >= FLOOR_M + 200 + 1.5 - 0.01, 'clamped'); ride.subject.frame = { ...ride.subject.frame, groundClamp: false }; clock.advance(16);