Merge remote-tracking branch 'origin/main' into mazeyar/shared-terrain-surface

# Conflicts:
#	src/app/constructCatalog.js
This commit is contained in:
Mazeyar Moeini Feizabadi committed 2026-11-14 21:03:10 +01:00
commit 7ebe98ba84
354 files changed
+19655 -2848

No files matched your search

+28 -1
View File
@@ -385,6 +385,8 @@
"src/layers/vessels/index.js",
"src/layers/vessels/ingestion.js",
"src/layers/vessels/lifecycle.js",
"src/layers/vessels/photos.js",
"src/layers/vessels/photos.test.mjs",
"src/layers/vessels/policy.js",
"src/layers/vessels/queries.js",
"src/layers/vessels/rendering.js",
@@ -555,6 +557,13 @@
"src/ui/frameRateMonitor.js",
"src/ui/frameRateMonitor.test.mjs",
"src/ui/frameRateView.test.mjs",
"src/ui/renderFailure.test.mjs",
"src/ui/renderFailureView.test.mjs",
"src/ui/renderFailureLoopErrors.test.mjs",
"src/ui/renderFailureEventRetry.test.mjs",
"src/app/renderFailureInlineRetry.test.mjs",
"src/ui/renderFailureSecondLoop.test.mjs",
"src/ui/renderLoopFixture.mjs",
"src/layers/cctv/lifecycle.js",
"src/layers/cctv/controls.js",
"src/layers/cctv/geometry.js",
@@ -1335,9 +1344,13 @@
"src/app/sourceComposition.js",
"src/app/sourceComposition.test.mjs",
"src/data/dataCredits.js",
"src/data/openVesselPhoto.js",
"src/data/openVesselPhoto.test.mjs",
"src/data/layerState.js",
"src/data/layerState.test.mjs",
"src/data/spriteOrder.js",
"src/i18n/page.js",
"src/i18n/page.test.mjs",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
@@ -1422,5 +1435,19 @@
"src/ui/ownerFlagSheet.js",
"src/data/placePacksLoading.test.mjs",
"src/ui/placePacksLoading.js",
"src/ui/placePacksLoading.test.mjs"
"src/ui/placePacksLoading.test.mjs",
"src/cockpitSubjectProviders.js",
"src/cockpitSubjectProviders.test.mjs",
"src/cockpitProgress.js",
"src/cockpitProgress.test.mjs",
"src/ui/cockpitSubjectView.test.mjs",
"src/ui/styles/cockpitSubject.css",
"src/layers/rail/cockpitSubject.js",
"src/layers/rail/cockpitSubject.test.mjs",
"src/layers/transit/cockpitSubject.js",
"src/data/fleetModels.test.mjs",
"src/data/fleetModelInstances.test.mjs",
"src/testSupport/fleetGlb.mjs",
"src/testSupport/fleetModelFakes.mjs",
"scripts/measure-fleet-trail-anchors.mjs"
]
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env node
/**
* Re-measure the trail attachment point of every aircraft-layer fleet model
* and print `src/data/fleetTrailAnchors.js`.
*
* node scripts/measure-fleet-trail-anchors.mjs > src/data/fleetTrailAnchors.js
*
* Run it whenever a model under `public/models/fleet/` changes; the unit
* suite (`src/data/fleetModels.test.mjs`) re-derives each value and fails
* on a table that no longer matches its GLB.
*/
import fs from 'node:fs';
import path from 'node:path';
import { projectRoot } from './project-root.mjs';
import {
fleetMesh,
measuredTrailAnchor,
} from '../src/testSupport/fleetGlb.mjs';
/** Catalog categories the aircraft layers draw (ADS-B ground vehicles and balloons included). */
export const TRAIL_ANCHOR_CATEGORIES = ['aircraft', 'helicopters', 'other'];
const root = projectRoot(import.meta.url);
const dir = path.join(root, 'public', 'models', 'fleet');
const catalog = JSON.parse(
fs.readFileSync(path.join(dir, 'catalog.json'), 'utf8'),
);
const rows = [];
for (const [name, model] of Object.entries(catalog)) {
if (!TRAIL_ANCHOR_CATEGORIES.includes(model.category)) continue;
const anchor = measuredTrailAnchor(
await fleetMesh(path.join(dir, model.file)),
);
if (!anchor) continue;
const [x, y] = anchor.map((value) => Number(value.toFixed(4)) || 0);
rows.push(
` ${/^[a-z_]\w*$/.test(name) ? name : `'${name}'`}: Object.freeze([${x}, ${y}, 0]),`,
);
}
process.stdout.write(`/**
* Aft-belly trail attachment points of the fleet models the aircraft layers
* draw, in raw glTF axes (X along the body, nose toward -X; Y up), metres
* before scale, node transforms applied. Each is the point of the model's
* centreline profile (its z = 0 slice) nearest its aft-belly bounding-box
* corner: on the hull, aft, and on the centreline (see modelVisualAnchor.js).
*
* Written by scripts/measure-fleet-trail-anchors.mjs; re-measured against the
* GLBs by fleetModels.test.mjs.
*/
export const FLEET_TRAIL_ANCHORS = Object.freeze({
${rows.join('\n')}
});
`);
+104 -6
View File
@@ -23,6 +23,9 @@
"src/ui/vueRuntime.js",
"src/ui/views/index.js",
"src/ui/views/CockpitView.vue",
"src/ui/views/panels/CockpitProgress.vue",
"src/cockpitProgress.js",
"src/cockpitSubjectProviders.js",
"src/ui/views/CommandDockView.vue",
"src/ui/views/ContextView.vue",
"src/ui/views/panels/RadioPanel.vue",
@@ -164,6 +167,7 @@
"src/ui/kit/UiBadge.vue",
"src/ui/stores/globeActions.js",
"src/ui/views/panels/GlobeActions.vue",
"src/ui/views/panels/RenderFailure.vue",
"src/ui/views/panels/GlobeCompass.vue",
"src/ui/stores/recording.js",
"src/ui/styleParameters.js",
@@ -176,6 +180,28 @@
],
"external": ["vue", "pinia", "vue-i18n"]
},
"i18n": {
"exports": ["./i18n"],
"modules": [
"src/i18n/page.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/he.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/sv.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
"external": []
},
"time": {
"exports": ["./time"],
"modules": ["src/time/worldClock.js"],
@@ -226,6 +252,7 @@
"src/data/adsbLolFallback.js",
"src/data/aisStreamAdapter.js",
"src/data/aisWatchdog.js",
"src/data/vesselTypes.js",
"src/sources/httpBody.js",
"src/i18n/marker.js",
"server/providers/acartia.js",
@@ -988,6 +1015,8 @@
"src/ui/cockpitInput.js",
"src/data/inputOwnership.js",
"src/ui/cockpitInstruments.js",
"src/cockpitSubjectProviders.js",
"src/cockpitProgress.js",
"src/ui/cockpitLayout.js",
"src/ui/cockpitPresentation.js",
"src/ui/cockpitSignals.js",
@@ -1153,6 +1182,8 @@
"src/ui/cockpitInput.js",
"src/data/inputOwnership.js",
"src/ui/cockpitInstruments.js",
"src/cockpitSubjectProviders.js",
"src/cockpitProgress.js",
"src/ui/cockpitLayout.js",
"src/ui/cockpitPresentation.js",
"src/ui/cockpitSignals.js",
@@ -1335,6 +1366,7 @@
"src/ui/styles/status.css",
"src/ui/styles/cockpit.css",
"src/ui/styles/cockpitBridge.css",
"src/ui/styles/cockpitSubject.css",
"src/ui/styles/cockpitRadar.css",
"src/ui/styles/overlays.css",
"src/ui/styles/layers.css",
@@ -1475,13 +1507,18 @@
"src/sources/live/index.js",
"src/sources/live/standalone.js",
"src/sources/live/vessels.js",
"src/data/planespottersPhoto.js"
"src/data/planespottersPhoto.js",
"src/data/openVesselPhoto.js"
],
"external": []
},
"civil-flights": {
"exports": ["./layers/flights"],
"modules": [
"src/data/fleetModels.js",
"src/data/vesselTypes.js",
"src/data/fleetTrailAnchors.js",
"src/data/fleetModelInstances.js",
"src/data/headingDegrees.js",
"src/i18n/marker.js",
"src/cyberSonar.js",
@@ -1556,6 +1593,12 @@
"military-flights": {
"exports": ["./layers/military"],
"modules": [
"src/layers/flights/modelSpec.js",
"src/layers/flights/policy.js",
"src/data/fleetModels.js",
"src/data/vesselTypes.js",
"src/data/fleetTrailAnchors.js",
"src/data/fleetModelInstances.js",
"src/data/headingDegrees.js",
"src/i18n/marker.js",
"src/cyberSonar.js",
@@ -1655,6 +1698,13 @@
"vessel-layer": {
"exports": ["./layers/vessels"],
"modules": [
"src/data/fleetModels.js",
"src/data/aircraftClass.js",
"src/i18n/marker.js",
"src/data/fleetModelInstances.js",
"src/data/aircraftModelLighting.js",
"src/data/modelEligibility.js",
"src/data/fleetTrailAnchors.js",
"src/data/headingDegrees.js",
"src/layers/vessels/cards.js",
"src/layers/vessels/evidence.js",
@@ -1697,7 +1747,9 @@
"src/i18n/locales/sv.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
"src/i18n/locales/zh-Hant.json",
"src/layers/vessels/photos.js",
"src/data/openVesselPhoto.js"
],
"external": ["cesium"]
},
@@ -2522,6 +2574,10 @@
"./standalone/catalog"
],
"modules": [
"src/data/fleetModels.js",
"src/data/fleetTrailAnchors.js",
"src/data/fleetModelInstances.js",
"src/app/layers/fleetModels.js",
"src/sources/adsbFields.js",
"src/data/headingDegrees.js",
"src/data/distanceFormat.js",
@@ -3074,6 +3130,8 @@
"src/ui/displayPanelScroll.js",
"src/ui/cockpitInput.js",
"src/ui/cockpitInstruments.js",
"src/cockpitSubjectProviders.js",
"src/cockpitProgress.js",
"src/ui/cockpitLayout.js",
"src/ui/cockpitPresentation.js",
"src/ui/cockpitSignals.js",
@@ -3091,6 +3149,7 @@
"src/ui/displayControls.js",
"src/ui/effects.js",
"src/ui/frameRateMonitor.js",
"src/ui/renderFailure.js",
"src/ui/layerPanel.js",
"src/ui/weatherPanel.js",
"src/ui/railCards.js",
@@ -3261,6 +3320,7 @@
"src/app/layers/rail.js",
"src/data/railFeeds.js",
"src/layers/rail/index.js",
"src/layers/rail/cockpitSubject.js",
"src/layers/rail/source.js",
"src/layers/rail/policy.js",
"src/layers/rail/ingestion.js",
@@ -3287,6 +3347,7 @@
"src/layers/transit/controls.js",
"src/layers/transit/height.js",
"src/layers/transit/index.js",
"src/layers/transit/cockpitSubject.js",
"src/layers/transit/ingestion.js",
"src/layers/transit/lifecycle.js",
"src/layers/transit/policy.js",
@@ -3760,7 +3821,9 @@
"src/data/ucsSatellites.js",
"src/data/imagingSensors.js",
"src/data/planespottersPhoto.js",
"src/ui/stores/dataLibrary.js"
"src/ui/stores/dataLibrary.js",
"src/data/openVesselPhoto.js",
"src/layers/vessels/photos.js"
],
"external": [
"hls.js",
@@ -3787,6 +3850,10 @@
"application-layer-construction": {
"exports": ["./application/layers"],
"modules": [
"src/data/fleetModels.js",
"src/data/fleetTrailAnchors.js",
"src/data/fleetModelInstances.js",
"src/app/layers/fleetModels.js",
"src/data/feedState.js",
"src/loadingFeedback.js",
"src/sources/adsbFields.js",
@@ -4244,6 +4311,7 @@
"src/app/layers/rail.js",
"src/data/railFeeds.js",
"src/layers/rail/index.js",
"src/layers/rail/cockpitSubject.js",
"src/layers/rail/source.js",
"src/layers/rail/policy.js",
"src/layers/rail/ingestion.js",
@@ -4269,6 +4337,7 @@
"src/layers/transit/controls.js",
"src/layers/transit/height.js",
"src/layers/transit/index.js",
"src/layers/transit/cockpitSubject.js",
"src/layers/transit/ingestion.js",
"src/layers/transit/lifecycle.js",
"src/layers/transit/policy.js",
@@ -4471,7 +4540,9 @@
"src/layers/satellites/sensors.js",
"src/data/ucsSatellites.js",
"src/data/imagingSensors.js",
"src/data/planespottersPhoto.js"
"src/data/planespottersPhoto.js",
"src/layers/vessels/photos.js",
"src/data/openVesselPhoto.js"
],
"external": [
"hls.js",
@@ -4752,7 +4823,10 @@
"src/data/approxDistance.js",
"src/data/renderAltitude.js",
"src/data/aircraftClass.js",
"src/data/aircraftEmergency.js"
"src/data/aircraftEmergency.js",
"src/data/fleetModels.js",
"src/data/vesselTypes.js",
"src/data/fleetTrailAnchors.js"
],
"external": []
},
@@ -5141,6 +5215,14 @@
"transit-standalone": {
"exports": ["./layers/transit/standalone"],
"modules": [
"src/data/fleetModels.js",
"src/data/vesselTypes.js",
"src/data/aircraftClass.js",
"src/i18n/marker.js",
"src/data/fleetModelInstances.js",
"src/data/modelEligibility.js",
"src/data/fleetTrailAnchors.js",
"src/data/aircraftModelLighting.js",
"src/data/headingDegrees.js",
"src/cyberSonar.js",
"src/celestialRing.js",
@@ -5172,6 +5254,7 @@
"src/layers/transit/controls.js",
"src/layers/transit/height.js",
"src/layers/transit/index.js",
"src/layers/transit/cockpitSubject.js",
"src/layers/transit/ingestion.js",
"src/layers/transit/lifecycle.js",
"src/layers/transit/movement.js",
@@ -5225,6 +5308,14 @@
"transit-layer": {
"exports": ["./layers/transit"],
"modules": [
"src/i18n/marker.js",
"src/data/fleetModels.js",
"src/data/vesselTypes.js",
"src/data/fleetModelInstances.js",
"src/data/aircraftClass.js",
"src/data/aircraftModelLighting.js",
"src/data/fleetTrailAnchors.js",
"src/data/modelEligibility.js",
"src/data/headingDegrees.js",
"src/data/iconOrientation.js",
"src/data/inputOwnership.js",
@@ -5237,6 +5328,7 @@
"src/layers/transit/height.js",
"src/services/terrainSurface.js",
"src/layers/transit/index.js",
"src/layers/transit/cockpitSubject.js",
"src/layers/transit/ingestion.js",
"src/layers/transit/lifecycle.js",
"src/layers/transit/policy.js",
@@ -5726,7 +5818,8 @@
"src/layers/satellites/sensors.js",
"src/data/ucsSatellites.js",
"src/data/imagingSensors.js",
"src/data/planespottersPhoto.js"
"src/data/planespottersPhoto.js",
"src/data/openVesselPhoto.js"
],
"external": ["@mapbox/vector-tile", "pbf"]
},
@@ -5858,5 +5951,10 @@
"src/layers/wildlifeSightings/performance.js"
],
"external": []
},
"rail-paths": {
"exports": ["./layers/rail/paths"],
"modules": ["src/data/railPaths.js"],
"external": []
}
}
+75
View File
@@ -339,6 +339,45 @@ try {
'#cctv-frame',
(image) => !image.getAttribute('src') && image.dataset.cameraId === '',
);
// The address bar follows the share state after a short delay: wait for the
// CCTV panel token (1 collapsed, 0 open) the step expects.
const panelState = async (expectedToken) => {
const settled = await page
.waitForFunction(
(token) =>
new RegExp(`(?:^|_)v\\.c\\.${token}(?:_|$)`).test(
new URLSearchParams(location.hash.slice(1)).get('ui') || '',
),
{ timeout: 5000 },
expectedToken,
)
.then(
() => true,
() => false,
);
return page.evaluate(
(shared) => ({
collapsed: document
.getElementById('cctv-panel')
.classList.contains('collapsed'),
shared,
ui: new URLSearchParams(location.hash.slice(1)).get('ui'),
}),
settled ? expectedToken : null,
);
};
const closedOnDisable = await panelState('1');
check(
'disable collapses the panel and its share preference',
closedOnDisable.collapsed && closedOnDisable.shared === '1',
JSON.stringify(closedOnDisable),
);
// The switch sits inside the collapsed panel: reopen it from the right rail.
await page.click('[data-rail-item="cctv-panel"]');
await page.waitForFunction(
() =>
!document.getElementById('cctv-panel').classList.contains('collapsed'),
);
await page.click('#cctv-enable-btn');
await page.waitForFunction(
() =>
@@ -351,6 +390,42 @@ try {
'disable clears the preview and re-enable reacquires the camera frame',
disabledCleanly,
);
// Contacts switches CCTV off to isolate itself and restores it on the way
// out: the open panel and its share preference survive the round trip.
const cameraBeforeContacts = await page.evaluate(
() =>
window.__godsEyeView.styleManager._cctvControls.getState()
?.activeCameraId,
);
const entered = await page.evaluate(() =>
window.__godsEyeView.styleManager.setContextMode('flights'),
);
await page.waitForFunction(
() => !window.__godsEyeView.dataManager.isEnabled('cctv'),
);
const duringContacts = await panelState('0');
await page.evaluate(() =>
window.__godsEyeView.styleManager.setContextMode(null),
);
await page.waitForFunction(() =>
window.__godsEyeView.dataManager.isEnabled('cctv'),
);
const afterContacts = await panelState('0');
check(
'Contacts isolation keeps the open CCTV panel and restores its camera',
entered?.ok === true &&
!duringContacts.collapsed &&
duringContacts.shared === '0' &&
!afterContacts.collapsed &&
afterContacts.shared === '0' &&
(await page.evaluate(
(cameraId) =>
window.__godsEyeView.styleManager._cctvControls.getState()
?.activeCameraId === cameraId,
cameraBeforeContacts,
)),
JSON.stringify({ entered, duringContacts, afterContacts }),
);
await page.$eval('#cctv-frame', (image) =>
image.scrollIntoView({ block: 'nearest' }),
);
+3 -3
View File
@@ -40,7 +40,7 @@
* the DISPLAYED (render-delayed) time within 35° — catches
* sign/offset inversion in the whole chain (chord lags the
* tangent by ≤ half a segment ≈ 22.5°)
* Both layers: flights (airplane.glb, offset 180°) then military (jet.glb,
* Both layers: flights (fleet models, offset 180°) then military (fleet models,
* normalized nose -X, offset 180°).
*
* VISUAL — with the turning flights plane tracked in 3D, capture screenshots
@@ -721,7 +721,7 @@ async function main() {
// ========================================================================
// Phase 1 — FLIGHTS layer: track TRN001, sample displayed course 30 s
// ========================================================================
console.log('\nPhase 1 — flights layer (airplane.glb, heading offset 180°)');
console.log('\nPhase 1 — flights layer (fleet models, heading offset 180°)');
await page.evaluate((icao) => {
window.__godsEyeView.dataManager.layers.get('flights').module.trackById(icao);
}, TURN.flights[0].icao);
@@ -807,7 +807,7 @@ async function main() {
// ========================================================================
// Phase 2 — MILITARY layer: track TRNMIL1 (flights layer auto-clears)
// ========================================================================
console.log('\nPhase 2 — military layer (jet.glb, heading offset 180°)');
console.log('\nPhase 2 — military layer (fleet models, heading offset 180°)');
await page.evaluate((hex) => {
window.__godsEyeView.dataManager.layers.get('military').module.trackById(hex);
}, TURN.military[0].hex);
+1 -1
View File
@@ -75,7 +75,7 @@ try {
}
const position = C.Cartesian3.fromDegrees(0, 0, 10000);
const model = await C.Model.fromGltfAsync({
url: '/models/airplane.glb',
url: '/models/fleet/aircraft/a320.glb',
modelMatrix: C.Transforms.eastNorthUpToFixedFrame(position),
});
scene.primitives.add(model);
+32 -33
View File
@@ -15,9 +15,9 @@
* per-class glyph (image data-URI === aircraftIcon(klass),
* imported from src) and per-class scale (flights:
* CLASS_SCALE_2D; military: 0.7 × CLASS_SCALE_2D)
* B3 models : with 3D on, fleet Cesium.Model.scale === the layer's
* MODEL_SCALE (both 1; legacy scale is baked into the GLB)
* × CLASS_SCALE_3D[klass] per icao; tracked too
* B3 models : with 3D on, fleet Cesium.Model.scale === the life-size
* scale of the fleet model the aircraft's type or category
* selects (aircraftModelSpec) per icao; tracked too
* B4 clean : no console errors
*
* Visual output (qa-shots/b5/, untracked): top-down 2D spreads of both rows,
@@ -33,15 +33,18 @@ import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { stubPlanespotters } from './qa-photo-stub.mjs';
import { classifyAircraft, CLASS_SCALE_2D, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
// Hangar fleet (2026-08-16): real per-class GLBs render at scale 1; military
// heavies (airliner/quadjet/glider) render airplane.glb at 1 x class; only
// fastjet/unknown keep the per-layer jet/airplane MODEL_SCALE formula.
const flightsWantScale = (klass) => (CLASS_MODEL_REAL[klass] ? 1 : FL_MODEL_SCALE * CLASS_SCALE_3D[klass]);
const MIL_PLANE_CLASSES = new Set(['airliner', 'quadjet', 'glider']);
const militaryWantScale = (klass) => (CLASS_MODEL_REAL[klass] ? 1
: MIL_PLANE_CLASSES.has(klass) ? MIL_PLANE_MODEL_SCALE * CLASS_SCALE_3D[klass]
: MIL_MODEL_SCALE * CLASS_SCALE_3D[klass]);
import { classifyAircraft, CLASS_SCALE_2D } from '../src/data/aircraftClass.js';
import { aircraftModelSpec } from '../src/data/fleetModels.js';
// Every aircraft draws its fleet model life size: its type's length over the
// model's built length, chosen by type code (military) or category (flights).
const FLEET_SET = Object.freeze({
catalog: JSON.parse(fs.readFileSync(new URL('../public/models/fleet/catalog.json', import.meta.url), 'utf8')),
types: JSON.parse(fs.readFileSync(new URL('../public/models/fleet/types.json', import.meta.url), 'utf8')),
});
const flightsWantScale = (icao) =>
aircraftModelSpec(FLEET_SET, { category: SPRITES.flights.find((p) => p.icao === icao)?.category }).scale;
const militaryWantScale = (hex) =>
aircraftModelSpec(FLEET_SET, { typeCode: SPRITES.military.find((p) => p.hex === hex)?.t }).scale;
import { aircraftIcon } from '../src/data/aircraftIcons.js';
// ---------------------------------------------------------------------------
@@ -58,9 +61,6 @@ const APP_URL = getOpt('--url', 'http://localhost:4173');
const HEADFUL = getFlag('--headful');
const SHOT_DIR = path.resolve('qa-shots/b5');
const FL_MODEL_SCALE = 1; // flights.js (airplane.glb is transform-applied and meter-scale)
const MIL_PLANE_MODEL_SCALE = 1; // military heavy classes share the same baked airplane.glb
const MIL_MODEL_SCALE = 1; // militaryFlights.js (jet.glb is already real-world scale, native radius ~29.8 m)
const MIL_BILLBOARD_SCALE = 0.7; // militaryFlights BILLBOARD_SCALE
const CHROME_EXECUTABLE_CANDIDATES = [
@@ -134,10 +134,9 @@ const SPRITES = {
// course 0 like the rest — keeps the row a clean size-comparison line (2D
// rotation is demoed by caa004 and gated by qa-heading-b3 anyway).
{ hex: 'dda008', flight: 'ML-FJ', t: 'F16', lon0: ROW_LON0 + 7 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 15000 },
// 3D cluster: C17 vs F16 (tracked) vs B744 vs C130. jet.glb is a
// real-world-scale asset (native bounding radius ~29.8) rendered at
// MODEL_SCALE 1, so models are ~22–43 m radius — same 150 m spacing and
// 450–900 m shot ranges as the flights cluster.
// 3D cluster: C17 vs F16 (tracked) vs B744 vs C130, each drawn life size
// (~8–36 m radius) — same 150 m spacing and 450–900 m shot ranges as the
// flights cluster.
{ hex: 'ddb001', flight: '3DMWID', t: 'C17', lon0: CLUSTER_LON0 - mDegLon(150, 30.05), lat0: 30.05, courseDeg: 0, speedMps: 60, altFt: 12000 },
{ hex: 'ddb002', flight: '3DMFJ', t: 'F16', lon0: CLUSTER_LON0, lat0: 30.05, courseDeg: 0, speedMps: 60, altFt: 12000 },
{ hex: 'ddb003', flight: '3DMQUA', t: 'B744', lon0: CLUSTER_LON0 + mDegLon(150, 30.05), lat0: 30.05, courseDeg: 0, speedMps: 60, altFt: 12000 },
@@ -464,7 +463,7 @@ async function main() {
// ========================================================================
// B3 + 3D screenshots — flights cluster
// ========================================================================
console.log('\n3D — flights cluster (airplane.glb, per-class scale)');
console.log('\n3D — flights cluster (fleet models, life size)');
await page.evaluate(() => {
const dm = window.__godsEyeView.dataManager;
dm.layers.get('flights').module.setParams({ models3d: true });
@@ -481,20 +480,20 @@ async function main() {
const flModels = await page.evaluate(() => window.__collectModels().filter((m) => m.ready));
const modelScaleBad = [];
for (const [id, want] of [
['cbb001', flightsWantScale(expectedFlights.get('cbb001'))],
['cbb003', flightsWantScale(expectedFlights.get('cbb003'))],
['cbb001', flightsWantScale('cbb001')],
['cbb003', flightsWantScale('cbb003')],
]) {
const m = flModels.find((x) => x.id === id);
if (!m || Math.abs(m.scale - want) > 1e-9) modelScaleBad.push(`${id}: ${m ? m.scale : 'missing'} != ${want}`);
}
const trackedM = flModels.find((x) => x.id === 'cbb002');
const trackedWant = flightsWantScale(expectedFlights.get('cbb002'));
const trackedWant = flightsWantScale('cbb002');
if (!trackedM || Math.abs(trackedM.scale - trackedWant) > 1e-9) {
modelScaleBad.push(`tracked: ${trackedM ? trackedM.scale : 'missing'} != ${trackedWant}`);
}
record('B3a flights: model scales match the per-class registry (all GLBs meter-scale; shared @1×class)',
record('B3a flights: model scales match each type's life-size fleet model',
modelScaleBad.length === 0,
modelScaleBad.length ? modelScaleBad.join(' ') : 'all exact per registry');
modelScaleBad.length ? modelScaleBad.join(' ') : 'all exact');
console.log(` world bounding radii (m): ${flModels.filter((m) => m.scale !== undefined).map((m) => `${m.id ?? 'tracked'}=${m.radius?.toFixed(1)}`).join(' ')}`);
await sleep(1500);
@@ -509,7 +508,7 @@ async function main() {
// ========================================================================
// B3 + 3D screenshots — military cluster
// ========================================================================
console.log('\n3D — military cluster (jet.glb, per-class scale)');
console.log('\n3D — military cluster (fleet models, life size)');
await page.evaluate(() => {
const dm = window.__godsEyeView.dataManager;
dm.layers.get('military').module.setParams({ models3d: true });
@@ -526,25 +525,25 @@ async function main() {
const milModels = await page.evaluate(() => window.__collectModels().filter((m) => m.ready));
const modelScaleBad = [];
for (const [id, want] of [
['ddb001', militaryWantScale(expectedMilitary.get('ddb001'))],
['ddb003', militaryWantScale(expectedMilitary.get('ddb003'))],
['ddb004', militaryWantScale(expectedMilitary.get('ddb004'))],
['ddb001', militaryWantScale('ddb001')],
['ddb003', militaryWantScale('ddb003')],
['ddb004', militaryWantScale('ddb004')],
]) {
const m = milModels.find((x) => x.id === id);
if (!m || Math.abs(m.scale - want) > 1e-9) modelScaleBad.push(`${id}: ${m ? m.scale : 'missing'} != ${want}`);
}
const trackedM = milModels.find((x) => x.id === 'ddb002');
const trackedWant = militaryWantScale(expectedMilitary.get('ddb002'));
const trackedWant = militaryWantScale('ddb002');
if (!trackedM || Math.abs(trackedM.scale - trackedWant) > 1e-9) {
modelScaleBad.push(`tracked: ${trackedM ? trackedM.scale : 'missing'} != ${trackedWant}`);
}
record('B3b military: model scales match the weight-class registry (real/heavy/jet assets @1×class)',
record('B3b military: model scales match each type's life-size fleet model',
modelScaleBad.length === 0,
modelScaleBad.length ? modelScaleBad.join(' ') : 'all exact per registry');
modelScaleBad.length ? modelScaleBad.join(' ') : 'all exact');
console.log(` world bounding radii (m): ${milModels.filter((m) => m.scale !== undefined).map((m) => `${m.id ?? 'tracked'}=${m.radius?.toFixed(1)}`).join(' ')}`);
await sleep(1500);
// jet.glb at MODEL_SCALE 1 is real-world size — same shot ranges as the
// Fleet models are drawn life size — same shot ranges as the
// flights cluster (close zoom no longer puts the camera inside a model).
await zoomToRange(900);
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-military-far.png') });
+24 -24
View File
@@ -97,9 +97,15 @@ import fs from 'node:fs';
import puppeteer from 'puppeteer';
import { respondToPlanespotters } from './qa-photo-stub.mjs';
import { arrivalAngleViolated, arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
import { aircraftModelSpec } from '../src/data/fleetModels.js';
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
/** The fleet model set the aircraft layers draw from, read from disk. */
const FLEET_SET = Object.freeze({
catalog: JSON.parse(fs.readFileSync(new URL('../public/models/fleet/catalog.json', import.meta.url), 'utf8')),
types: JSON.parse(fs.readFileSync(new URL('../public/models/fleet/types.json', import.meta.url), 'utf8')),
});
// ---------------------------------------------------------------------------
// Args
// ---------------------------------------------------------------------------
@@ -547,13 +553,13 @@ async function main() {
// Independent GLB capability control. Product model absence cannot prove
// that the browser backend is incapable: that is the condition this
// harness is meant to catch. Load the already-approved airplane asset
// harness is meant to catch. Load one of the shipped fleet models
// directly through Cesium, outside the Flights/Military model pipelines,
// then remove the control before exercising product behavior.
const glbControlStarted = await evalPage(async () => {
window.__qaGlbControl = null;
window.__qaGlbControlError = null;
const asset = await fetch('/models/airplane.glb');
const asset = await fetch('/models/fleet/aircraft/a320.glb');
const assetBytes = asset.ok ? (await asset.arrayBuffer()).byteLength : 0;
if (!asset.ok || assetBytes === 0) {
throw new Error(`GLB control asset unavailable: HTTP ${asset.status}`);
@@ -564,7 +570,7 @@ async function main() {
Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 9000),
);
void Cesium.Model.fromGltfAsync({
url: '/models/airplane.glb',
url: '/models/fleet/aircraft/a320.glb',
modelMatrix,
scale: 1,
}).then((model) => {
@@ -1454,8 +1460,8 @@ async function main() {
}, trackedIcao);
console.log(' (3D model did not render under SwiftShader; testing the shared display-position contract directly)');
} else {
// Full check: independently transform airplane.glb's measured AABB centre
// through the actual 3D model matrix, then compare it with detection.
// Full check: independently transform the fleet model's visual centre
// (its origin) through the actual 3D model matrix, then compare it with detection.
// glTF [x,y,z] → Cesium-local [x,-z,y]. The effective rendered
// computedScale is separate from modelMatrix and includes any
// minimumPixelSize enlargement, so include it just as the renderer does.
@@ -1469,7 +1475,7 @@ async function main() {
const scale = Number.isFinite(model.computedScale)
? model.computedScale
: (Number.isFinite(model.scale) ? model.scale : 1);
// airplane.glb is now bounding-box centred with transforms applied.
// Fleet models are drawn about their origin, transforms applied.
const x = 0 * scale;
const y = 0 * scale;
const z = 0 * scale;
@@ -2229,18 +2235,12 @@ async function main() {
// ============================================================
console.log('\nGround 3D — grounded planes are model-eligible + snapped to the sampled skin');
const GROUND3D_STUB_H = 187.5; // stubbed tile-skin height (m, ellipsoid)
// Expected belly offsets (m): native origin-above-lowest-vertex × MODEL_SCALE
// × CLASS_SCALE_3D[klass] — constants match the layers' MODEL_BELLY_OFFSET_NATIVE
// / MODEL_SCALE pairs, which modelScale.test.mjs locks against the GLBs.
// Class is computed with the REAL classifier on the synthetic feed fields.
const g3dFlKlass = classifyAircraft({ typeCode: null, category: null });
const g3dMilKlass = classifyAircraft({ typeCode: 'C130', category: undefined });
const g3dFlOffset = 6.719 * 1 * (CLASS_SCALE_3D[g3dFlKlass] || 1); // airplane.glb, flights
// Hangar fleet (2026-08-16): military C130 → turboprop → the real atr72.glb,
// whose belly offset is the registry's measured bellyM (meters, scale 1) —
// NOT the jet.glb 5.631 × class formula. Pinned by modelScale.test.mjs.
const g3dMilOffset = CLASS_MODEL_REAL[g3dMilKlass]?.bellyM
?? 5.631 * 1 * (CLASS_SCALE_3D[g3dMilKlass] || 1); // jet.glb fallback classes
// Expected belly offsets (m): the fleet model's wheel clearance at its
// life-size scale, the model chosen as the layers choose it
// (aircraftModelSpec) from the synthetic feed's fields: no type for the
// flights contact (the default airliner), a C130 for the military one.
const g3dFlOffset = aircraftModelSpec(FLEET_SET, { typeCode: null }).clearanceM;
const g3dMilOffset = aircraftModelSpec(FLEET_SET, { typeCode: 'C130' }).clearanceM;
// Height-datum fix (Task 6): the flights-layer billboard for a grounded synthetic
// (no geo_altitude in this stub feed, no warm terrain-height cache in a headless
// run) now renders at baroM + geoidHeight(lat, lon) rather than the raw baro
@@ -2509,10 +2509,10 @@ async function main() {
const flDelta = g3dState.flModelRadius - g3dIngest.flBBRadius;
const milDelta = g3dState.milModelRadius - g3dIngest.milBBRadius;
const HEIGHT_TOL_M = 1.0;
record(`ground-3d: flights model height = sampled skin + belly offset (${g3dFlKlass})`,
record(`ground-3d: flights model height = sampled skin + belly offset (default airliner)`,
Math.abs(flDelta - flWantDelta) <= HEIGHT_TOL_M,
`Δradial=${flDelta.toFixed(2)} m want=${flWantDelta.toFixed(2)} m (stub ${GROUND3D_STUB_H} + offset ${g3dFlOffset.toFixed(2)} − alt ${(150 + g3dFlGeoidN).toFixed(2)} [150 baro + ${g3dFlGeoidN.toFixed(2)} geoidN])`);
record(`ground-3d: military model height = sampled skin + belly offset (${g3dMilKlass})`,
record(`ground-3d: military model height = sampled skin + belly offset (C130)`,
Math.abs(milDelta - milWantDelta) <= HEIGHT_TOL_M,
`Δradial=${milDelta.toFixed(2)} m want=${milWantDelta.toFixed(2)} m (stub ${GROUND3D_STUB_H} + offset ${g3dMilOffset.toFixed(2)} − alt 0)`);
record('ground-3d: billboard→model handoff holds on the ground (icons hidden once models render)',
@@ -2712,7 +2712,7 @@ async function main() {
const scale = Number.isFinite(m.computedScale)
? m.computedScale
: (Number.isFinite(m.scale) ? m.scale : 1);
// airplane.glb is now bounding-box centred with transforms applied.
// Fleet models are drawn about their origin, transforms applied.
const x = 0 * scale;
const y = 0 * scale;
const z = 0 * scale;
@@ -3722,8 +3722,8 @@ async function main() {
// WARM ground, camera IN: the placement. The model is not merely visible,
// it is standing ON the floor — within one class belly offset above it,
// never under it. MAX_BELLY_M clears the largest offset any class adds
// (widebody b789, 7.81 m in CLASS_MODEL_REAL); the floor itself is the
// never under it. MAX_BELLY_M clears the largest wheel clearance any
// fleet aircraft stands on at life size; the floor itself is the
// hard lower bound, and a model placed at the raw feed altitude is ~240 m
// under it.
const MAX_BELLY_M = 12;