Files
gods-eye-view/src/data/modelScale.test.mjs
T

929 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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`,
);
}
});