mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
929 lines
44 KiB
JavaScript
929 lines
44 KiB
JavaScript
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||
// Regression test for the giant-military-jet bug: MODEL_SCALE must put every
|
||
// rendered aircraft at REAL-WORLD size, whatever the GLB's native scale is.
|
||
//
|
||
// airplane.glb and jet.glb are both transform-applied at real-world size and
|
||
// render with MODEL_SCALE 1. The airplane previously relied on a runtime ×24
|
||
// calibration; that factor is now baked into its geometry so future consumers
|
||
// cannot miss it. The original military bug came from copying scale 24 onto the
|
||
// already meter-scale jet, rendering military models ~600–1000 m
|
||
// across — invisible at the ~6 km follow range where minimumPixelSize
|
||
// dominates, but zoom below ~1 km and the camera ends up INSIDE the model.
|
||
//
|
||
// The layer modules pull the full Cesium engine and don't export their
|
||
// constants, so (as in modelMatrix.test.mjs) we don't import them: constants
|
||
// are read from source, and each GLB's native bounding radius is computed from
|
||
// its POSITION accessor min/max with the node hierarchy's transforms applied
|
||
// (both legacy GLBs are now transform-applied, but the hierarchy-aware reader
|
||
// guards future asset updates). For jet.glb this yields 29.83 — Cesium's
|
||
// Model.boundingSphere.radius / scale measured in-app (2026-07-02).
|
||
import { test } from 'node:test';
|
||
import * as Cesium from 'cesium';
|
||
import assert from 'node:assert/strict';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { CLASS_SCALE_3D, CLASS_MODEL_URL, CLASS_MODEL_REAL } from './aircraftClass.js';
|
||
import {
|
||
MODEL_TRAIL_ANCHOR_NATIVE,
|
||
MODEL_VISUAL_CENTER_NATIVE,
|
||
modelVisualAnchor,
|
||
trailAnchorForModel,
|
||
trailHeadStart,
|
||
modelAnchorWorld,
|
||
} from './modelVisualAnchor.js';
|
||
|
||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||
|
||
/** Every class's rendered bounding radius (m) must land in this envelope —
|
||
* bigger than any clamped-down light aircraft, smaller than the largest real
|
||
* heavy (A380/An-124 bounding radius ~45 m), nowhere near the 500–1000 m of
|
||
* the scale-24 jet.glb bug. */
|
||
const WORLD_RADIUS_MIN_M = 10;
|
||
const WORLD_RADIUS_MAX_M = 60;
|
||
|
||
test('model-space anchors honor Cesium minimum-pixel computed scale', () => {
|
||
const matrix = [
|
||
0, 1, 0, 0,
|
||
-1, 0, 0, 0,
|
||
0, 0, 1, 0,
|
||
100, 200, 300, 1,
|
||
];
|
||
const result = { x: 0, y: 0, z: 0 };
|
||
modelVisualAnchor(matrix, [2, 3, -4], 7, result);
|
||
assert.deepEqual(result, { x: 79, y: 214, z: 272 });
|
||
});
|
||
|
||
// --- minimal GLB bounds reader ---------------------------------------------
|
||
|
||
function glbJson(file) {
|
||
const buf = fs.readFileSync(file);
|
||
assert.equal(buf.readUInt32LE(0), 0x46546c67, `${file}: not a GLB`);
|
||
assert.equal(buf.readUInt32LE(16), 0x4e4f534a, `${file}: first chunk not JSON`);
|
||
return JSON.parse(buf.subarray(20, 20 + buf.readUInt32LE(12)).toString('utf8'));
|
||
}
|
||
|
||
// Column-major 4×4 helpers (glTF matrix layout).
|
||
const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||
function mul(a, b) {
|
||
const o = new Array(16).fill(0);
|
||
for (let c = 0; c < 4; c++)
|
||
for (let r = 0; r < 4; r++)
|
||
for (let k = 0; k < 4; k++) o[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k];
|
||
return o;
|
||
}
|
||
function nodeMatrix(node) {
|
||
if (node.matrix) return node.matrix;
|
||
const t = node.translation || [0, 0, 0];
|
||
const [x, y, z, w] = node.rotation || [0, 0, 0, 1];
|
||
const s = node.scale || [1, 1, 1];
|
||
const rot = [
|
||
1 - 2 * (y * y + z * z), 2 * (x * y + z * w), 2 * (x * z - y * w), 0,
|
||
2 * (x * y - z * w), 1 - 2 * (x * x + z * z), 2 * (y * z + x * w), 0,
|
||
2 * (x * z + y * w), 2 * (y * z - x * w), 1 - 2 * (x * x + y * y), 0,
|
||
0, 0, 0, 1,
|
||
];
|
||
return mul(
|
||
[1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, t[0], t[1], t[2], 1],
|
||
mul(rot, [s[0], 0, 0, 0, 0, s[1], 0, 0, 0, 0, s[2], 0, 0, 0, 0, 1]),
|
||
);
|
||
}
|
||
function transformPoint(m, p) {
|
||
return [
|
||
m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
|
||
m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
|
||
m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],
|
||
];
|
||
}
|
||
|
||
/** Scene-space AABB over all mesh primitives' POSITION bounds, node transforms applied. */
|
||
function nativeBounds(file) {
|
||
const gltf = glbJson(file);
|
||
const min = [Infinity, Infinity, Infinity];
|
||
const max = [-Infinity, -Infinity, -Infinity];
|
||
const visit = (idx, parent) => {
|
||
const node = gltf.nodes[idx];
|
||
const m = mul(parent, nodeMatrix(node));
|
||
if (node.mesh != null) {
|
||
for (const prim of gltf.meshes[node.mesh].primitives) {
|
||
const acc = gltf.accessors[prim.attributes.POSITION];
|
||
if (!acc?.min || !acc?.max) continue;
|
||
for (let i = 0; i < 8; i++) {
|
||
const corner = transformPoint(m, [
|
||
(i & 1) ? acc.max[0] : acc.min[0],
|
||
(i & 2) ? acc.max[1] : acc.min[1],
|
||
(i & 4) ? acc.max[2] : acc.min[2],
|
||
]);
|
||
for (let a = 0; a < 3; a++) {
|
||
min[a] = Math.min(min[a], corner[a]);
|
||
max[a] = Math.max(max[a], corner[a]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for (const child of node.children || []) visit(child, m);
|
||
};
|
||
for (const n of gltf.scenes[gltf.scene ?? 0].nodes) visit(n, IDENTITY);
|
||
assert.ok(Number.isFinite(min[0]), `${file}: no POSITION bounds found`);
|
||
return { min, max };
|
||
}
|
||
|
||
// --- real mesh reader (vertices + triangles, not just accessor bounds) ------
|
||
//
|
||
// The bounds reader above is enough to size a model, but NOT to site a point on
|
||
// it: an AABB corner is empty space. These read the POSITION buffers themselves
|
||
// so the trail anchor can be checked against actual triangles.
|
||
|
||
const GLB_COMPONENT_READERS = {
|
||
5120: (buf, o) => buf.readInt8(o),
|
||
5121: (buf, o) => buf.readUInt8(o),
|
||
5122: (buf, o) => buf.readInt16LE(o),
|
||
5123: (buf, o) => buf.readUInt16LE(o),
|
||
5125: (buf, o) => buf.readUInt32LE(o),
|
||
5126: (buf, o) => buf.readFloatLE(o),
|
||
};
|
||
const GLB_COMPONENT_BYTES = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 };
|
||
const GLB_TYPE_COMPONENTS = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 };
|
||
|
||
/** JSON chunk plus the binary chunk of a GLB. */
|
||
function glbChunks(file) {
|
||
const buf = fs.readFileSync(file);
|
||
assert.equal(buf.readUInt32LE(0), 0x46546c67, `${file}: not a GLB`);
|
||
const jsonLen = buf.readUInt32LE(12);
|
||
const gltf = JSON.parse(buf.subarray(20, 20 + jsonLen).toString('utf8'));
|
||
let bin = null;
|
||
for (let off = 20 + jsonLen; off + 8 <= buf.length;) {
|
||
const len = buf.readUInt32LE(off);
|
||
if (buf.readUInt32LE(off + 4) === 0x004e4942) { bin = buf.subarray(off + 8, off + 8 + len); break; }
|
||
off += 8 + len;
|
||
}
|
||
assert.ok(bin, `${file}: no BIN chunk`);
|
||
return { gltf, bin };
|
||
}
|
||
|
||
/** One accessor's elements, honouring bufferView byteStride. */
|
||
function glbAccessor(gltf, bin, index) {
|
||
const acc = gltf.accessors[index];
|
||
assert.ok(!acc.sparse, 'sparse accessors are not used by the bundled assets');
|
||
const read = GLB_COMPONENT_READERS[acc.componentType];
|
||
const size = GLB_COMPONENT_BYTES[acc.componentType];
|
||
const n = GLB_TYPE_COMPONENTS[acc.type];
|
||
const bv = gltf.bufferViews[acc.bufferView];
|
||
const base = (bv.byteOffset || 0) + (acc.byteOffset || 0);
|
||
const stride = bv.byteStride || size * n;
|
||
const out = [];
|
||
for (let i = 0; i < acc.count; i++) {
|
||
const o = base + i * stride;
|
||
const el = [];
|
||
for (let c = 0; c < n; c++) el.push(read(bin, o + c * size));
|
||
out.push(el);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Every mesh vertex in Cesium model-local space, plus the triangle index list.
|
||
* Same glTF Y-up → Cesium Z-up mapping the anchors are expressed in.
|
||
*/
|
||
function nativeMesh(file) {
|
||
const { gltf, bin } = glbChunks(file);
|
||
assert.ok(
|
||
!(gltf.extensionsRequired || []).some((e) => e.toLowerCase().includes('draco')),
|
||
`${file}: Draco-compressed assets would need a decoder here`,
|
||
);
|
||
const verts = [];
|
||
const tris = [];
|
||
const visit = (idx, parent) => {
|
||
const node = gltf.nodes[idx];
|
||
const m = mul(parent, nodeMatrix(node));
|
||
if (node.mesh != null) {
|
||
for (const prim of gltf.meshes[node.mesh].primitives) {
|
||
const pIdx = prim.attributes?.POSITION;
|
||
if (pIdx == null) continue;
|
||
const start = verts.length;
|
||
for (const p of glbAccessor(gltf, bin, pIdx)) {
|
||
// RAW glTF axes, deliberately: the anchor table stores raw glTF and
|
||
// lets Cesium's own axis correction do the rest (modelAnchorWorld).
|
||
// Converting here would re-introduce the second, hand-maintained
|
||
// convention whose half-correction put the anchor on the wrong axis.
|
||
verts.push(transformPoint(m, p));
|
||
}
|
||
if (prim.indices != null) {
|
||
const ind = glbAccessor(gltf, bin, prim.indices).map((e) => e[0]);
|
||
for (let i = 0; i + 2 < ind.length; i += 3) {
|
||
tris.push([start + ind[i], start + ind[i + 1], start + ind[i + 2]]);
|
||
}
|
||
} else {
|
||
for (let i = start; i + 2 < verts.length; i += 3) tris.push([i, i + 1, i + 2]);
|
||
}
|
||
}
|
||
}
|
||
for (const child of node.children || []) visit(child, m);
|
||
};
|
||
for (const n of gltf.scenes[gltf.scene ?? 0].nodes) visit(n, IDENTITY);
|
||
assert.ok(verts.length && tris.length, `${file}: no triangles found`);
|
||
return { verts, tris };
|
||
}
|
||
|
||
/** AABB over real vertices (Cesium model-local). */
|
||
function vertexBounds(verts) {
|
||
const min = [Infinity, Infinity, Infinity];
|
||
const max = [-Infinity, -Infinity, -Infinity];
|
||
for (const v of verts) {
|
||
for (let a = 0; a < 3; a++) {
|
||
if (v[a] < min[a]) min[a] = v[a];
|
||
if (v[a] > max[a]) max[a] = v[a];
|
||
}
|
||
}
|
||
return { min, max };
|
||
}
|
||
|
||
const dist3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||
|
||
/** Closest point of triangle abc to p (Ericson, Real-Time Collision Detection). */
|
||
function closestOnTriangle(p, a, b, c) {
|
||
const sub = (u, v) => [u[0] - v[0], u[1] - v[1], u[2] - v[2]];
|
||
const dot = (u, v) => u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
|
||
const ab = sub(b, a); const ac = sub(c, a); const ap = sub(p, a);
|
||
const d1 = dot(ab, ap); const d2 = dot(ac, ap);
|
||
if (d1 <= 0 && d2 <= 0) return a;
|
||
const bp = sub(p, b);
|
||
const d3 = dot(ab, bp); const d4 = dot(ac, bp);
|
||
if (d3 >= 0 && d4 <= d3) return b;
|
||
const vc = d1 * d4 - d3 * d2;
|
||
if (vc <= 0 && d1 >= 0 && d3 <= 0) {
|
||
const v = d1 / (d1 - d3);
|
||
return [a[0] + ab[0] * v, a[1] + ab[1] * v, a[2] + ab[2] * v];
|
||
}
|
||
const cp = sub(p, c);
|
||
const d5 = dot(ab, cp); const d6 = dot(ac, cp);
|
||
if (d6 >= 0 && d5 <= d6) return c;
|
||
const vb = d5 * d2 - d1 * d6;
|
||
if (vb <= 0 && d2 >= 0 && d6 <= 0) {
|
||
const w = d2 / (d2 - d6);
|
||
return [a[0] + ac[0] * w, a[1] + ac[1] * w, a[2] + ac[2] * w];
|
||
}
|
||
const va = d3 * d6 - d5 * d4;
|
||
if (va <= 0 && (d4 - d3) >= 0 && (d5 - d6) >= 0) {
|
||
const w = (d4 - d3) / ((d4 - d3) + (d5 - d6));
|
||
return [b[0] + (c[0] - b[0]) * w, b[1] + (c[1] - b[1]) * w, b[2] + (c[2] - b[2]) * w];
|
||
}
|
||
const den = 1 / (va + vb + vc);
|
||
const v = vb * den; const w = vc * den;
|
||
return [a[0] + ab[0] * v + ac[0] * w, a[1] + ab[1] * v + ac[1] * w, a[2] + ab[2] * v + ac[2] * w];
|
||
}
|
||
|
||
/** How far a point lies from the nearest triangle of the mesh. */
|
||
function distanceToSurface(p, verts, tris) {
|
||
let best = Infinity;
|
||
for (const [i, j, k] of tris) {
|
||
const d = dist3(p, closestOnTriangle(p, verts[i], verts[j], verts[k]));
|
||
if (d < best) best = d;
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/** The hull's longitudinal cross-section: where triangles cross the y = 0 plane. */
|
||
function centrelineProfile(verts, tris) {
|
||
const pts = [];
|
||
for (const [i, j, k] of tris) {
|
||
const t = [verts[i], verts[j], verts[k]];
|
||
for (let e = 0; e < 3; e++) {
|
||
const a = t[e]; const b = t[(e + 1) % 3];
|
||
if (a[2] === 0) pts.push(a);
|
||
if ((a[2] < 0 && b[2] > 0) || (a[2] > 0 && b[2] < 0)) {
|
||
const s = a[2] / (a[2] - b[2]);
|
||
pts.push([a[0] + (b[0] - a[0]) * s, a[1] + (b[1] - a[1]) * s, 0]);
|
||
}
|
||
}
|
||
}
|
||
return pts;
|
||
}
|
||
|
||
/** Closest point of the centreline profile to `target` (segment-exact). */
|
||
function closestProfilePoint(target, profile, tris, verts) {
|
||
// Walk the profile as the segments the slice actually produced, so the answer
|
||
// can fall between two vertices the way the real surface does.
|
||
const segs = [];
|
||
for (const [i, j, k] of tris) {
|
||
const t = [verts[i], verts[j], verts[k]];
|
||
const pts = [];
|
||
for (let e = 0; e < 3; e++) {
|
||
const a = t[e]; const b = t[(e + 1) % 3];
|
||
if (a[2] === 0) pts.push(a);
|
||
if ((a[2] < 0 && b[2] > 0) || (a[2] > 0 && b[2] < 0)) {
|
||
const s = a[2] / (a[2] - b[2]);
|
||
pts.push([a[0] + (b[0] - a[0]) * s, a[1] + (b[1] - a[1]) * s, 0]);
|
||
}
|
||
}
|
||
if (pts.length >= 2) segs.push([pts[0], pts[1]]);
|
||
}
|
||
let best = null; let bestD = Infinity;
|
||
for (const [a, b] of segs) {
|
||
const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
||
const den = ab[0] * ab[0] + ab[1] * ab[1] + ab[2] * ab[2];
|
||
let q = a;
|
||
if (den > 0) {
|
||
let s = ((target[0] - a[0]) * ab[0] + (target[1] - a[1]) * ab[1] + (target[2] - a[2]) * ab[2]) / den;
|
||
s = Math.max(0, Math.min(1, s));
|
||
q = [a[0] + ab[0] * s, a[1] + ab[1] * s, a[2] + ab[2] * s];
|
||
}
|
||
const d = dist3(target, q);
|
||
if (d < bestD) { bestD = d; best = q; }
|
||
}
|
||
assert.ok(best, 'the centreline slice produced no segments');
|
||
assert.ok(profile.length > 0, 'the centreline profile is empty');
|
||
return best;
|
||
}
|
||
|
||
/** Native bounding radius: half-diagonal of the scene-space AABB. */
|
||
function nativeBoundingRadius(file) {
|
||
const { min, max } = nativeBounds(file);
|
||
return Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2;
|
||
}
|
||
|
||
/** How far the glTF origin sits above the model's lowest vertex (glTF is Y-up;
|
||
* Cesium maps that to local up). This is the native "belly depth" the grounded-
|
||
* model snap must lift by so the model rests on its gear/belly, not its origin. */
|
||
function nativeOriginAboveLowestVertex(file) {
|
||
return -nativeBounds(file).min[1];
|
||
}
|
||
|
||
/** Scene-AABB centre converted from glTF Y-up to Cesium model-local Z-up. */
|
||
function nativeVisualCenter(file) {
|
||
const { min, max } = nativeBounds(file);
|
||
const gltfCenter = min.map((value, axis) => (value + max[axis]) / 2);
|
||
return [gltfCenter[0], -gltfCenter[2], gltfCenter[1]];
|
||
}
|
||
|
||
// --- per-layer constants, read from source (see header) --------------------
|
||
|
||
function layerConstants(sourceFile) {
|
||
const src = readLayerSource(path.join(ROOT, sourceFile));
|
||
const scale = src.match(/\bconst MODEL_SCALE = ([\d.]+);/);
|
||
assert.ok(scale, `${sourceFile}: MODEL_SCALE not found`);
|
||
const belly = src.match(/\bconst MODEL_BELLY_OFFSET_NATIVE = ([\d.]+);/);
|
||
assert.ok(belly, `${sourceFile}: MODEL_BELLY_OFFSET_NATIVE not found`);
|
||
return { modelScale: Number(scale[1]), bellyOffsetNative: Number(belly[1]) };
|
||
}
|
||
|
||
function normalBillboardScaleByDistance(sourceFile) {
|
||
const src = readLayerSource(path.join(ROOT, sourceFile));
|
||
const fn = src.match(/function (?:parts\.\w+\.)?_normalBillboardScaleByDistance\(\) \{([\s\S]*?)\n\}/);
|
||
assert.ok(fn, `${sourceFile}: _normalBillboardScaleByDistance not found`);
|
||
const scalar = fn[1].match(/NearFarScalar\((\d+), ([\d.]+), (\d+), ([\d.]+)\)/);
|
||
assert.ok(scalar, `${sourceFile}: billboard NearFarScalar not found`);
|
||
return scalar.slice(1).map(Number);
|
||
}
|
||
|
||
const LAYERS = [
|
||
{
|
||
name: 'flights',
|
||
source: 'src/data/flights.js',
|
||
// All classes share one GLB today (see CLASS_MODEL_URL) — assert that, so
|
||
// a real per-class asset drop-in forces this test to grow with it.
|
||
asset: (() => {
|
||
const urls = new Set(Object.values(CLASS_MODEL_URL));
|
||
assert.equal(urls.size, 1, 'CLASS_MODEL_URL: expected a single shared GLB');
|
||
return [...urls][0];
|
||
})(),
|
||
},
|
||
{
|
||
name: 'military',
|
||
source: 'src/data/militaryFlights.js',
|
||
asset: (() => {
|
||
const src = readLayerSource(path.join(ROOT, 'src/data/militaryFlights.js'));
|
||
const m = src.match(/\bconst JET_MODEL_URL = '([^']+)';/);
|
||
assert.ok(m, 'militaryFlights.js: JET_MODEL_URL not found');
|
||
return m[1];
|
||
})(),
|
||
},
|
||
];
|
||
|
||
const measured = LAYERS.map((layer) => {
|
||
const { modelScale, bellyOffsetNative } = layerConstants(layer.source);
|
||
const assetPath = path.join(ROOT, 'public', layer.asset);
|
||
const nativeRadius = nativeBoundingRadius(assetPath);
|
||
return {
|
||
...layer, modelScale, bellyOffsetNative, nativeRadius,
|
||
baseWorldRadius: nativeRadius * modelScale,
|
||
originAboveBelly: nativeOriginAboveLowestVertex(assetPath),
|
||
};
|
||
});
|
||
|
||
// --- the invariants ---------------------------------------------------------
|
||
|
||
for (const layer of measured) {
|
||
test(`${layer.name}: every class renders at real-world aircraft scale (${layer.asset} native r=${layer.nativeRadius.toFixed(2)} × MODEL_SCALE ${layer.modelScale})`, () => {
|
||
for (const [klass, mult] of Object.entries(CLASS_SCALE_3D)) {
|
||
const worldRadius = layer.baseWorldRadius * mult;
|
||
assert.ok(
|
||
worldRadius >= WORLD_RADIUS_MIN_M && worldRadius <= WORLD_RADIUS_MAX_M,
|
||
`${klass}: world bounding radius ${worldRadius.toFixed(1)} m outside ` +
|
||
`[${WORLD_RADIUS_MIN_M}, ${WORLD_RADIUS_MAX_M}] m — MODEL_SCALE is calibrated ` +
|
||
`for a different asset's native scale`,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Grounded-model belly offset (ground-3D, 2026-07-03): each layer's
|
||
// MODEL_BELLY_OFFSET_NATIVE must equal the GLB's actual origin-above-lowest-vertex
|
||
// height, else ground-snapped models sink into (const too small) or hover above
|
||
// (too big) the sampled tile skin. Absolute tolerance = 2% of the native bounding
|
||
// radius (sub-decimeter at world scale for both assets).
|
||
for (const layer of measured) {
|
||
test(`${layer.name}: MODEL_BELLY_OFFSET_NATIVE matches ${layer.asset}'s origin height above its belly`, () => {
|
||
const tol = layer.nativeRadius * 0.02;
|
||
assert.ok(
|
||
Math.abs(layer.bellyOffsetNative - layer.originAboveBelly) <= tol,
|
||
`MODEL_BELLY_OFFSET_NATIVE ${layer.bellyOffsetNative} vs measured ` +
|
||
`${layer.originAboveBelly.toFixed(3)} (tol ${tol.toFixed(3)}) — the constant was ` +
|
||
'calibrated for a different asset; re-measure the GLB (glTF Y-up AABB, node transforms applied)',
|
||
);
|
||
});
|
||
}
|
||
|
||
test('flights and military models render at matching world sizes (cross-layer parity)', () => {
|
||
const [flights, military] = measured;
|
||
const ratio = flights.baseWorldRadius / military.baseWorldRadius;
|
||
assert.ok(
|
||
ratio > 1 / 1.5 && ratio < 1.5,
|
||
`base world radii differ ×${(ratio >= 1 ? ratio : 1 / ratio).toFixed(1)} ` +
|
||
`(flights ${flights.baseWorldRadius.toFixed(1)} m vs military ${military.baseWorldRadius.toFixed(1)} m) — ` +
|
||
'the layers render side by side, so a class must read the same size in both',
|
||
);
|
||
});
|
||
|
||
// --- 2026-08-15 Hangar fleet: real per-class GLBs ---------------------------
|
||
// CLASS_MODEL_REAL assets are vertex-baked to REAL-WORLD METERS in the
|
||
// airplane.glb axis convention and render at scale 1, so the class-envelope
|
||
// test above does not apply to them; instead each registry entry's radiusM
|
||
// and bellyM must MATCH the shipped GLB (the layers consume these numbers for
|
||
// pixel-cap math and grounded-model lift). Envelope: a real C172 (r≈7.1 m)
|
||
// up to a real 787-9 (r≈44.1 m) — nothing clamped, nothing monstrous.
|
||
const REAL_RADIUS_MIN_M = 5;
|
||
const REAL_RADIUS_MAX_M = 60;
|
||
for (const [klass, spec] of Object.entries(CLASS_MODEL_REAL)) {
|
||
test(`hangar fleet: ${klass} registry pins match the shipped GLB (${spec.url})`, () => {
|
||
const assetPath = path.join(ROOT, 'public', spec.url);
|
||
const radius = nativeBoundingRadius(assetPath);
|
||
const belly = nativeOriginAboveLowestVertex(assetPath);
|
||
assert.ok(
|
||
radius >= REAL_RADIUS_MIN_M && radius <= REAL_RADIUS_MAX_M,
|
||
`${klass}: bounding radius ${radius.toFixed(2)} m outside the real-aircraft ` +
|
||
`envelope [${REAL_RADIUS_MIN_M}, ${REAL_RADIUS_MAX_M}] m — the GLB is not baked to meters`,
|
||
);
|
||
const tol = radius * 0.02;
|
||
assert.ok(
|
||
Math.abs(radius - spec.radiusM) <= tol,
|
||
`${klass}: registry radiusM ${spec.radiusM} vs measured ${radius.toFixed(3)} ` +
|
||
`(tol ${tol.toFixed(3)}) — re-measure the GLB and update CLASS_MODEL_REAL`,
|
||
);
|
||
assert.ok(
|
||
Math.abs(belly - spec.bellyM) <= tol,
|
||
`${klass}: registry bellyM ${spec.bellyM} vs measured ${belly.toFixed(3)} ` +
|
||
`(tol ${tol.toFixed(3)}) — grounded models would sink or hover; update CLASS_MODEL_REAL`,
|
||
);
|
||
});
|
||
}
|
||
|
||
// militaryFlights renders airplane.glb for its heavy classes with duplicated
|
||
// PLANE_* constants — pin them to the measured meter-scale GLB + flights'
|
||
// calibration so the copies cannot drift.
|
||
test('military layer airplane.glb constants match the measured GLB and flights calibration', () => {
|
||
const src = readLayerSource(path.join(ROOT, 'src/data/militaryFlights.js'));
|
||
const grab = (name) => {
|
||
const m = src.match(new RegExp(`\\bconst ${name} = ([\\d.]+);`));
|
||
assert.ok(m, `militaryFlights.js: ${name} not found`);
|
||
return Number(m[1]);
|
||
};
|
||
const [flights] = measured;
|
||
assert.equal(grab('PLANE_MODEL_SCALE'), flights.modelScale, 'PLANE_MODEL_SCALE must match flights MODEL_SCALE');
|
||
assert.ok(
|
||
Math.abs(grab('PLANE_NATIVE_RADIUS_M') - flights.nativeRadius) <= flights.nativeRadius * 0.02,
|
||
`PLANE_NATIVE_RADIUS_M vs measured ${flights.nativeRadius.toFixed(3)}`,
|
||
);
|
||
assert.ok(
|
||
Math.abs(grab('PLANE_BELLY_OFFSET_NATIVE') - flights.originAboveBelly) <= flights.nativeRadius * 0.02,
|
||
`PLANE_BELLY_OFFSET_NATIVE vs measured ${flights.originAboveBelly.toFixed(3)}`,
|
||
);
|
||
});
|
||
|
||
test('shared-model visual-centre metadata matches the shipped GLBs', () => {
|
||
for (const [url, expected] of Object.entries(MODEL_VISUAL_CENTER_NATIVE)) {
|
||
const measuredCenter = nativeVisualCenter(path.join(ROOT, 'public', url));
|
||
measuredCenter.forEach((value, axis) => {
|
||
assert.ok(
|
||
Math.abs(value - expected[axis]) <= 0.001,
|
||
`${url} axis ${axis}: metadata ${expected[axis]} vs measured ${value.toFixed(6)}`,
|
||
);
|
||
});
|
||
}
|
||
});
|
||
|
||
// The tracked trail is drawn from a point BEHIND the aircraft to this anchor,
|
||
// so the anchor decides both whether the trail terminates ON the aeroplane and
|
||
// whether the last stretch of it crosses the airframe. An earlier round put the
|
||
// anchor on the aft-belly AABB CORNER, reasoning that a point aft of every
|
||
// vertex cannot be crossed. True, but a corner is empty space — 4.80 m from any
|
||
// real geometry on airplane.glb, 6.14 m on jet.glb — so the trail ended in mid
|
||
// air beside the model. The anchor is now a genuine hull point, and these pins
|
||
// re-derive it from the POSITION BUFFERS rather than restating the table.
|
||
test('every approved aircraft model has a trail anchor on its real aft-belly hull', () => {
|
||
const urls = new Set([
|
||
...Object.keys(MODEL_VISUAL_CENTER_NATIVE),
|
||
...Object.values(CLASS_MODEL_REAL).map((spec) => spec.url),
|
||
]);
|
||
assert.deepEqual(
|
||
new Set(Object.keys(MODEL_TRAIL_ANCHOR_NATIVE)),
|
||
urls,
|
||
'trail-anchor metadata must cover every approved aircraft GLB',
|
||
);
|
||
|
||
for (const url of urls) {
|
||
const file = path.join(ROOT, 'public', url);
|
||
const { verts, tris } = nativeMesh(file);
|
||
const { min, max } = vertexBounds(verts);
|
||
const centre = min.map((value, axis) => (value + max[axis]) / 2);
|
||
const actual = MODEL_TRAIL_ANCHOR_NATIVE[url];
|
||
|
||
// 1. ON THE HULL. The property the AABB corner failed. Measured against the
|
||
// real triangles, not against a bounding box: the corner values sat
|
||
// metres out in open air, and this is what said so.
|
||
// Measured bound across the shipped assets is <= 5e-5 native: re-measured
|
||
// from these buffers they span 5.2e-6 (bell206) to 4.1e-5 (atr72), with
|
||
// airplane.glb at 1.5e-5 and jet.glb at 1.8e-5. None is exactly zero — the
|
||
// anchor is the nearest point of a triangulated profile, not a vertex — so
|
||
// 1e-4 is met with margin and is still tight enough to catch an anchor that
|
||
// has left the hull; the old 1e-3 was loose enough to be uninformative.
|
||
const offHull = distanceToSurface(actual, verts, tris);
|
||
assert.ok(
|
||
offHull <= 1e-4,
|
||
`${url}: trail anchor must lie ON the mesh — it is ${offHull.toFixed(4)} m off the ` +
|
||
'nearest triangle, so the trail would end in mid air beside the aircraft',
|
||
);
|
||
|
||
// 2. AFT of the airframe's middle, or the head segment enters at the tail
|
||
// and stops inside the fuselage — the line through the model the review
|
||
// photographed. Every shipped asset clears 80 % of its aft extreme.
|
||
assert.ok(
|
||
actual[0] > centre[0],
|
||
`${url}: trail anchor must sit aft of the centroid (${actual[0]} <= ${centre[0]})`,
|
||
);
|
||
assert.ok(
|
||
actual[0] >= max[0] * 0.8,
|
||
`${url}: trail anchor must sit on the AFT hull — ${actual[0].toFixed(3)} is only ` +
|
||
`${(actual[0] / max[0] * 100).toFixed(1)}% of the ${max[0].toFixed(3)} aft extreme`,
|
||
);
|
||
|
||
// 3. BELOW: nothing of the aircraft hangs lower than the anchor at or
|
||
// behind its own station, so the trail leaves from underneath rather
|
||
// than out of the roof. Stated against the station rather than against
|
||
// the global minimum on purpose — the global lowest vertex is a wingtip
|
||
// or a gear leg amidships, and demanding the anchor match it is what
|
||
// drove the previous round off the hull in the first place.
|
||
const profile = centrelineProfile(verts, tris);
|
||
const lowestAtOrAft = profile
|
||
.filter((p) => p[0] >= actual[0] - 1e-3)
|
||
.reduce((lowest, p) => Math.min(lowest, p[1]), Infinity);
|
||
assert.ok(
|
||
Number.isFinite(lowestAtOrAft) && actual[1] <= lowestAtOrAft + 1e-3,
|
||
`${url}: trail anchor must be the lowest centreline hull at or aft of its station ` +
|
||
`(anchor ${actual[1]}, hull reaches ${lowestAtOrAft})`,
|
||
);
|
||
|
||
// 4. ON THE CENTRELINE, so the attachment cannot swing screen-left or
|
||
// screen-right as the aircraft turns.
|
||
assert.equal(actual[2], 0, `${url}: trail anchor must stay on the longitudinal centreline`);
|
||
|
||
// 5. And the value itself is the closest centreline hull point to the
|
||
// aft-belly corner — the construction, re-derived. This is what makes
|
||
// the table re-measurable rather than re-guessable.
|
||
const corner = [max[0], min[1], 0];
|
||
const nearest = closestProfilePoint(corner, profile, tris, verts);
|
||
[0, 1].forEach((axis) => {
|
||
assert.ok(
|
||
Math.abs(actual[axis] - nearest[axis]) <= 1e-3,
|
||
`${url} trail axis ${axis}: metadata ${actual[axis]} vs re-measured ${nearest[axis].toFixed(6)}`,
|
||
);
|
||
});
|
||
}
|
||
});
|
||
|
||
// A PARKED contact is the case this decides. Grounded tracking starts a trail
|
||
// unconditionally, and on a contact that has not moved the last body point sits
|
||
// where the aircraft is — so the head segment ran from inside the model out to
|
||
// its own aft-belly anchor, straight through half the fuselage. The cut is on
|
||
// the segment's START, not its length (a length test hides trail that is
|
||
// genuinely in open air) and never on its END (clipping at the envelope stops a
|
||
// moving trail short of the aircraft). See trailHeadStart.
|
||
//
|
||
// A flat 1-D rig stands in for the world here: the model centre at the origin,
|
||
// the aft-belly anchor at its own measured station, and the last body point
|
||
// swept out along +x. That is exactly the geometry the guard reasons about —
|
||
// two distances from one centre — so the arithmetic under test is the shipped
|
||
// arithmetic, with no transform chain in the way (that is the pin below).
|
||
const TRAIL_RIG_URLS = new Set([
|
||
...Object.keys(MODEL_VISUAL_CENTER_NATIVE),
|
||
...Object.values(CLASS_MODEL_REAL).map((spec) => spec.url),
|
||
]);
|
||
const trailRigAt = (d) => ({ x: d, y: 0, z: 0 });
|
||
const trailRigCentre = trailRigAt(0);
|
||
/** The rig's world anchor: the model's own measured attachment point, laid out
|
||
* aft (+x) and below (y) exactly as far as it really sits from the centre. */
|
||
function trailRigAnchor(url) {
|
||
const native = trailAnchorForModel(url);
|
||
return { x: native[0] || 0, y: native[1] || 0, z: 0 };
|
||
}
|
||
/** Visible length of the head segment the guard would have drawn, in metres.
|
||
* Zero means nothing is drawn — which is the answer the whole guard exists for. */
|
||
function drawnHeadLength(start, anchor, centre, envelope) {
|
||
const from = trailHeadStart(start, anchor, centre, envelope, { x: 0, y: 0, z: 0 });
|
||
if (!from) return 0;
|
||
return Math.hypot(from.x - anchor.x, from.y - anchor.y, from.z - anchor.z);
|
||
}
|
||
|
||
test('a stationary contact draws no trail head, and a moving one draws all of it', () => {
|
||
for (const url of TRAIL_RIG_URLS) {
|
||
const envelope = nativeBoundingRadius(path.join(ROOT, 'public', url));
|
||
const anchor = trailRigAnchor(url);
|
||
// Parked: the last body point is where the aircraft is. Nothing at all —
|
||
// not a short segment, not a degenerate one. Nothing.
|
||
assert.equal(
|
||
trailHeadStart(trailRigCentre, anchor, trailRigCentre, envelope, { x: 0, y: 0, z: 0 }), null,
|
||
`${url}: a parked contact's head segment is the anchor offset, not motion, ` +
|
||
`and must not be drawn (envelope ${envelope.toFixed(2)} m)`,
|
||
);
|
||
// Still nothing while the last body point is no further out than the
|
||
// anchor's own station: every millimetre of that segment would be drawn
|
||
// FORWARD of the attachment point, into the fuselage.
|
||
const station = Math.hypot(anchor.x, anchor.y, anchor.z);
|
||
assert.equal(drawnHeadLength(trailRigAt(station * 0.5), anchor, trailRigCentre, envelope), 0,
|
||
`${url}: a segment that would run forward of its own anchor draws nothing`);
|
||
// At and past the envelope the segment is real and must be drawn IN FULL so
|
||
// it reaches the hull — the failure mode clipping introduced. Referentially
|
||
// the caller's own start point, so a contact that has cleared its own size
|
||
// is bit-for-bit what the containment rule drew.
|
||
const clear = trailRigAt(envelope * 1.01);
|
||
assert.equal(trailHeadStart(clear, anchor, trailRigCentre, envelope, { x: 0, y: 0, z: 0 }), clear,
|
||
`${url}: once the contact clears its own envelope the whole head must draw`);
|
||
}
|
||
|
||
// The review's repro: airplane.glb, a prior fix 58 m aft of a 34.41 m
|
||
// envelope. The old length test suppressed this entirely; ~23.6 m of it is in
|
||
// open air, so it must draw.
|
||
const airliner = nativeBoundingRadius(path.join(ROOT, 'public', '/models/airplane.glb'));
|
||
const anchor = trailRigAnchor('/models/airplane.glb');
|
||
for (const [d, why] of [
|
||
[58, 'a fix 58 m aft is well outside the envelope and its segment must be drawn'],
|
||
[7500, 'a jet covers ~7.5 km between fixes — that trail must never be shortened'],
|
||
[300, 'even a slow 10 m/s taxi between 30 s fixes still draws its whole trail'],
|
||
]) {
|
||
const start = trailRigAt(d);
|
||
assert.equal(
|
||
trailHeadStart(start, anchor, trailRigCentre, airliner, { x: 0, y: 0, z: 0 }), start, why,
|
||
);
|
||
}
|
||
// No model drawing (billboard owns the visual): nothing to cut through.
|
||
const loose = trailRigAt(5);
|
||
assert.equal(trailHeadStart(loose, anchor, null, 0, { x: 0, y: 0, z: 0 }), loose,
|
||
'with no model envelope the whole segment always draws');
|
||
});
|
||
|
||
// THE PIN FOR THE BOUNDARY FLASH.
|
||
//
|
||
// The containment rule this replaces was BINARY, and a binary rule at
|
||
// airplane.glb's 34.41 m envelope is a 10.33 m flash: a start at 34.40 m drew
|
||
// nothing, a start at 34.42 m drew the entire segment, and a contact whose next
|
||
// fix landed back inside dropped it again. Two centimetres of travel cannot
|
||
// change ten metres of what the operator sees, and the pin the round shipped
|
||
// with could not catch it because it only ever asked static questions.
|
||
//
|
||
// So this asks the continuity question directly: sweep the start out through
|
||
// the boundary in small steps and require the VISIBLE LENGTH to move by no more
|
||
// than the step allows. The bound is 3x the step — the drawn length is at worst
|
||
// the segment's own growth (1:1 with the step) plus the reveal sliding across a
|
||
// shell that is never shorter than the segment it reveals, so 3 is a ceiling
|
||
// with room, and it is nowhere near the 10.33 m the boolean produced.
|
||
test('the trail head grows continuously across the envelope, never in one step', () => {
|
||
for (const url of TRAIL_RIG_URLS) {
|
||
const envelope = nativeBoundingRadius(path.join(ROOT, 'public', url));
|
||
const anchor = trailRigAnchor(url);
|
||
const step = envelope / 400;
|
||
let previous = 0;
|
||
for (let d = 0; d <= envelope * 1.5; d += step) {
|
||
const start = trailRigAt(d);
|
||
const length = drawnHeadLength(start, anchor, trailRigCentre, envelope);
|
||
assert.ok(
|
||
length - previous <= step * 3 + 1e-9 && previous - length <= step * 3 + 1e-9,
|
||
`${url}: the head jumped ${Math.abs(length - previous).toFixed(3)} m across a ` +
|
||
`${step.toFixed(3)} m step at ${d.toFixed(3)} m — that is the flash, not motion`,
|
||
);
|
||
// Never longer than the segment actually being drawn: the guard only ever
|
||
// takes trail away from the START, and only ever from inside the envelope.
|
||
const full = Math.hypot(start.x - anchor.x, start.y - anchor.y, start.z - anchor.z);
|
||
assert.ok(length <= full + 1e-9,
|
||
`${url}: drew ${length.toFixed(3)} m of a ${full.toFixed(3)} m segment at ${d.toFixed(3)} m`);
|
||
previous = length;
|
||
}
|
||
}
|
||
|
||
// The exact repro, in the numbers the review used.
|
||
const airliner = nativeBoundingRadius(path.join(ROOT, 'public', '/models/airplane.glb'));
|
||
const anchor = trailRigAnchor('/models/airplane.glb');
|
||
const inside = drawnHeadLength(trailRigAt(34.40), anchor, trailRigCentre, airliner);
|
||
const outside = drawnHeadLength(trailRigAt(34.42), anchor, trailRigCentre, airliner);
|
||
assert.ok(outside > 10, `the segment 34.42 m aft is ~10.3 m long and must draw (${outside})`);
|
||
assert.ok(Math.abs(outside - inside) < 0.1,
|
||
'crossing the 34.41 m envelope by 2 cm must change the trail by centimetres, ' +
|
||
`not by the whole ${outside.toFixed(2)} m segment (moved ${Math.abs(outside - inside).toFixed(3)} m)`);
|
||
|
||
// And the flap: a contact whose fixes cross back and forth over the boundary
|
||
// must not strobe the whole segment on and off. Each visit draws essentially
|
||
// the same partial length as the last.
|
||
const flap = [34.40, 34.42, 34.40, 34.43, 34.39].map(
|
||
(d) => drawnHeadLength(trailRigAt(d), anchor, trailRigCentre, airliner));
|
||
const spread = Math.max(...flap) - Math.min(...flap);
|
||
assert.ok(spread < 0.1,
|
||
`an in/out/in flap across the boundary moved the trail ${spread.toFixed(3)} m — ` +
|
||
'the boolean rule moved the whole segment, and that is what the operator saw');
|
||
assert.ok(Math.min(...flap) > 10,
|
||
'every visit in that flap is a real ~10 m segment: the inside ones are partial, not absent');
|
||
});
|
||
|
||
// THE PIN THAT WOULD HAVE CAUGHT THE 2026-08-23 FIELD REGRESSION.
|
||
//
|
||
// The anchor is expressed in the model's own axes, and it only lands where it
|
||
// is meant to if it rides the SAME transform chain Cesium renders the model
|
||
// with. It did not: the table was stored after a single glTF Y-up -> Z-up step
|
||
// and multiplied straight by `modelMatrix`, which is half of Cesium's
|
||
// correction (the `fromGltfAsync` defaults also apply Z_UP_TO_X_UP). The
|
||
// aircraft's longitudinal axis is raw glTF X, so the aft offset landed on the
|
||
// RENDERED model's LATERAL axis — a 90-degree error that `modelMatrix`'s
|
||
// heading then rotated into world space, putting the trail out to one side and
|
||
// flipping which side as the course changed.
|
||
//
|
||
// A single-heading harness cannot catch this, which is exactly how it reached
|
||
// the owner: at one heading a wrongly-framed offset can coincidentally point
|
||
// aft. So this sweeps headings and every shipped asset, and asserts the
|
||
// property directly — the anchor lies in the model's longitudinal/vertical
|
||
// plane, with NO lateral component.
|
||
//
|
||
// The mock carries a NON-IDENTITY `sceneGraph.components.transform`, because
|
||
// the chain has a third link and a mock without one cannot tell whether the
|
||
// code reads it: Cesium renders `modelMatrix x components.transform x
|
||
// axisCorrection`, and an asset with a baked root transform has to ride all
|
||
// three. A scale and an offset ALONG the model's own axes is the strongest
|
||
// choice the property survives — it changes both numbers the pin asserts
|
||
// (identity would leave them at the raw table values, which is the answer a
|
||
// dropped link also gives) while leaving lateral zero, which is the regression.
|
||
const TRAIL_ROOT_SCALE = 2;
|
||
const TRAIL_ROOT_OFFSET = Object.freeze({ lengthwise: 5, vertical: -1 });
|
||
test('the trail anchor rides the rendered longitudinal axis at every heading', () => {
|
||
const position = Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 3000);
|
||
const sub = (a, b) => Cesium.Cartesian3.subtract(a, b, new Cesium.Cartesian3());
|
||
// Cesium-model-local: x is lateral, y lengthwise, z vertical once the axis
|
||
// correction has mapped raw glTF [x, y, z] -> [z, x, y].
|
||
const rootTransform = Cesium.Matrix4.fromTranslationQuaternionRotationScale(
|
||
new Cesium.Cartesian3(0, TRAIL_ROOT_OFFSET.lengthwise, TRAIL_ROOT_OFFSET.vertical),
|
||
Cesium.Quaternion.IDENTITY,
|
||
new Cesium.Cartesian3(TRAIL_ROOT_SCALE, TRAIL_ROOT_SCALE, TRAIL_ROOT_SCALE),
|
||
new Cesium.Matrix4(),
|
||
);
|
||
for (const headingDeg of [0, 45, 90, 180, 270, 315]) {
|
||
const hpr = new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(headingDeg), 0, 0);
|
||
const modelMatrix = Cesium.Transforms.headingPitchRollToFixedFrame(
|
||
position, hpr, Cesium.Ellipsoid.WGS84, undefined, new Cesium.Matrix4(),
|
||
);
|
||
for (const [url, anchor] of Object.entries(MODEL_TRAIL_ANCHOR_NATIVE)) {
|
||
const model = {
|
||
modelMatrix,
|
||
computedScale: 1,
|
||
sceneGraph: { components: { transform: rootTransform } },
|
||
};
|
||
const origin = Cesium.Matrix4.getTranslation(modelMatrix, new Cesium.Cartesian3());
|
||
const world = modelAnchorWorld(model, anchor, new Cesium.Cartesian3());
|
||
// Reference axes derived INDEPENDENTLY, from the ENU frame and the same
|
||
// heading the matrix was built with — deliberately NOT through
|
||
// modelAnchorWorld. Taking them through the function under test makes the
|
||
// check self-consistent: a wrong transform rotates the anchor and the
|
||
// reference frame together and the lateral component stays zero, which is
|
||
// how the first version of this pin passed against the very bug it was
|
||
// written for.
|
||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(origin, Cesium.Ellipsoid.WGS84, new Cesium.Matrix4());
|
||
const axis = (i) => {
|
||
const c = Cesium.Matrix4.getColumn(enu, i, new Cesium.Cartesian4());
|
||
return Cesium.Cartesian3.normalize(new Cesium.Cartesian3(c.x, c.y, c.z), new Cesium.Cartesian3());
|
||
};
|
||
const east = axis(0); const north = axis(1); const vertical = axis(2);
|
||
const rad = Cesium.Math.toRadians(headingDeg);
|
||
const lengthwise = Cesium.Cartesian3.normalize(Cesium.Cartesian3.add(
|
||
Cesium.Cartesian3.multiplyByScalar(north, Math.cos(rad), new Cesium.Cartesian3()),
|
||
Cesium.Cartesian3.multiplyByScalar(east, Math.sin(rad), new Cesium.Cartesian3()),
|
||
new Cesium.Cartesian3()), new Cesium.Cartesian3());
|
||
const lateral = Cesium.Cartesian3.normalize(
|
||
Cesium.Cartesian3.cross(lengthwise, vertical, new Cesium.Cartesian3()), new Cesium.Cartesian3());
|
||
const offset = sub(world, origin);
|
||
const along = Cesium.Cartesian3.dot(offset, lengthwise);
|
||
const up = Cesium.Cartesian3.dot(offset, vertical);
|
||
const side = Cesium.Cartesian3.dot(offset, lateral);
|
||
const where = `${url} @ heading ${headingDeg}`;
|
||
// The anchor must lie in the vertical plane through the heading. The
|
||
// regression put the whole longitudinal offset on THIS axis.
|
||
assert.ok(Math.abs(side) < 1e-4,
|
||
`${where}: the anchor must have NO lateral component (got ${side.toFixed(4)} m) — ` +
|
||
'a sideways offset is the field regression, and it is invisible at a single heading');
|
||
// SIGNED, not magnitudes. An `abs()` on both sides passes an anchor that
|
||
// landed on the nose instead of the tail, or on the roof instead of the
|
||
// belly — which are the two directions the table exists to fix. The
|
||
// expected values carry the root transform, so a dropped `components`
|
||
// link fails here rather than quietly rendering the raw table value.
|
||
const expectedAlong = anchor[0] * TRAIL_ROOT_SCALE + TRAIL_ROOT_OFFSET.lengthwise;
|
||
const expectedUp = anchor[1] * TRAIL_ROOT_SCALE + TRAIL_ROOT_OFFSET.vertical;
|
||
assert.ok(Math.abs(along - expectedAlong) < 1e-4,
|
||
`${where}: the longitudinal offset must land AFT along the heading ` +
|
||
`(${along.toFixed(4)} vs ${expectedAlong.toFixed(4)})`);
|
||
assert.ok(Math.abs(up - expectedUp) < 1e-4,
|
||
`${where}: the belly offset must land BELOW on the vertical axis ` +
|
||
`(${up.toFixed(4)} vs ${expectedUp.toFixed(4)})`);
|
||
}
|
||
}
|
||
});
|
||
|
||
// A helicopter is in the sweep above by asset, but the field report also had a
|
||
// hovering H60 whose course is ill-defined. The anchor must not depend on the
|
||
// contact having a meaningful heading — it is a model-space offset, so an
|
||
// arbitrary heading still puts it aft of the hull.
|
||
test('a hovering rotorcraft anchors aft of its own hull, whatever its heading', () => {
|
||
const position = Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 300);
|
||
const anchor = MODEL_TRAIL_ANCHOR_NATIVE['/models/bell206.glb'];
|
||
for (const headingDeg of [0, 137.5, 271.9]) {
|
||
const hpr = new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(headingDeg), 0, 0);
|
||
const model = {
|
||
modelMatrix: Cesium.Transforms.headingPitchRollToFixedFrame(
|
||
position, hpr, Cesium.Ellipsoid.WGS84, undefined, new Cesium.Matrix4()),
|
||
computedScale: 1,
|
||
};
|
||
const origin = Cesium.Matrix4.getTranslation(model.modelMatrix, new Cesium.Cartesian3());
|
||
const world = modelAnchorWorld(model, anchor, new Cesium.Cartesian3());
|
||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(origin, Cesium.Ellipsoid.WGS84, new Cesium.Matrix4());
|
||
const ax = (i) => {
|
||
const c = Cesium.Matrix4.getColumn(enu, i, new Cesium.Cartesian4());
|
||
return Cesium.Cartesian3.normalize(new Cesium.Cartesian3(c.x, c.y, c.z), new Cesium.Cartesian3());
|
||
};
|
||
const rad = Cesium.Math.toRadians(headingDeg);
|
||
const fwd = Cesium.Cartesian3.normalize(Cesium.Cartesian3.add(
|
||
Cesium.Cartesian3.multiplyByScalar(ax(1), Math.cos(rad), new Cesium.Cartesian3()),
|
||
Cesium.Cartesian3.multiplyByScalar(ax(0), Math.sin(rad), new Cesium.Cartesian3()),
|
||
new Cesium.Cartesian3()), new Cesium.Cartesian3());
|
||
const lateral = Cesium.Cartesian3.normalize(
|
||
Cesium.Cartesian3.cross(fwd, ax(2), new Cesium.Cartesian3()), new Cesium.Cartesian3());
|
||
const side = Cesium.Cartesian3.dot(
|
||
Cesium.Cartesian3.subtract(world, origin, new Cesium.Cartesian3()), lateral);
|
||
assert.ok(Math.abs(side) < 1e-4,
|
||
`bell206 @ heading ${headingDeg}: hovering rotorcraft anchor drifted sideways by ${side.toFixed(4)} m`);
|
||
}
|
||
});
|
||
|
||
test('legacy aircraft GLBs keep scale and orientation baked with no node transforms', () => {
|
||
for (const url of Object.keys(MODEL_VISUAL_CENTER_NATIVE)) {
|
||
const file = path.join(ROOT, 'public', url);
|
||
const gltf = glbJson(file);
|
||
for (const node of gltf.nodes || []) {
|
||
assert.equal(node.matrix, undefined, `${url}: ${node.name || 'node'} has an unapplied matrix`);
|
||
assert.equal(node.translation, undefined, `${url}: ${node.name || 'node'} has unapplied translation`);
|
||
assert.equal(node.rotation, undefined, `${url}: ${node.name || 'node'} has unapplied rotation`);
|
||
assert.equal(node.scale, undefined, `${url}: ${node.name || 'node'} has unapplied scale`);
|
||
}
|
||
const { min, max } = nativeBounds(file);
|
||
const extents = max.map((value, axis) => value - min[axis]);
|
||
assert.ok(
|
||
extents[1] < extents[0] && extents[1] < extents[2],
|
||
`${url}: glTF +Y must remain the short vertical axis; got ${extents.map((v) => v.toFixed(2)).join(' × ')}`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('real per-class models remain origin-centred for visual anchoring', () => {
|
||
for (const spec of Object.values(CLASS_MODEL_REAL)) {
|
||
const center = nativeVisualCenter(path.join(ROOT, 'public', spec.url));
|
||
const maxOffset = Math.max(...center.map(Math.abs));
|
||
assert.ok(
|
||
maxOffset <= 0.001,
|
||
`${spec.url}: visual centre ${center.map((v) => v.toFixed(4)).join(', ')} ` +
|
||
'must stay at the origin or receive explicit modelVisualAnchor metadata',
|
||
);
|
||
}
|
||
});
|
||
|
||
test('civilian and military globe-view aircraft retain the established 3.0 near scale and 0.5 floor', () => {
|
||
for (const layer of LAYERS) {
|
||
assert.deepEqual(
|
||
normalBillboardScaleByDistance(layer.source),
|
||
[1000, 3, 8000000, 0.5],
|
||
`${layer.name}: owner-established close sizing and the orbital floor must remain readable`,
|
||
);
|
||
}
|
||
});
|