mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 07:12:21 +00:00
fix(cockpit): an application subject's eye eases off the floor instead of jumping: its anchor is no longer clamped to the floor, and the eye's clearance above it changes at most 1.5 m/s (easeCockpitLift)
This commit is contained in:
1 parent
f75179eea0
commit
01a74fceec
4 files changed
+225
-47
No files matched your search
@@ -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.
|
||||
|
||||
+151
-44
@@ -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);
|
||||
});
|
||||
+29
-2
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user