mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
286 lines
14 KiB
JavaScript
286 lines
14 KiB
JavaScript
import * as Cesium from 'cesium';
|
||
/**
|
||
* Model-space visual centres measured from the shipped GLBs' scene-space AABBs.
|
||
*
|
||
* glTF stores these assets Y-up. Cesium's model frame is Z-up, so a glTF
|
||
* centre `[x, y, z]` becomes `[x, -z, y]` before the layer's ENU modelMatrix
|
||
* is applied. Values remain in native model units; callers supply the Model's
|
||
* effective rendered `computedScale` because Cesium does not bake it into
|
||
* modelMatrix and may raise it above `scale` to satisfy minimumPixelSize.
|
||
*/
|
||
const CENTERED_MODEL = Object.freeze([0, 0, 0]);
|
||
|
||
export const MODEL_VISUAL_CENTER_NATIVE = Object.freeze({
|
||
'/models/airplane.glb': CENTERED_MODEL,
|
||
'/models/jet.glb': CENTERED_MODEL,
|
||
});
|
||
|
||
/**
|
||
* Aft-belly trail attachment points, measured on each approved model's actual
|
||
* MESH SURFACE. Every bundled asset is baked to one convention (see
|
||
* aircraftClass.js): origin at the bbox centre, X = length with the NOSE at
|
||
* local −X, glTF Y = up (Cesium-local Z here).
|
||
*
|
||
* Each value is the point of the hull's CENTRELINE PROFILE (the y = 0 slice of
|
||
* the mesh) closest to that model's aft-belly AABB corner. Three things follow
|
||
* from that construction, and each is asserted rather than described in
|
||
* modelScale.test.mjs:
|
||
*
|
||
* ON THE HULL. This is the correction that matters. The previous round put
|
||
* the anchor ON the AABB corner itself, reasoning that an anchor aft of
|
||
* every vertex makes non-intersection geometric. It does — but an AABB
|
||
* corner is EMPTY SPACE, and on these airframes it is a long way from
|
||
* any of them: 4.80 m out for airplane.glb and 6.14 m for jet.glb in
|
||
* native units, which at the 450 m framing is a visible gap between the
|
||
* aircraft and the end of its own trail. The trail must terminate on the
|
||
* aeroplane, so the anchor is a surface point.
|
||
*
|
||
* AFT. The head segment is drawn from a point behind the aircraft to this
|
||
* anchor, so an anchor near the middle makes the segment enter at the
|
||
* tail and stop mid-fuselage — a line drawn through the airframe. These
|
||
* sit at 83–96 % of each model's aft extreme, on the aft-most hull the
|
||
* centreline has.
|
||
*
|
||
* BELOW. Nothing of the aircraft is lower than the anchor at or behind its
|
||
* own station, so the trail leaves from underneath the way a wake does
|
||
* rather than out of the roof. On most assets that is a frank belly
|
||
* point; on jet.glb the aft centreline profile has swept up into the
|
||
* tail cone by then, so the anchor sits just above the model's vertical
|
||
* centre (+0.13 native on a 5.63 m half-height) at the exhaust — which
|
||
* is where a fast jet's wake leaves it anyway.
|
||
*
|
||
* Y stays on the centreline — the profile is sliced at y = 0 rather than taking
|
||
* the nearest surface point in the round — so the attachment cannot drift
|
||
* screen-left or screen-right as the aircraft turns. (The unconstrained nearest
|
||
* point would put jet.glb's anchor 1.29 m off-axis.) Values are native model
|
||
* units; callers scale them by Cesium's effective `computedScale`.
|
||
*
|
||
* Locked against the shipped GLBs by modelScale.test.mjs, which re-reads the
|
||
* POSITION buffers and re-derives every property above — re-measure, never
|
||
* re-guess, when an asset changes.
|
||
*/
|
||
export const MODEL_TRAIL_ANCHOR_NATIVE = Object.freeze({
|
||
'/models/airplane.glb': Object.freeze([24.0879, -2.2163, 0]),
|
||
'/models/jet.glb': Object.freeze([18.9103, 0.1266, 0]),
|
||
'/models/bell206.glb': Object.freeze([5.2077, -0.0005, 0]),
|
||
'/models/c172.glb': Object.freeze([3.6816, -0.2425, 0]),
|
||
'/models/citation2.glb': Object.freeze([6.8804, -0.6136, 0]),
|
||
'/models/mq9.glb': Object.freeze([5.1951, -1.1516, 0]),
|
||
'/models/b789.glb': Object.freeze([30.1007, -3.4814, 0]),
|
||
'/models/atr72.glb': Object.freeze([12.9785, -1.2935, 0]),
|
||
});
|
||
|
||
/** @constant Cesium's OWN axis correction for a glTF loaded with the
|
||
* `Model.fromGltfAsync` defaults (upAxis Y, forwardAxis Z), assembled from its
|
||
* exported constants rather than restated as numbers here. `ModelSceneGraph`
|
||
* renders with `modelMatrix x components.transform x axisCorrection`, and the
|
||
* trail anchor has to ride the same chain or it lands on a different axis than
|
||
* the aircraft it is supposed to be attached to. */
|
||
const AXIS_CORRECTION = Cesium.Matrix4.multiplyTransformation(
|
||
Cesium.Axis.Y_UP_TO_Z_UP,
|
||
Cesium.Axis.Z_UP_TO_X_UP,
|
||
new Cesium.Matrix4(),
|
||
);
|
||
const _anchorScratch = new Cesium.Cartesian3();
|
||
const _chainScratch = new Cesium.Matrix4();
|
||
|
||
/**
|
||
* World position of a model-local anchor, through the SAME transform chain
|
||
* Cesium renders the model with.
|
||
*
|
||
* This exists because doing it by hand got it wrong. The anchors used to be
|
||
* stored after a single glTF Y-up -> Z-up step (`[x, y, z]` -> `[x, -z, y]`) and
|
||
* multiplied straight by `modelMatrix`. That is only half of Cesium's
|
||
* correction: with the `fromGltfAsync` defaults it also applies Z_UP_TO_X_UP,
|
||
* and the full correction maps raw glTF `[x, y, z]` -> `[z, x, y]`. The
|
||
* aircraft's longitudinal axis is raw glTF X, so the old conversion put the
|
||
* aft offset on the RENDERED model's lateral axis — a 90-degree disagreement.
|
||
* Because `modelMatrix` carries the heading, both frames rotated together and
|
||
* the offset came out sideways in world space, flipping which side it appeared
|
||
* on as the course changed. Measured on the live rig at N/E/S/W plus a
|
||
* helicopter: the old path put 81.47 m of a civil airliner's anchor entirely on
|
||
* cross-track with 0 aft, at every heading; this path puts 81.47 m aft with 0
|
||
* cross.
|
||
*
|
||
* The anchors are therefore stored in RAW glTF coordinates and transformed
|
||
* here, so exactly one transform — Cesium's own — stands between the table and
|
||
* the world, and there is no second convention to drift.
|
||
*
|
||
* @param {object} model Cesium Model (needs modelMatrix, computedScale, sceneGraph).
|
||
* @param {readonly number[]} nativeAnchor Raw glTF `[x, y, z]` native-unit offset.
|
||
* @param {{x:number,y:number,z:number}} result Mutable Cartesian-like result.
|
||
* @returns {{x:number,y:number,z:number}|null} World position of the anchor.
|
||
*/
|
||
export function modelAnchorWorld(model, nativeAnchor, result) {
|
||
if (!model || !model.modelMatrix || !nativeAnchor || !result) return null;
|
||
const scale = Number.isFinite(model.computedScale) ? model.computedScale : 1;
|
||
_anchorScratch.x = (nativeAnchor[0] || 0) * scale;
|
||
_anchorScratch.y = (nativeAnchor[1] || 0) * scale;
|
||
_anchorScratch.z = (nativeAnchor[2] || 0) * scale;
|
||
// The model's own root transform, when it exposes one, then Cesium's axis
|
||
// correction. Read from the instance rather than assumed, so an asset with a
|
||
// baked root transform is carried too.
|
||
const components = model?.sceneGraph?.components?.transform;
|
||
Cesium.Matrix4.multiplyTransformation(
|
||
model.modelMatrix,
|
||
components || Cesium.Matrix4.IDENTITY,
|
||
_chainScratch,
|
||
);
|
||
Cesium.Matrix4.multiplyTransformation(
|
||
_chainScratch,
|
||
AXIS_CORRECTION,
|
||
_chainScratch,
|
||
);
|
||
return Cesium.Matrix4.multiplyByPoint(_chainScratch, _anchorScratch, result);
|
||
}
|
||
|
||
/**
|
||
* Return the model-local visual centre for an approved bundled GLB.
|
||
* Assets absent from the table were audited as origin-centred.
|
||
* @param {string} url Bundled model URL.
|
||
* @returns {readonly number[]} Cesium-local `[x, y, z]` native-unit offset.
|
||
*/
|
||
export function visualCenterForModel(url) {
|
||
return MODEL_VISUAL_CENTER_NATIVE[url] || CENTERED_MODEL;
|
||
}
|
||
|
||
/**
|
||
* Return the model-local aft-belly attachment point for a tracked trail.
|
||
* Unknown future assets safely fall back to their visual centre until their
|
||
* geometry is measured and added to the table.
|
||
* @param {string} url Bundled model URL.
|
||
* @returns {readonly number[]} Cesium-local `[x, y, z]` native-unit offset.
|
||
*/
|
||
export function trailAnchorForModel(url) {
|
||
return MODEL_TRAIL_ANCHOR_NATIVE[url] || visualCenterForModel(url);
|
||
}
|
||
|
||
/**
|
||
* Where a trail head segment should START being drawn: the whole segment, a
|
||
* shortened piece of it, or nothing at all.
|
||
*
|
||
* The head segment bridges the last displayed trail point to the aft-belly
|
||
* anchor. On a moving contact that point is a poll interval behind, so the
|
||
* segment arrives from open air and terminates ON the hull, which is what it
|
||
* should do. On a PARKED one it sits where the aircraft is: the segment
|
||
* degenerates into a line from inside the model out to its own anchor — a stick
|
||
* through half the fuselage on an aeroplane going nowhere. Grounded tracking
|
||
* starts a trail unconditionally, so every parked selection drew one.
|
||
*
|
||
* Three earlier cuts were wrong, and each is recorded because the shape of the
|
||
* mistake matters:
|
||
*
|
||
* - Testing segment LENGTH against the radius hid real trail. With
|
||
* `airplane.glb`'s 34.41 m radius and 24.09 m tail anchor, a prior fix 58 m
|
||
* aft gives a 33.98 m segment — under the radius, so suppressed — even
|
||
* though ~23.6 m of it is in open air behind the aircraft. It also made the
|
||
* whole segment pop in at once on crossing ~58.4 m.
|
||
* - CLIPPING the segment at the envelope boundary and keeping only what lay
|
||
* OUTSIDE fixed the pop but broke the thing the trail is for: a bounding
|
||
* sphere encloses a lot of empty space around a slender airframe, so
|
||
* trimming at sphere entry stops the trail visibly SHORT of the aircraft it
|
||
* is attached to. The head end is never cut. That end is the whole point.
|
||
* - CONTAINMENT — the whole segment when the start is outside the envelope,
|
||
* nothing when it is inside — kept both of those ends honest but decided it
|
||
* with a BOOLEAN, and a boolean at a 34.41 m boundary is a 10.33 m flash.
|
||
* Moving the start from 34.40 m to 34.42 m turned the entire segment on at
|
||
* once, and a contact whose next fix landed back inside turned it off again.
|
||
* Nothing in the world changes by 10 m across 2 cm of travel.
|
||
*
|
||
* So the far end MOVES and the near end does not. The drawn start slides along
|
||
* the segment, from the anchor itself out to the segment's true start, over the
|
||
* shell between the two lengths the model already defines:
|
||
*
|
||
* - the ANCHOR'S OWN STATION, `|anchor − centre|`. Inside that, the last body
|
||
* point is no further from the aircraft's centre than its own attachment
|
||
* point is, so every millimetre of the segment would be drawn forward of
|
||
* the anchor, into the fuselage. Nothing is drawn. A parked contact sits at
|
||
* exactly zero here, which is the case this whole guard exists for.
|
||
* - the ENVELOPE, `radiusM`. At and beyond it the segment is drawn in full,
|
||
* bit-identical to the containment rule it replaces — so every contact that
|
||
* has moved more than its own size (any airborne contact: 30 s of flight is
|
||
* kilometres) is completely unaffected by any of this.
|
||
*
|
||
* Between them the visible length grows continuously from zero, so a contact
|
||
* creeping across the boundary reveals its trail a few centimetres at a time
|
||
* and an in/out/in flap costs a few centimetres each way instead of ten metres.
|
||
*
|
||
* Scale-free rather than tuned: both ends of that shell come from the model —
|
||
* `radiusM` carries Cesium's `computedScale`, so it tracks whatever
|
||
* `minimumPixelSize` inflated the model to, and the station comes from the
|
||
* anchor being drawn to.
|
||
*
|
||
* @param {{x:number,y:number,z:number}} start Segment start (last trail point).
|
||
* @param {{x:number,y:number,z:number}|null} anchor Segment end, world space —
|
||
* the aft-belly attachment point actually being drawn to.
|
||
* @param {{x:number,y:number,z:number}|null} center Model origin, world space.
|
||
* @param {number} radiusM Rendered bounding radius (nativeRadiusM x computedScale).
|
||
* @param {{x:number,y:number,z:number}} result Mutable Cartesian-like result.
|
||
* @returns {{x:number,y:number,z:number}|null} The point to draw FROM, or null
|
||
* when the segment should not be drawn. May be `start` itself.
|
||
*/
|
||
export function trailHeadStart(start, anchor, center, radiusM, result) {
|
||
if (!start) return null;
|
||
// No envelope to judge against (no model drawing this contact, a radius we
|
||
// cannot trust, or nowhere to put the answer) — draw it all rather than lose
|
||
// a real trail.
|
||
if (!anchor || !center || !result) return start;
|
||
if (!Number.isFinite(radiusM) || radiusM <= 0) return start;
|
||
const startD = Math.hypot(
|
||
start.x - center.x,
|
||
start.y - center.y,
|
||
start.z - center.z,
|
||
);
|
||
if (startD >= radiusM) return start;
|
||
const anchorD = Math.hypot(
|
||
anchor.x - center.x,
|
||
anchor.y - center.y,
|
||
anchor.z - center.z,
|
||
);
|
||
// A degenerate shell (an anchor at or outside the envelope — no shipped asset
|
||
// has one, since every anchor is a measured hull point) leaves nothing to
|
||
// ramp across; fall back to the containment verdict rather than divide by it.
|
||
if (!(radiusM > anchorD)) return null;
|
||
const revealed = (startD - anchorD) / (radiusM - anchorD);
|
||
if (revealed <= 0) return null;
|
||
result.x = anchor.x + (start.x - anchor.x) * revealed;
|
||
result.y = anchor.y + (start.y - anchor.y) * revealed;
|
||
result.z = anchor.z + (start.z - anchor.z) * revealed;
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Transform a model-local visual centre into world coordinates without
|
||
* allocating. `modelMatrix` continues to own the model origin (including
|
||
* ground snapping); bracket/readout and trail consumers supply their own
|
||
* model-local anchor.
|
||
* @param {ArrayLike<number>} modelMatrix Cesium Matrix4-compatible value.
|
||
* @param {readonly number[]} nativeCenter Cesium-local native-unit offset.
|
||
* @param {number} scale Cesium Model.computedScale applied outside modelMatrix.
|
||
* @param {{x:number,y:number,z:number}} result Mutable Cartesian-like result.
|
||
* @returns {{x:number,y:number,z:number}|null} World visual centre.
|
||
*/
|
||
export function modelVisualAnchor(modelMatrix, nativeCenter, scale, result) {
|
||
if (!modelMatrix || !result) return null;
|
||
const safeScale = Number.isFinite(scale) ? scale : 1;
|
||
const x = (nativeCenter?.[0] || 0) * safeScale;
|
||
const y = (nativeCenter?.[1] || 0) * safeScale;
|
||
const z = (nativeCenter?.[2] || 0) * safeScale;
|
||
result.x =
|
||
modelMatrix[0] * x +
|
||
modelMatrix[4] * y +
|
||
modelMatrix[8] * z +
|
||
modelMatrix[12];
|
||
result.y =
|
||
modelMatrix[1] * x +
|
||
modelMatrix[5] * y +
|
||
modelMatrix[9] * z +
|
||
modelMatrix[13];
|
||
result.z =
|
||
modelMatrix[2] * x +
|
||
modelMatrix[6] * y +
|
||
modelMatrix[10] * z +
|
||
modelMatrix[14];
|
||
return result;
|
||
}
|