mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
Complete phase 3 W1 acceptance and close T16 findings
Fix Navara model display tint, overlay ordering, follow gestures and resource cleanup. Reduce repeated aircraft work and correct fixture enrichment responses. Strengthen dev/preview QA, preserve failure history and document verified W1 results. Validation: 7171 ordinary tests pass, one Windows-only skip; 15 allocation probes; 37 W1 acceptance steps; contracts, shell, four performance gates; 18 identical Cesium baseline PNGs. Known pre-existing CCTV and live-source limits are recorded.
This commit is contained in:
1 parent
766c786093
commit
0600964249
59 files changed
+2334
-701
No files matched your search
@@ -88,8 +88,12 @@ export function createQaSources(entries, { now = () => Date.now() } = {}) {
|
||||
if (layer === 'flights')
|
||||
sources.flights = createFlightSource({
|
||||
now,
|
||||
fetchImpl: async () =>
|
||||
json(syntheticFlights({ nowMs: now(), count: count ?? 8000 })),
|
||||
fetchImpl: async (url) => {
|
||||
const request = String(url);
|
||||
if (request.includes('/api/adsbdb/')) return json({ found: false });
|
||||
if (request.includes('/api/flights/track')) return json({ path: [] });
|
||||
return json(syntheticFlights({ nowMs: now(), count: count ?? 8000 }));
|
||||
},
|
||||
});
|
||||
if (layer === 'military')
|
||||
sources.military = createMilitarySource({
|
||||
@@ -107,6 +111,8 @@ export function createQaSources(entries, { now = () => Date.now() } = {}) {
|
||||
if (layer === 'vessels')
|
||||
sources.vessels = createVesselSource({
|
||||
fetchImpl: async (url) => {
|
||||
if (String(url).includes('/api/vessels/track'))
|
||||
return json({ samples: [] });
|
||||
const params = new URL(url).searchParams;
|
||||
const area = params.has('lat')
|
||||
? {
|
||||
|
||||
@@ -86,3 +86,51 @@ test('local-adsb:fixture answers as the local receivers do (1090 MHz and UAT)',
|
||||
assert.ok(state.records.some((record) => record.band === '978'));
|
||||
assert.ok(state.records.some((record) => record.band === '1090'));
|
||||
});
|
||||
|
||||
test('flight fixture enrichment returns a not-found reply without regenerating the fleet', async () => {
|
||||
let clockReads = 0;
|
||||
const { flights } = createQaSources(
|
||||
parseQaFeed('?qaFeed=flights:synthetic:10000'),
|
||||
{
|
||||
now: () => {
|
||||
clockReads++;
|
||||
return Date.UTC(2026, 9, 16, 12);
|
||||
},
|
||||
},
|
||||
);
|
||||
for (const kind of ['type', 'route']) {
|
||||
const reply = await flights.getEnrichment({ kind, id: 'a00001' });
|
||||
assert.equal(reply.found, false);
|
||||
assert.deepEqual(Object.keys(reply), ['found']);
|
||||
}
|
||||
assert.equal(clockReads, 0);
|
||||
});
|
||||
|
||||
test('flight fixture track reads do not regenerate a snapshot', async () => {
|
||||
let clockReads = 0;
|
||||
const { flights } = createQaSources(
|
||||
parseQaFeed('?qaFeed=flights:synthetic:10000'),
|
||||
{
|
||||
now: () => {
|
||||
clockReads++;
|
||||
return Date.UTC(2026, 9, 16, 12);
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual(await flights.getTrack('a00001'), {
|
||||
records: [],
|
||||
complete: false,
|
||||
});
|
||||
assert.equal(clockReads, 0);
|
||||
});
|
||||
|
||||
test('vessel fixture answers a relative track URL and retains its full snapshot', async () => {
|
||||
const { vessels } = createQaSources(parseQaFeed('?qaFeed=vessels:fixture'), {
|
||||
now: () => Date.UTC(2026, 9, 16, 12),
|
||||
});
|
||||
assert.deepEqual(await vessels.getTrack('990000001'), {
|
||||
records: [],
|
||||
complete: false,
|
||||
});
|
||||
assert.equal((await vessels.getSnapshot({})).records.length, 12000);
|
||||
});
|
||||
@@ -1,309 +1,4 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
const ALPHA_STOPS = [
|
||||
[0, 0.8],
|
||||
[120000, 0.45],
|
||||
[600000, 0.2],
|
||||
[900000, 0.08],
|
||||
];
|
||||
export function trailAlpha(ageMs) {
|
||||
const stops = ALPHA_STOPS;
|
||||
for (let i = 1; i < stops.length; i++) {
|
||||
if (ageMs === stops[i][0]) return stops[i][1];
|
||||
if (ageMs < stops[i][0]) {
|
||||
const [a, x] = stops[i - 1],
|
||||
[b, y] = stops[i];
|
||||
return x + ((y - x) * Math.max(0, ageMs - a)) / (b - a);
|
||||
}
|
||||
}
|
||||
return 0.08;
|
||||
}
|
||||
|
||||
/** One static history batch and one frequently updated head. Never entities. */
|
||||
export function createContactTrailRenderer(scene) {
|
||||
const ground =
|
||||
!!scene.frameState?.context &&
|
||||
Cesium.GroundPolylinePrimitive.isSupported(scene);
|
||||
const collection = ground ? scene.groundPrimitives : scene.primitives;
|
||||
// One 25 m corridor is clipped in material coordinates. Only subdivision
|
||||
// crossings build geometry; frame updates write a scalar, never vertex buffers.
|
||||
const headMaterial = (alpha) =>
|
||||
new Cesium.Material({
|
||||
fabric: {
|
||||
type: 'TransitTrailHead',
|
||||
uniforms: {
|
||||
color: new Cesium.Color(0.37, 0.94, 0.54, alpha),
|
||||
fraction: 0,
|
||||
backing: true,
|
||||
},
|
||||
source: `czm_material czm_getMaterial(czm_materialInput materialInput) {
|
||||
if (materialInput.st.s > fraction) discard;
|
||||
czm_material m = czm_getDefaultMaterial(materialInput);
|
||||
bool edge = abs(materialInput.st.t - 0.5) > 0.3;
|
||||
m.diffuse = czm_gammaCorrect(vec4(edge && backing ? vec3(0.02, 0.03, 0.05) : color.rgb, 1.0)).rgb;
|
||||
m.alpha = color.a;
|
||||
return m;
|
||||
}`,
|
||||
},
|
||||
translucent: true,
|
||||
});
|
||||
const material = headMaterial(0.8),
|
||||
depthMaterial = headMaterial(0.55);
|
||||
depthMaterial.uniforms.backing = false;
|
||||
const head = { positions: [], show: false, width: 3, material };
|
||||
const backing = { positions: [], show: false, width: 5 };
|
||||
let headPrimitive = null,
|
||||
headRebuilds = 0;
|
||||
function geometry(positions, width, material = false) {
|
||||
return ground
|
||||
? new Cesium.GroundPolylineGeometry({ positions, width, granularity: 0 })
|
||||
: new Cesium.PolylineGeometry({
|
||||
positions,
|
||||
width,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
vertexFormat: material
|
||||
? Cesium.PolylineMaterialAppearance.VERTEX_FORMAT
|
||||
: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
|
||||
});
|
||||
}
|
||||
function primitive(instances, appearance, depthFailAppearance) {
|
||||
return ground
|
||||
? new Cesium.GroundPolylinePrimitive({
|
||||
geometryInstances: instances,
|
||||
appearance,
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
allowPicking: false,
|
||||
asynchronous: true,
|
||||
show: visible,
|
||||
})
|
||||
: new Cesium.Primitive({
|
||||
geometryInstances: instances,
|
||||
appearance,
|
||||
depthFailAppearance,
|
||||
allowPicking: false,
|
||||
asynchronous: false,
|
||||
show: visible,
|
||||
});
|
||||
}
|
||||
let body = null,
|
||||
segments = [],
|
||||
revision = -1,
|
||||
visible = true,
|
||||
style = '#5EF08A';
|
||||
let lastSecond = -1,
|
||||
completed = -1,
|
||||
active = null;
|
||||
const styleColor = Cesium.Color.fromCssColorString(style);
|
||||
const attributeIds = [];
|
||||
const color = new Cesium.Color(),
|
||||
endpoint = new Cesium.Cartesian3();
|
||||
const headPositions = [new Cesium.Cartesian3(), endpoint];
|
||||
let rebuilds = 0;
|
||||
function tint(alpha, dark = false) {
|
||||
if (dark) {
|
||||
color.red = 0.02;
|
||||
color.green = 0.03;
|
||||
color.blue = 0.05;
|
||||
} else Cesium.Color.clone(styleColor, color);
|
||||
color.alpha = alpha;
|
||||
return color;
|
||||
}
|
||||
function replaceHistory(next) {
|
||||
if (next.revision === revision) return;
|
||||
revision = next.revision;
|
||||
segments = next.segments;
|
||||
completed = -1;
|
||||
lastSecond = -1;
|
||||
active = null;
|
||||
if (headPrimitive) collection.remove(headPrimitive);
|
||||
headPrimitive = null;
|
||||
head.show = backing.show = false;
|
||||
if (body) collection.remove(body);
|
||||
body = null;
|
||||
const instances = [];
|
||||
attributeIds.length = 0;
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i];
|
||||
attributeIds.push([`${i}:0`, `${i}:1`]);
|
||||
if (segment.positions.length < 2) continue;
|
||||
for (let lane = 0; lane < 2; lane++) {
|
||||
instances.push(
|
||||
new Cesium.GeometryInstance({
|
||||
id: `${i}:${lane}`,
|
||||
geometry: geometry(segment.positions, lane ? 3 : 5),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
tint(0.8, !lane),
|
||||
),
|
||||
depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
tint(0.55),
|
||||
),
|
||||
show: new Cesium.ShowGeometryInstanceAttribute(false),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (instances.length) {
|
||||
body = collection.add(
|
||||
primitive(
|
||||
instances,
|
||||
new Cesium.PolylineColorAppearance({ translucent: true }),
|
||||
new Cesium.PolylineColorAppearance({ translucent: true }),
|
||||
),
|
||||
);
|
||||
rebuilds++;
|
||||
}
|
||||
}
|
||||
function setDisplaySample(sample, renderedPosition) {
|
||||
if (!sample || !renderedPosition) {
|
||||
head.show = false;
|
||||
backing.show = false;
|
||||
if (headPrimitive) headPrimitive.show = false;
|
||||
return;
|
||||
}
|
||||
const second = Math.floor(sample.displayT / 1000);
|
||||
let lo = 0,
|
||||
hi = segments.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (segments[mid].toT <= sample.displayT) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
const done = lo - 1;
|
||||
const candidate = segments[lo];
|
||||
const segment =
|
||||
candidate &&
|
||||
candidate.fromT <= sample.displayT &&
|
||||
candidate.fromSeq === sample.fromSeq &&
|
||||
candidate.toSeq === sample.toSeq
|
||||
? candidate
|
||||
: null;
|
||||
if (body?.ready && done !== completed) {
|
||||
for (
|
||||
let i = Math.min(done, completed) + 1;
|
||||
i <= Math.max(done, completed);
|
||||
i++
|
||||
) {
|
||||
for (let lane = 0; lane < 2; lane++) {
|
||||
const attributes = body.getGeometryInstanceAttributes(
|
||||
attributeIds[i][lane],
|
||||
);
|
||||
if (!attributes) continue;
|
||||
// Cesium getters return copies. Write back the array we changed.
|
||||
const show = attributes.show;
|
||||
show[0] = i <= done ? 1 : 0;
|
||||
attributes.show = show;
|
||||
}
|
||||
}
|
||||
completed = done;
|
||||
}
|
||||
if (body?.ready && second !== lastSecond) {
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const alpha = trailAlpha(sample.displayT - segments[i].toT);
|
||||
for (let lane = 0; lane < 2; lane++) {
|
||||
const attributes = body.getGeometryInstanceAttributes(
|
||||
attributeIds[i][lane],
|
||||
);
|
||||
if (!attributes) continue;
|
||||
const rgba = attributes.color;
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
tint(alpha, !lane),
|
||||
rgba,
|
||||
);
|
||||
attributes.color = rgba;
|
||||
if (!ground) {
|
||||
const depthRgba = attributes.depthFailColor;
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
tint(0.55),
|
||||
depthRgba,
|
||||
);
|
||||
attributes.depthFailColor = depthRgba;
|
||||
}
|
||||
}
|
||||
}
|
||||
lastSecond = second;
|
||||
}
|
||||
const show = visible && !!segment && sample.fraction < 1;
|
||||
head.show = show;
|
||||
backing.show = show;
|
||||
if (headPrimitive) headPrimitive.show = show;
|
||||
if (!show) return;
|
||||
if (active !== segment) {
|
||||
active = segment;
|
||||
if (headPrimitive) collection.remove(headPrimitive);
|
||||
headPrimitive = collection.add(
|
||||
primitive(
|
||||
new Cesium.GeometryInstance({
|
||||
geometry: geometry(segment.positions, 5, true),
|
||||
}),
|
||||
new Cesium.PolylineMaterialAppearance({
|
||||
material,
|
||||
translucent: true,
|
||||
}),
|
||||
new Cesium.PolylineMaterialAppearance({
|
||||
material: depthMaterial,
|
||||
translucent: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
headRebuilds++;
|
||||
headPositions[0] = segment.positions[0];
|
||||
headPositions[1] = endpoint;
|
||||
}
|
||||
Cesium.Cartesian3.clone(renderedPosition, endpoint);
|
||||
head.positions = headPositions;
|
||||
backing.positions = headPositions;
|
||||
material.uniforms.fraction = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
(sample.displayT - segment.fromT) / (segment.toT - segment.fromT),
|
||||
),
|
||||
);
|
||||
depthMaterial.uniforms.fraction = material.uniforms.fraction;
|
||||
Cesium.Color.clone(tint(0.8), material.uniforms.color);
|
||||
Cesium.Color.clone(tint(0.55), depthMaterial.uniforms.color);
|
||||
}
|
||||
return {
|
||||
replaceHistory,
|
||||
setDisplaySample,
|
||||
setStyle(next) {
|
||||
if (next === style) return;
|
||||
style = next;
|
||||
Cesium.Color.fromCssColorString(style, styleColor);
|
||||
lastSecond = -1;
|
||||
},
|
||||
setVisible(next) {
|
||||
visible = next;
|
||||
if (headPrimitive) headPrimitive.show = next && head.show;
|
||||
if (body) body.show = next;
|
||||
},
|
||||
destroy() {
|
||||
if (body) collection.remove(body);
|
||||
if (headPrimitive) collection.remove(headPrimitive);
|
||||
headPrimitive = null;
|
||||
material.destroy();
|
||||
depthMaterial.destroy();
|
||||
body = null;
|
||||
segments = [];
|
||||
},
|
||||
diagnostics() {
|
||||
return {
|
||||
revision,
|
||||
rebuilds,
|
||||
segments: segments.length,
|
||||
body,
|
||||
head,
|
||||
backing,
|
||||
headPrimitive,
|
||||
headStrategy: ground
|
||||
? 'draped corridor with material clip'
|
||||
: 'depth-fail corridor with material clip',
|
||||
ground,
|
||||
headRebuilds,
|
||||
entitiesAdded: 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
export {
|
||||
createContactTrailRenderer,
|
||||
trailAlpha,
|
||||
} from './render/cesiumContactTrailRenderer.js';
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { worldToWindowCoordinates } from './render/cesiumProjection.js';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
|
||||
|
||||
// A window position through the engine's projector when `scene` has a
|
||||
@@ -9,7 +10,7 @@ function worldToWindow(scene, position, result) {
|
||||
const projector = engineFromViewer(scene)?.projector;
|
||||
return projector
|
||||
? projector.worldToWindow(position, result)
|
||||
: Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, result);
|
||||
: worldToWindowCoordinates(scene, position, result);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,7 +43,7 @@ export const DEFAULT_FOCUS_DEEMPHASIS_PARAMS = Object.freeze({
|
||||
let _params = { ...DEFAULT_FOCUS_DEEMPHASIS_PARAMS };
|
||||
let _focusTarget = null;
|
||||
const _spriteStates = new WeakMap();
|
||||
const _scratchScreen = new Cesium.Cartesian2();
|
||||
const _scratchScreen = geo.cartesian2.create();
|
||||
const _advanceResult = {
|
||||
factor: 1,
|
||||
changed: false,
|
||||
@@ -240,10 +241,7 @@ export function publishFocusTargetFromCachedPosition({
|
||||
bottom: screen.y + halfHeight + padding,
|
||||
},
|
||||
paddingPx: padding,
|
||||
cameraDistance: Cesium.Cartesian3.distance(
|
||||
camera.positionWC,
|
||||
displayPosition,
|
||||
),
|
||||
cameraDistance: geo.cartesian3.distance(camera.positionWC, displayPosition),
|
||||
frameNumber: scene.frameState?.frameNumber ?? -1,
|
||||
};
|
||||
if (appeared) {
|
||||
@@ -491,7 +489,7 @@ export function advanceProjectedSpriteFocus(
|
||||
position && scene ? worldToWindow(scene, position, _scratchScreen) : null;
|
||||
const cameraDistance =
|
||||
position && camera?.positionWC
|
||||
? Cesium.Cartesian3.distance(camera.positionWC, position)
|
||||
? geo.cartesian3.distance(camera.positionWC, position)
|
||||
: Number.NaN;
|
||||
return advanceSpriteFocus(sprite, {
|
||||
screenPosition: screen,
|
||||
|
||||
@@ -21,7 +21,10 @@
|
||||
* keeps rotation continuous through tracked orbits; field evidence, not an
|
||||
* unverified math swap, decides whether exact projection becomes preferred.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
worldToWindowCoordinates,
|
||||
horizonOccluder,
|
||||
} from './render/cesiumProjection.js';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
|
||||
|
||||
@@ -31,7 +34,7 @@ function worldToWindow(scene, position, result) {
|
||||
const projector = engineFromViewer(scene)?.projector;
|
||||
return projector
|
||||
? projector.worldToWindow(position, result)
|
||||
: Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, result);
|
||||
: worldToWindowCoordinates(scene, position, result);
|
||||
}
|
||||
|
||||
/** Meters used to give the course vector a stable projection magnitude. */
|
||||
@@ -126,11 +129,6 @@ export function stabilizeScreenRotation(
|
||||
return Math.abs(delta) < Math.max(0, deadbandRad) ? previous : next;
|
||||
}
|
||||
|
||||
const _occluder = new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Half-width of the crossfade band centred on the ellipsoid silhouette, in
|
||||
* radians. ~1.1° per side: at the product's default 60° frustum on a 1280×800
|
||||
@@ -256,10 +254,7 @@ export function skyBackdropFactor(
|
||||
* @param {Cesium.Camera} camera - The scene camera.
|
||||
* @returns {Cesium.EllipsoidalOccluder}
|
||||
*/
|
||||
export function horizonOccluder(camera) {
|
||||
_occluder.cameraPosition = camera.positionWC;
|
||||
return _occluder;
|
||||
}
|
||||
export { horizonOccluder };
|
||||
|
||||
/**
|
||||
* Cheap camera pose signature for "did the camera move" gating of rotation
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { modelAxis } from './render/cesiumConstants.js';
|
||||
import * as geo from '../geo/index.js';
|
||||
/**
|
||||
* Model-space visual centres measured from the shipped GLBs' scene-space AABBs.
|
||||
@@ -78,8 +78,8 @@ export const MODEL_TRAIL_ANCHOR_NATIVE = Object.freeze({
|
||||
* trail anchor has to ride the same chain or it lands on a different axis than
|
||||
* the aircraft it is supposed to be attached to. */
|
||||
const AXIS_CORRECTION = geo.matrix4.multiplyTransformation(
|
||||
Cesium.Axis.Y_UP_TO_Z_UP,
|
||||
Cesium.Axis.Z_UP_TO_X_UP,
|
||||
modelAxis.Y_UP_TO_Z_UP,
|
||||
modelAxis.Z_UP_TO_X_UP,
|
||||
geo.matrix4.create(),
|
||||
);
|
||||
const _anchorScratch = geo.cartesian3.create();
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Native constants shared by the W1 adapters; keep the SDK import here.
|
||||
export {
|
||||
Axis as modelAxis,
|
||||
ScreenSpaceEventType as trackingEventTypes,
|
||||
} from 'cesium';
|
||||
@@ -0,0 +1,309 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
const ALPHA_STOPS = [
|
||||
[0, 0.8],
|
||||
[120000, 0.45],
|
||||
[600000, 0.2],
|
||||
[900000, 0.08],
|
||||
];
|
||||
export function trailAlpha(ageMs) {
|
||||
const stops = ALPHA_STOPS;
|
||||
for (let i = 1; i < stops.length; i++) {
|
||||
if (ageMs === stops[i][0]) return stops[i][1];
|
||||
if (ageMs < stops[i][0]) {
|
||||
const [a, x] = stops[i - 1],
|
||||
[b, y] = stops[i];
|
||||
return x + ((y - x) * Math.max(0, ageMs - a)) / (b - a);
|
||||
}
|
||||
}
|
||||
return 0.08;
|
||||
}
|
||||
|
||||
/** One static history batch and one frequently updated head. Never entities. */
|
||||
export function createContactTrailRenderer(scene) {
|
||||
const ground =
|
||||
!!scene.frameState?.context &&
|
||||
Cesium.GroundPolylinePrimitive.isSupported(scene);
|
||||
const collection = ground ? scene.groundPrimitives : scene.primitives;
|
||||
// One 25 m corridor is clipped in material coordinates. Only subdivision
|
||||
// crossings build geometry; frame updates write a scalar, never vertex buffers.
|
||||
const headMaterial = (alpha) =>
|
||||
new Cesium.Material({
|
||||
fabric: {
|
||||
type: 'TransitTrailHead',
|
||||
uniforms: {
|
||||
color: new Cesium.Color(0.37, 0.94, 0.54, alpha),
|
||||
fraction: 0,
|
||||
backing: true,
|
||||
},
|
||||
source: `czm_material czm_getMaterial(czm_materialInput materialInput) {
|
||||
if (materialInput.st.s > fraction) discard;
|
||||
czm_material m = czm_getDefaultMaterial(materialInput);
|
||||
bool edge = abs(materialInput.st.t - 0.5) > 0.3;
|
||||
m.diffuse = czm_gammaCorrect(vec4(edge && backing ? vec3(0.02, 0.03, 0.05) : color.rgb, 1.0)).rgb;
|
||||
m.alpha = color.a;
|
||||
return m;
|
||||
}`,
|
||||
},
|
||||
translucent: true,
|
||||
});
|
||||
const material = headMaterial(0.8),
|
||||
depthMaterial = headMaterial(0.55);
|
||||
depthMaterial.uniforms.backing = false;
|
||||
const head = { positions: [], show: false, width: 3, material };
|
||||
const backing = { positions: [], show: false, width: 5 };
|
||||
let headPrimitive = null,
|
||||
headRebuilds = 0;
|
||||
function geometry(positions, width, material = false) {
|
||||
return ground
|
||||
? new Cesium.GroundPolylineGeometry({ positions, width, granularity: 0 })
|
||||
: new Cesium.PolylineGeometry({
|
||||
positions,
|
||||
width,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
vertexFormat: material
|
||||
? Cesium.PolylineMaterialAppearance.VERTEX_FORMAT
|
||||
: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
|
||||
});
|
||||
}
|
||||
function primitive(instances, appearance, depthFailAppearance) {
|
||||
return ground
|
||||
? new Cesium.GroundPolylinePrimitive({
|
||||
geometryInstances: instances,
|
||||
appearance,
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
allowPicking: false,
|
||||
asynchronous: true,
|
||||
show: visible,
|
||||
})
|
||||
: new Cesium.Primitive({
|
||||
geometryInstances: instances,
|
||||
appearance,
|
||||
depthFailAppearance,
|
||||
allowPicking: false,
|
||||
asynchronous: false,
|
||||
show: visible,
|
||||
});
|
||||
}
|
||||
let body = null,
|
||||
segments = [],
|
||||
revision = -1,
|
||||
visible = true,
|
||||
style = '#5EF08A';
|
||||
let lastSecond = -1,
|
||||
completed = -1,
|
||||
active = null;
|
||||
const styleColor = Cesium.Color.fromCssColorString(style);
|
||||
const attributeIds = [];
|
||||
const color = new Cesium.Color(),
|
||||
endpoint = new Cesium.Cartesian3();
|
||||
const headPositions = [new Cesium.Cartesian3(), endpoint];
|
||||
let rebuilds = 0;
|
||||
function tint(alpha, dark = false) {
|
||||
if (dark) {
|
||||
color.red = 0.02;
|
||||
color.green = 0.03;
|
||||
color.blue = 0.05;
|
||||
} else Cesium.Color.clone(styleColor, color);
|
||||
color.alpha = alpha;
|
||||
return color;
|
||||
}
|
||||
function replaceHistory(next) {
|
||||
if (next.revision === revision) return;
|
||||
revision = next.revision;
|
||||
segments = next.segments;
|
||||
completed = -1;
|
||||
lastSecond = -1;
|
||||
active = null;
|
||||
if (headPrimitive) collection.remove(headPrimitive);
|
||||
headPrimitive = null;
|
||||
head.show = backing.show = false;
|
||||
if (body) collection.remove(body);
|
||||
body = null;
|
||||
const instances = [];
|
||||
attributeIds.length = 0;
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i];
|
||||
attributeIds.push([`${i}:0`, `${i}:1`]);
|
||||
if (segment.positions.length < 2) continue;
|
||||
for (let lane = 0; lane < 2; lane++) {
|
||||
instances.push(
|
||||
new Cesium.GeometryInstance({
|
||||
id: `${i}:${lane}`,
|
||||
geometry: geometry(segment.positions, lane ? 3 : 5),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
tint(0.8, !lane),
|
||||
),
|
||||
depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
tint(0.55),
|
||||
),
|
||||
show: new Cesium.ShowGeometryInstanceAttribute(false),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (instances.length) {
|
||||
body = collection.add(
|
||||
primitive(
|
||||
instances,
|
||||
new Cesium.PolylineColorAppearance({ translucent: true }),
|
||||
new Cesium.PolylineColorAppearance({ translucent: true }),
|
||||
),
|
||||
);
|
||||
rebuilds++;
|
||||
}
|
||||
}
|
||||
function setDisplaySample(sample, renderedPosition) {
|
||||
if (!sample || !renderedPosition) {
|
||||
head.show = false;
|
||||
backing.show = false;
|
||||
if (headPrimitive) headPrimitive.show = false;
|
||||
return;
|
||||
}
|
||||
const second = Math.floor(sample.displayT / 1000);
|
||||
let lo = 0,
|
||||
hi = segments.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (segments[mid].toT <= sample.displayT) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
const done = lo - 1;
|
||||
const candidate = segments[lo];
|
||||
const segment =
|
||||
candidate &&
|
||||
candidate.fromT <= sample.displayT &&
|
||||
candidate.fromSeq === sample.fromSeq &&
|
||||
candidate.toSeq === sample.toSeq
|
||||
? candidate
|
||||
: null;
|
||||
if (body?.ready && done !== completed) {
|
||||
for (
|
||||
let i = Math.min(done, completed) + 1;
|
||||
i <= Math.max(done, completed);
|
||||
i++
|
||||
) {
|
||||
for (let lane = 0; lane < 2; lane++) {
|
||||
const attributes = body.getGeometryInstanceAttributes(
|
||||
attributeIds[i][lane],
|
||||
);
|
||||
if (!attributes) continue;
|
||||
// Cesium getters return copies. Write back the array we changed.
|
||||
const show = attributes.show;
|
||||
show[0] = i <= done ? 1 : 0;
|
||||
attributes.show = show;
|
||||
}
|
||||
}
|
||||
completed = done;
|
||||
}
|
||||
if (body?.ready && second !== lastSecond) {
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const alpha = trailAlpha(sample.displayT - segments[i].toT);
|
||||
for (let lane = 0; lane < 2; lane++) {
|
||||
const attributes = body.getGeometryInstanceAttributes(
|
||||
attributeIds[i][lane],
|
||||
);
|
||||
if (!attributes) continue;
|
||||
const rgba = attributes.color;
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
tint(alpha, !lane),
|
||||
rgba,
|
||||
);
|
||||
attributes.color = rgba;
|
||||
if (!ground) {
|
||||
const depthRgba = attributes.depthFailColor;
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
tint(0.55),
|
||||
depthRgba,
|
||||
);
|
||||
attributes.depthFailColor = depthRgba;
|
||||
}
|
||||
}
|
||||
}
|
||||
lastSecond = second;
|
||||
}
|
||||
const show = visible && !!segment && sample.fraction < 1;
|
||||
head.show = show;
|
||||
backing.show = show;
|
||||
if (headPrimitive) headPrimitive.show = show;
|
||||
if (!show) return;
|
||||
if (active !== segment) {
|
||||
active = segment;
|
||||
if (headPrimitive) collection.remove(headPrimitive);
|
||||
headPrimitive = collection.add(
|
||||
primitive(
|
||||
new Cesium.GeometryInstance({
|
||||
geometry: geometry(segment.positions, 5, true),
|
||||
}),
|
||||
new Cesium.PolylineMaterialAppearance({
|
||||
material,
|
||||
translucent: true,
|
||||
}),
|
||||
new Cesium.PolylineMaterialAppearance({
|
||||
material: depthMaterial,
|
||||
translucent: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
headRebuilds++;
|
||||
headPositions[0] = segment.positions[0];
|
||||
headPositions[1] = endpoint;
|
||||
}
|
||||
Cesium.Cartesian3.clone(renderedPosition, endpoint);
|
||||
head.positions = headPositions;
|
||||
backing.positions = headPositions;
|
||||
material.uniforms.fraction = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
(sample.displayT - segment.fromT) / (segment.toT - segment.fromT),
|
||||
),
|
||||
);
|
||||
depthMaterial.uniforms.fraction = material.uniforms.fraction;
|
||||
Cesium.Color.clone(tint(0.8), material.uniforms.color);
|
||||
Cesium.Color.clone(tint(0.55), depthMaterial.uniforms.color);
|
||||
}
|
||||
return {
|
||||
replaceHistory,
|
||||
setDisplaySample,
|
||||
setStyle(next) {
|
||||
if (next === style) return;
|
||||
style = next;
|
||||
Cesium.Color.fromCssColorString(style, styleColor);
|
||||
lastSecond = -1;
|
||||
},
|
||||
setVisible(next) {
|
||||
visible = next;
|
||||
if (headPrimitive) headPrimitive.show = next && head.show;
|
||||
if (body) body.show = next;
|
||||
},
|
||||
destroy() {
|
||||
if (body) collection.remove(body);
|
||||
if (headPrimitive) collection.remove(headPrimitive);
|
||||
headPrimitive = null;
|
||||
material.destroy();
|
||||
depthMaterial.destroy();
|
||||
body = null;
|
||||
segments = [];
|
||||
},
|
||||
diagnostics() {
|
||||
return {
|
||||
revision,
|
||||
rebuilds,
|
||||
segments: segments.length,
|
||||
body,
|
||||
head,
|
||||
backing,
|
||||
headPrimitive,
|
||||
headStrategy: ground
|
||||
? 'draped corridor with material clip'
|
||||
: 'depth-fail corridor with material clip',
|
||||
ground,
|
||||
headRebuilds,
|
||||
entitiesAdded: 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as Cesium from 'cesium';
|
||||
export function worldToWindowCoordinates(scene, position, result) {
|
||||
return Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
position,
|
||||
result,
|
||||
);
|
||||
}
|
||||
const occluder = new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
export function horizonOccluder(camera) {
|
||||
occluder.cameraPosition = camera.positionWC;
|
||||
return occluder;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @module trailRenderer
|
||||
* @description Shared track-history renderer (PRD WS-F F4; rebuilt round 6,
|
||||
* 2026-07-06).
|
||||
*
|
||||
* One trail = one ENTITY polyline. Round 6 replaced the faded per-vertex
|
||||
* Primitive for two owner directives from the field:
|
||||
* - "the line must ALWAYS be visible": the Primitive's depthFailAppearance
|
||||
* did not reliably render segments below the photoreal mesh — entity
|
||||
* polylines with `depthFailMaterial` DO (in-repo proof: CCTV's frustum
|
||||
* wireframes read through geometry with exactly this), so occluded
|
||||
* segments now draw dimmed instead of vanishing.
|
||||
* - "show actual tracks, don't style them too much": the tail-fade is gone;
|
||||
* the whole history renders at one readable alpha (dimmer where it passes
|
||||
* behind/below geometry).
|
||||
*
|
||||
* Trails update at poll cadence (~15-60 s) plus once on history backfill, so
|
||||
* assigning a fresh positions array per update is cheap. `allowPicking` has
|
||||
* no entity equivalent; the polyline entity is excluded from clicks by never
|
||||
* carrying a pick id the layers' click handlers resolve.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { registerPickOwner } from '../pickRegistry.js';
|
||||
|
||||
// Round 6: trail ENTITIES are pickable (the old Primitive had
|
||||
// allowPicking:false). A trail hugs its aircraft, so an unclaimed pick would
|
||||
// read as "empty space" in every layer's click handler and deselect the very
|
||||
// plane being tracked. Claiming the 'gev-trail:' id namespace makes
|
||||
// isOwnedByOtherLayer() true for every layer — clicking a trail is a no-op
|
||||
// everywhere. Registered once at module load; the predicate is pure.
|
||||
registerPickOwner('trails', (pickedId) =>
|
||||
String(pickedId).startsWith('gev-trail:'),
|
||||
);
|
||||
|
||||
/** @type {number} Uniquifier for trail entity ids (Cesium requires unique entity ids). */
|
||||
let _trailSeq = 0;
|
||||
|
||||
/** @constant {number} Alpha where the trail passes the depth test. */
|
||||
const TRAIL_ALPHA = 0.85;
|
||||
/** @constant {number} Alpha where the trail is behind/below scene geometry —
|
||||
* visible (owner: never vanish) but readable as occluded. */
|
||||
const TRAIL_OCCLUDED_ALPHA = 0.4;
|
||||
/** @constant {number} Squared distance (m^2) below which consecutive points are merged. */
|
||||
const MIN_SEGMENT_DISTANCE_SQ = 0.01;
|
||||
|
||||
/**
|
||||
* Create an always-visible polyline trail bound to a viewer.
|
||||
* @param {Cesium.Viewer} viewer - Viewer whose entity collection owns the trail.
|
||||
* @param {object} options - Trail options.
|
||||
* @param {string} options.color - CSS color string for the trail hue.
|
||||
* @param {number} [options.width=2.5] - Polyline width in pixels.
|
||||
* @returns {{setPositions: function(Cesium.Cartesian3[]): void, setVisible: function(boolean): void, clear: function(): void, destroy: function(): void}}
|
||||
* Trail handle: setPositions replaces the geometry, setVisible temporarily
|
||||
* hides it without discarding history, clear empties it, and destroy removes
|
||||
* the entity permanently.
|
||||
*/
|
||||
export function createTrail(viewer, { color, width = 2.5 }) {
|
||||
const baseColor = Cesium.Color.fromCssColorString(color);
|
||||
/** @type {Cesium.Cartesian3[]} Current deduped positions (owned copy). */
|
||||
let current = [];
|
||||
let destroyed = false;
|
||||
let visible = true;
|
||||
/** @type {Cesium.Entity|null} */
|
||||
let entity = null;
|
||||
|
||||
function ensureEntity() {
|
||||
if (entity || destroyed || !viewer || viewer.isDestroyed()) return;
|
||||
entity = viewer.entities.add({
|
||||
id: `gev-trail:${++_trailSeq}`,
|
||||
show: visible,
|
||||
polyline: {
|
||||
// CallbackProperty so a positions swap never rebuilds the entity —
|
||||
// Cesium re-reads on change; `false` marks it non-constant.
|
||||
positions: new Cesium.CallbackProperty(() => current, false),
|
||||
width,
|
||||
material: baseColor.withAlpha(TRAIL_ALPHA),
|
||||
// The owner-locked rule (round 6): a segment below the photoreal
|
||||
// mesh renders dimmed — it must never disappear into the ground.
|
||||
depthFailMaterial: baseColor.withAlpha(TRAIL_OCCLUDED_ALPHA),
|
||||
// Round 8 (owner: UAL1104's Pacific trail "cutting through the
|
||||
// globe"): NONE draws straight 3D chords between waypoints — over a
|
||||
// sparse trans-oceanic trace a single segment spans hundreds of km
|
||||
// and tunnels through the planet. GEODESIC subdivides each segment
|
||||
// along the curved surface (heights interpolated), so long legs hug
|
||||
// the globe instead of chording through it.
|
||||
arcType: Cesium.ArcType.GEODESIC,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Replace the trail geometry with a chronological position list
|
||||
* (oldest first). Fewer than 2 distinct positions clears the trail.
|
||||
* @param {Cesium.Cartesian3[]} cartesians - Positions, oldest -> newest.
|
||||
*/
|
||||
setPositions(cartesians) {
|
||||
if (destroyed || !viewer || viewer.isDestroyed()) return;
|
||||
// Drop consecutive near-duplicates: zero-length segments add nothing.
|
||||
const positions = [];
|
||||
for (const position of Array.isArray(cartesians) ? cartesians : []) {
|
||||
if (!position) continue;
|
||||
const last = positions[positions.length - 1];
|
||||
if (
|
||||
last &&
|
||||
Cesium.Cartesian3.distanceSquared(last, position) <
|
||||
MIN_SEGMENT_DISTANCE_SQ
|
||||
)
|
||||
continue;
|
||||
positions.push(position);
|
||||
}
|
||||
current = positions.length >= 2 ? positions : [];
|
||||
ensureEntity();
|
||||
},
|
||||
|
||||
/** Temporarily hide/show the trail without discarding accumulated history. */
|
||||
setVisible(nextVisible) {
|
||||
visible = nextVisible !== false;
|
||||
if (entity) entity.show = visible;
|
||||
},
|
||||
|
||||
/** Empty the trail without removing the entity (cheap re-arm). */
|
||||
clear() {
|
||||
current = [];
|
||||
},
|
||||
|
||||
/** Remove the trail entity permanently (layer disable/teardown). */
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
current = [];
|
||||
if (entity && viewer && !viewer.isDestroyed()) {
|
||||
try {
|
||||
viewer.entities.remove(entity);
|
||||
} catch {
|
||||
/* torn down */
|
||||
}
|
||||
}
|
||||
entity = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { trackingEventTypes } from './render/cesiumConstants.js';
|
||||
import { isPointerFree } from './inputOwnership.js';
|
||||
|
||||
export const MAX_TRACKING_CLICK_TRAVEL_PX = 6;
|
||||
@@ -47,7 +47,7 @@ export function isTrackingClickGesture(gesture = {}) {
|
||||
*/
|
||||
export function bindTrackingClickGesture(handler, onClick, options = {}) {
|
||||
const now = options.now || (() => performance.now());
|
||||
const eventTypes = options.eventTypes || Cesium.ScreenSpaceEventType;
|
||||
const eventTypes = options.eventTypes || trackingEventTypes;
|
||||
let pressActive = false;
|
||||
let pressStartedAt = 0;
|
||||
let previousPosition = null;
|
||||
|
||||
+1
-141
@@ -1,141 +1 @@
|
||||
/**
|
||||
* @module trailRenderer
|
||||
* @description Shared track-history renderer (PRD WS-F F4; rebuilt round 6,
|
||||
* 2026-07-06).
|
||||
*
|
||||
* One trail = one ENTITY polyline. Round 6 replaced the faded per-vertex
|
||||
* Primitive for two owner directives from the field:
|
||||
* - "the line must ALWAYS be visible": the Primitive's depthFailAppearance
|
||||
* did not reliably render segments below the photoreal mesh — entity
|
||||
* polylines with `depthFailMaterial` DO (in-repo proof: CCTV's frustum
|
||||
* wireframes read through geometry with exactly this), so occluded
|
||||
* segments now draw dimmed instead of vanishing.
|
||||
* - "show actual tracks, don't style them too much": the tail-fade is gone;
|
||||
* the whole history renders at one readable alpha (dimmer where it passes
|
||||
* behind/below geometry).
|
||||
*
|
||||
* Trails update at poll cadence (~15-60 s) plus once on history backfill, so
|
||||
* assigning a fresh positions array per update is cheap. `allowPicking` has
|
||||
* no entity equivalent; the polyline entity is excluded from clicks by never
|
||||
* carrying a pick id the layers' click handlers resolve.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { registerPickOwner } from './pickRegistry.js';
|
||||
|
||||
// Round 6: trail ENTITIES are pickable (the old Primitive had
|
||||
// allowPicking:false). A trail hugs its aircraft, so an unclaimed pick would
|
||||
// read as "empty space" in every layer's click handler and deselect the very
|
||||
// plane being tracked. Claiming the 'gev-trail:' id namespace makes
|
||||
// isOwnedByOtherLayer() true for every layer — clicking a trail is a no-op
|
||||
// everywhere. Registered once at module load; the predicate is pure.
|
||||
registerPickOwner('trails', (pickedId) =>
|
||||
String(pickedId).startsWith('gev-trail:'),
|
||||
);
|
||||
|
||||
/** @type {number} Uniquifier for trail entity ids (Cesium requires unique entity ids). */
|
||||
let _trailSeq = 0;
|
||||
|
||||
/** @constant {number} Alpha where the trail passes the depth test. */
|
||||
const TRAIL_ALPHA = 0.85;
|
||||
/** @constant {number} Alpha where the trail is behind/below scene geometry —
|
||||
* visible (owner: never vanish) but readable as occluded. */
|
||||
const TRAIL_OCCLUDED_ALPHA = 0.4;
|
||||
/** @constant {number} Squared distance (m^2) below which consecutive points are merged. */
|
||||
const MIN_SEGMENT_DISTANCE_SQ = 0.01;
|
||||
|
||||
/**
|
||||
* Create an always-visible polyline trail bound to a viewer.
|
||||
* @param {Cesium.Viewer} viewer - Viewer whose entity collection owns the trail.
|
||||
* @param {object} options - Trail options.
|
||||
* @param {string} options.color - CSS color string for the trail hue.
|
||||
* @param {number} [options.width=2.5] - Polyline width in pixels.
|
||||
* @returns {{setPositions: function(Cesium.Cartesian3[]): void, setVisible: function(boolean): void, clear: function(): void, destroy: function(): void}}
|
||||
* Trail handle: setPositions replaces the geometry, setVisible temporarily
|
||||
* hides it without discarding history, clear empties it, and destroy removes
|
||||
* the entity permanently.
|
||||
*/
|
||||
export function createTrail(viewer, { color, width = 2.5 }) {
|
||||
const baseColor = Cesium.Color.fromCssColorString(color);
|
||||
/** @type {Cesium.Cartesian3[]} Current deduped positions (owned copy). */
|
||||
let current = [];
|
||||
let destroyed = false;
|
||||
let visible = true;
|
||||
/** @type {Cesium.Entity|null} */
|
||||
let entity = null;
|
||||
|
||||
function ensureEntity() {
|
||||
if (entity || destroyed || !viewer || viewer.isDestroyed()) return;
|
||||
entity = viewer.entities.add({
|
||||
id: `gev-trail:${++_trailSeq}`,
|
||||
show: visible,
|
||||
polyline: {
|
||||
// CallbackProperty so a positions swap never rebuilds the entity —
|
||||
// Cesium re-reads on change; `false` marks it non-constant.
|
||||
positions: new Cesium.CallbackProperty(() => current, false),
|
||||
width,
|
||||
material: baseColor.withAlpha(TRAIL_ALPHA),
|
||||
// The owner-locked rule (round 6): a segment below the photoreal
|
||||
// mesh renders dimmed — it must never disappear into the ground.
|
||||
depthFailMaterial: baseColor.withAlpha(TRAIL_OCCLUDED_ALPHA),
|
||||
// Round 8 (owner: UAL1104's Pacific trail "cutting through the
|
||||
// globe"): NONE draws straight 3D chords between waypoints — over a
|
||||
// sparse trans-oceanic trace a single segment spans hundreds of km
|
||||
// and tunnels through the planet. GEODESIC subdivides each segment
|
||||
// along the curved surface (heights interpolated), so long legs hug
|
||||
// the globe instead of chording through it.
|
||||
arcType: Cesium.ArcType.GEODESIC,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Replace the trail geometry with a chronological position list
|
||||
* (oldest first). Fewer than 2 distinct positions clears the trail.
|
||||
* @param {Cesium.Cartesian3[]} cartesians - Positions, oldest -> newest.
|
||||
*/
|
||||
setPositions(cartesians) {
|
||||
if (destroyed || !viewer || viewer.isDestroyed()) return;
|
||||
// Drop consecutive near-duplicates: zero-length segments add nothing.
|
||||
const positions = [];
|
||||
for (const position of Array.isArray(cartesians) ? cartesians : []) {
|
||||
if (!position) continue;
|
||||
const last = positions[positions.length - 1];
|
||||
if (
|
||||
last &&
|
||||
Cesium.Cartesian3.distanceSquared(last, position) <
|
||||
MIN_SEGMENT_DISTANCE_SQ
|
||||
)
|
||||
continue;
|
||||
positions.push(position);
|
||||
}
|
||||
current = positions.length >= 2 ? positions : [];
|
||||
ensureEntity();
|
||||
},
|
||||
|
||||
/** Temporarily hide/show the trail without discarding accumulated history. */
|
||||
setVisible(nextVisible) {
|
||||
visible = nextVisible !== false;
|
||||
if (entity) entity.show = visible;
|
||||
},
|
||||
|
||||
/** Empty the trail without removing the entity (cheap re-arm). */
|
||||
clear() {
|
||||
current = [];
|
||||
},
|
||||
|
||||
/** Remove the trail entity permanently (layer disable/teardown). */
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
current = [];
|
||||
if (entity && viewer && !viewer.isDestroyed()) {
|
||||
try {
|
||||
viewer.entities.remove(entity);
|
||||
} catch {
|
||||
/* torn down */
|
||||
}
|
||||
}
|
||||
entity = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
export { createTrail } from './render/cesiumTrailRenderer.js';
|
||||
@@ -282,11 +282,9 @@
|
||||
* - Cesium's viewer.clock: the app never animates it (shouldAnimate false,
|
||||
* its time frozen at boot); Cesium's sun and lighting and every entity
|
||||
* property read at viewer.clock.currentTime stay on that time;
|
||||
* - launches: the replay timeline (replay.js, Date.now/performance.now),
|
||||
* the mission orbit's GMST frame (orbitRendering.js new Date()), launch
|
||||
* pad and panel throttles (phase 3 Task 15a);
|
||||
* - wall-clock throttles and feed receipt stamps everywhere (they measure
|
||||
* real elapsed time, not scene time).
|
||||
* Launch replay, satellite propagation and orbit GMST now use this clock.
|
||||
* @property {() => number} now The current app time.
|
||||
* @property {() => number} frameTime The app time latched at the start of the
|
||||
* engine's current frame: every reader in one frame gets the same value; it
|
||||
@@ -374,8 +372,8 @@
|
||||
* straight segments.
|
||||
* @property {Matrix4} [modelMatrix] Applied to the positions (an orbit ring
|
||||
* turned by GMST).
|
||||
* @property {any} [pickId] What a Cesium pick of the line returns (Navara
|
||||
* lines are not pickable yet).
|
||||
* @property {any} [pickId] What a pick of the line returns (Navara uses
|
||||
* CPU segment picks; lines do not participate in its GPU pass).
|
||||
*
|
||||
* @typedef {object} Line
|
||||
* @property {(positions: Cartesian3[] | Float64Array) => void} setPositions
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
// (ScreenSpaceCameraController rotate3D and zoom3D):
|
||||
// - left drag: heading turns by rate × dx / width × 2π and the camera rises
|
||||
// by rate × −dy / height × π, rate = 1.77 (Cesium's maximum rotate rate,
|
||||
// which a tracked camera beyond a few metres always reaches); each move
|
||||
// counts at most a tenth of the canvas (maximumMovementRatio); a drag
|
||||
// which a tracked camera beyond a few metres always reaches); the signed
|
||||
// window ratio uses Cesium's maximumMovementRatio cap; a drag
|
||||
// right turns the heading clockwise;
|
||||
// - right drag: a dolly by dy / height (down closes in);
|
||||
// - wheel: one notch (deltaY 100) is 20 % of the range.
|
||||
@@ -46,7 +46,7 @@ export function navaraFollowGestures(
|
||||
return;
|
||||
}
|
||||
orbit(
|
||||
ROTATE_RATE * Math.min(dx / width, MAX_MOVEMENT_RATIO) * 2 * Math.PI,
|
||||
-ROTATE_RATE * Math.min(-dx / width, MAX_MOVEMENT_RATIO) * 2 * Math.PI,
|
||||
ROTATE_RATE * Math.min(-dy / height, MAX_MOVEMENT_RATIO) * Math.PI,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -52,14 +52,22 @@ test('drag turns and right drag dollies as Cesium tracked camera; wheel scales;
|
||||
win.dispatchEvent(event('pointerup', { button: 0 }));
|
||||
assert.ok(calls[0][2] < 0, 'pitch down');
|
||||
assert.ok(Math.abs(calls[0][2] + (1.77 * 40 * Math.PI) / 800) < 1e-12);
|
||||
// Per move at most a tenth of the canvas counts (maximumMovementRatio).
|
||||
// Cesium rotate3D caps (startX - endX) / width, before the sign
|
||||
// reversal into heading. Large right and left drags are asymmetric.
|
||||
calls.length = 0;
|
||||
canvas.dispatchEvent(
|
||||
event('pointerdown', { button: 0, clientX: 0, clientY: 0 }),
|
||||
);
|
||||
win.dispatchEvent(event('pointermove', { clientX: 640, clientY: 0 }));
|
||||
win.dispatchEvent(event('pointerup', { button: 0 }));
|
||||
assert.ok(Math.abs(calls[0][1] - 1.77 * 0.1 * 2 * Math.PI) < 1e-12);
|
||||
assert.ok(Math.abs(calls[0][1] - 1.77 * 0.5 * 2 * Math.PI) < 1e-12);
|
||||
calls.length = 0;
|
||||
canvas.dispatchEvent(
|
||||
event('pointerdown', { button: 0, clientX: 640, clientY: 0 }),
|
||||
);
|
||||
win.dispatchEvent(event('pointermove', { clientX: 0, clientY: 0 }));
|
||||
win.dispatchEvent(event('pointerup', { button: 0 }));
|
||||
assert.ok(Math.abs(calls[0][1] + 1.77 * 0.1 * 2 * Math.PI) < 1e-12);
|
||||
// A right drag down: a dolly by the window ratio (Cesium zoom3D).
|
||||
calls.length = 0;
|
||||
const context = event('contextmenu');
|
||||
|
||||
@@ -18,6 +18,7 @@ import { createNavaraLines } from './lines/index.js';
|
||||
import { createNavaraOverlay } from './overlay.js';
|
||||
import {
|
||||
NAVARA_CONTEXT_ATTRIBUTES,
|
||||
ACES_FILMIC,
|
||||
navaraScenePlan,
|
||||
navaraViewOptions,
|
||||
resolveNavaraLook,
|
||||
@@ -120,14 +121,20 @@ export async function createNavaraGlobe({
|
||||
);
|
||||
// The look's shadowed sun leaked a light per frame (internals.js).
|
||||
keepCascadedLightIds(view);
|
||||
// Sprites, points and lines, drawn after tone mapping (overlay.js).
|
||||
// Screen overlays and model display colour after tone mapping (overlay.js).
|
||||
overlay = createNavaraOverlay(view);
|
||||
engine = createNavaraEngine(view, {
|
||||
hooks: navaraHooks(view),
|
||||
container,
|
||||
...(appClock ? { appClock } : {}),
|
||||
sharpen: createNavaraSharpen(view),
|
||||
movers: createNavaraMovers(view, { overlay }),
|
||||
movers: createNavaraMovers(view, {
|
||||
overlay,
|
||||
// Model colour follows the display-space stage, after the look's ACES.
|
||||
aces: plan.effects.some(
|
||||
({ toneMapping }) => toneMapping?.mode === ACES_FILMIC,
|
||||
),
|
||||
}),
|
||||
lines: createNavaraLines(view, { overlay }),
|
||||
});
|
||||
const camera = engine.camera;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// One camera snapshot per Navara frame, shared by its mover sets. A camera
|
||||
// command or resize within the frame invalidates it too. Nonstandard parented
|
||||
// or manually managed cameras retain the uncached path.
|
||||
const EARTH_RADIUS_M = 6_371_000;
|
||||
|
||||
export function createMoverFrameCamera(view) {
|
||||
const frame = {
|
||||
cameraPosition: { x: 0, y: 0, z: 0 },
|
||||
cameraDirection: { x: 0, y: 0, z: -1 },
|
||||
cameraHeight: 0,
|
||||
fovDeg: 60,
|
||||
viewportHeightPx: 0,
|
||||
pixelRatio: 1,
|
||||
};
|
||||
const previous = {};
|
||||
return (ctx, time) => {
|
||||
const raw = view.camera.raw;
|
||||
const renderer = ctx?.getRenderer();
|
||||
const height = renderer?.domElement.clientHeight;
|
||||
if (!raw || !(height > 0)) return null;
|
||||
const p = raw.position,
|
||||
q = raw.quaternion,
|
||||
scale = raw.scale;
|
||||
const dpr = renderer.getPixelRatio?.() ?? 1;
|
||||
const cacheable =
|
||||
Number.isFinite(time) &&
|
||||
!raw.parent &&
|
||||
raw.matrixAutoUpdate !== false &&
|
||||
raw.matrixWorldAutoUpdate !== false &&
|
||||
scale.x === 1 &&
|
||||
scale.y === 1 &&
|
||||
scale.z === 1;
|
||||
if (
|
||||
cacheable &&
|
||||
previous.time === time &&
|
||||
previous.raw === raw &&
|
||||
previous.renderer === renderer &&
|
||||
previous.x === p.x &&
|
||||
previous.y === p.y &&
|
||||
previous.z === p.z &&
|
||||
previous.qx === q.x &&
|
||||
previous.qy === q.y &&
|
||||
previous.qz === q.z &&
|
||||
previous.qw === q.w &&
|
||||
frame.fovDeg === raw.fov &&
|
||||
frame.viewportHeightPx === height &&
|
||||
frame.pixelRatio === dpr
|
||||
)
|
||||
return frame;
|
||||
raw.updateMatrixWorld();
|
||||
const e = raw.matrixWorld.elements;
|
||||
const position = frame.cameraPosition;
|
||||
position.x = e[12];
|
||||
position.y = e[13];
|
||||
position.z = e[14];
|
||||
const direction = frame.cameraDirection;
|
||||
const length = Math.hypot(e[8], e[9], e[10]) || 1;
|
||||
direction.x = -e[8] / length;
|
||||
direction.y = -e[9] / length;
|
||||
direction.z = -e[10] / length;
|
||||
frame.cameraHeight =
|
||||
Math.hypot(position.x, position.y, position.z) - EARTH_RADIUS_M;
|
||||
frame.fovDeg = raw.fov;
|
||||
frame.viewportHeightPx = height;
|
||||
frame.pixelRatio = dpr;
|
||||
previous.time = cacheable ? time : undefined;
|
||||
previous.raw = raw;
|
||||
previous.renderer = renderer;
|
||||
previous.x = p.x;
|
||||
previous.y = p.y;
|
||||
previous.z = p.z;
|
||||
previous.qx = q.x;
|
||||
previous.qy = q.y;
|
||||
previous.qz = q.z;
|
||||
previous.qw = q.w;
|
||||
return frame;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createMoverFrameCamera } from './frameCamera.js';
|
||||
|
||||
function setup() {
|
||||
let updates = 0;
|
||||
const raw = {
|
||||
position: { x: 6372000, y: 2, z: 3 },
|
||||
quaternion: { x: 0, y: 0, z: 0, w: 1 },
|
||||
scale: { x: 1, y: 1, z: 1 },
|
||||
parent: null,
|
||||
matrixAutoUpdate: true,
|
||||
fov: 60,
|
||||
matrixWorld: { elements: new Float64Array(16) },
|
||||
updateMatrixWorld() {
|
||||
updates++;
|
||||
const e = this.matrixWorld.elements;
|
||||
e[12] = this.position.x;
|
||||
e[13] = this.position.y;
|
||||
e[14] = this.position.z;
|
||||
e[8] = 0;
|
||||
e[9] = this.quaternion.y;
|
||||
e[10] = 2;
|
||||
},
|
||||
};
|
||||
const renderer = {
|
||||
domElement: { clientHeight: 800 },
|
||||
getPixelRatio: () => 1,
|
||||
};
|
||||
const ctx = { getRenderer: () => renderer };
|
||||
const read = createMoverFrameCamera({ camera: { raw } });
|
||||
return { read, raw, renderer, ctx, updates: () => updates };
|
||||
}
|
||||
|
||||
test('mover sets share one matrix update in a frame and refresh next frame', () => {
|
||||
const f = setup();
|
||||
const first = f.read(f.ctx, 100);
|
||||
for (let i = 0; i < 30; i++) assert.equal(f.read(f.ctx, 100), first);
|
||||
assert.equal(f.updates(), 1);
|
||||
assert.deepEqual(first.cameraDirection, { x: -0, y: -0, z: -1 });
|
||||
assert.equal(first.cameraPosition.x, 6372000);
|
||||
f.read(f.ctx, 101);
|
||||
assert.equal(f.updates(), 2);
|
||||
});
|
||||
|
||||
test('same-frame camera moves, rotations, resize and DPR never reuse stale data', () => {
|
||||
const f = setup();
|
||||
f.read(f.ctx, 100);
|
||||
f.raw.position.x += 100;
|
||||
assert.equal(f.read(f.ctx, 100).cameraPosition.x, 6372100);
|
||||
f.raw.quaternion.y = 2;
|
||||
assert.ok(
|
||||
Math.abs(f.read(f.ctx, 100).cameraDirection.y + Math.SQRT1_2) < 1e-12,
|
||||
);
|
||||
f.renderer.domElement.clientHeight = 600;
|
||||
assert.equal(f.read(f.ctx, 100).viewportHeightPx, 600);
|
||||
f.renderer.getPixelRatio = () => 2;
|
||||
assert.equal(f.read(f.ctx, 100).pixelRatio, 2);
|
||||
assert.equal(f.updates(), 5);
|
||||
});
|
||||
|
||||
test('missing frames and nonstandard camera transforms keep the uncached path', () => {
|
||||
for (const modify of [
|
||||
() => {},
|
||||
(f) => {
|
||||
f.raw.parent = {};
|
||||
},
|
||||
(f) => {
|
||||
f.raw.matrixAutoUpdate = false;
|
||||
},
|
||||
(f) => {
|
||||
f.raw.scale.x = 2;
|
||||
},
|
||||
(f) => {
|
||||
f.raw.matrixWorldAutoUpdate = false;
|
||||
},
|
||||
]) {
|
||||
const f = setup();
|
||||
modify(f);
|
||||
const time =
|
||||
f.raw.parent ||
|
||||
!f.raw.matrixAutoUpdate ||
|
||||
f.raw.scale.x !== 1 ||
|
||||
f.raw.matrixWorldAutoUpdate === false
|
||||
? 100
|
||||
: undefined;
|
||||
f.read(f.ctx, time);
|
||||
f.read(f.ctx, time);
|
||||
assert.equal(f.updates(), 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('a hidden canvas has no snapshot and independent views do not share scratch state', () => {
|
||||
const a = setup(),
|
||||
b = setup();
|
||||
a.renderer.domElement.clientHeight = 0;
|
||||
assert.equal(a.read(a.ctx, 100), null);
|
||||
a.renderer.domElement.clientHeight = 800;
|
||||
const first = a.read(a.ctx, 100);
|
||||
b.raw.position.x += 900;
|
||||
assert.notEqual(first, b.read(b.ctx, 100));
|
||||
assert.equal(first.cameraPosition.x, 6372000);
|
||||
});
|
||||
@@ -2,7 +2,8 @@
|
||||
// per set (moverMesh.js) in gev's overlay (overlay.js: drawn after tone
|
||||
// mapping, so their colours are exact); models are a Navara mesh descriptor
|
||||
// (public MeshDesc API) holding one InstancedMesh per glTF mesh
|
||||
// (modelMover.js), lit and graded like the scene. Pickable sets register a
|
||||
// (modelMover.js). In the cinematic look, depth stays in that scene and
|
||||
// lit model colour resolves in the display pass. Pickable sets register a
|
||||
// PickableInstancedMeshWrapper so Navara's pick pass sees each instance;
|
||||
// pickIds.js turns its batch ids back into instances. commit() uploads and
|
||||
// asks for one frame (view.forceUpdate); per-frame work runs only on frames
|
||||
@@ -18,10 +19,10 @@ import { createMoverRegistry } from '../../movers.js';
|
||||
import { createModelMover } from './modelMover.js';
|
||||
import { createMoverMesh } from './moverMesh.js';
|
||||
import { createPickIndex } from './pickIds.js';
|
||||
import { createMoverFrameCamera } from './frameCamera.js';
|
||||
|
||||
const DESC_NAME = 'gevMoverSet';
|
||||
const registered = new WeakSet();
|
||||
const EARTH_RADIUS_M = 6_371_000;
|
||||
|
||||
class GevMoverSetDesc extends MeshDesc {
|
||||
constructor(view, ctx, config) {
|
||||
@@ -50,15 +51,15 @@ class GevMoverSetDesc extends MeshDesc {
|
||||
if (this.destroyedByGev) return;
|
||||
mover.group.meshes.forEach((mesh, index) => {
|
||||
group.add(mesh);
|
||||
register(mesh, `${this.id}#${index}`);
|
||||
register(mover.group.pickMeshes[index], `${this.id}#${index}`);
|
||||
});
|
||||
this.view.forceUpdate();
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
update() {
|
||||
updateMover(this.view, this.ctx, this.gev.mover);
|
||||
update(time) {
|
||||
updateMover(this.gev.readFrameCamera, this.ctx, this.gev.mover, time);
|
||||
}
|
||||
|
||||
onDestroy() {
|
||||
@@ -69,53 +70,25 @@ class GevMoverSetDesc extends MeshDesc {
|
||||
}
|
||||
}
|
||||
|
||||
// The camera a mover needs each drawn frame (scratch: no allocation).
|
||||
const frameCamera = {
|
||||
cameraPosition: { x: 0, y: 0, z: 0 },
|
||||
cameraDirection: { x: 0, y: 0, z: -1 },
|
||||
cameraHeight: 0,
|
||||
fovDeg: 60,
|
||||
viewportHeightPx: 0,
|
||||
pixelRatio: 1,
|
||||
};
|
||||
function updateMover(view, ctx, mover) {
|
||||
const raw = view.camera.raw;
|
||||
const renderer = ctx?.getRenderer();
|
||||
const height = renderer?.domElement.clientHeight;
|
||||
if (!raw || !(height > 0)) return;
|
||||
raw.updateMatrixWorld();
|
||||
const e = raw.matrixWorld.elements;
|
||||
const position = frameCamera.cameraPosition;
|
||||
position.x = e[12];
|
||||
position.y = e[13];
|
||||
position.z = e[14];
|
||||
// A three camera looks down its local −z.
|
||||
const direction = frameCamera.cameraDirection;
|
||||
const length = Math.hypot(e[8], e[9], e[10]) || 1;
|
||||
direction.x = -e[8] / length;
|
||||
direction.y = -e[9] / length;
|
||||
direction.z = -e[10] / length;
|
||||
frameCamera.cameraHeight =
|
||||
Math.hypot(position.x, position.y, position.z) - EARTH_RADIUS_M;
|
||||
frameCamera.fovDeg = raw.fov;
|
||||
frameCamera.viewportHeightPx = height;
|
||||
frameCamera.pixelRatio = renderer.getPixelRatio?.() ?? 1;
|
||||
mover.update(frameCamera);
|
||||
function updateMover(readFrameCamera, ctx, mover, time) {
|
||||
const camera = readFrameCamera(ctx, time);
|
||||
if (camera) mover.update(camera);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} view an initialised Navara ThreeView
|
||||
* @param {{ overlay: ReturnType<import('../overlay.js').createNavaraOverlay> }} ports
|
||||
* @param {{ overlay: ReturnType<import('../overlay.js').createNavaraOverlay>, aces?: boolean }} ports
|
||||
* @returns {{ movers: import('../../contract.js').GlobeMovers,
|
||||
* resolvePick: (batchId: number) => ({ set: string, instance: number, pickId: any } | null),
|
||||
* destroy: () => void }}
|
||||
*/
|
||||
export function createNavaraMovers(view, { overlay }) {
|
||||
export function createNavaraMovers(view, { overlay, aces = false }) {
|
||||
if (!registered.has(view)) {
|
||||
view.registerMesh(DESC_NAME, GevMoverSetDesc);
|
||||
registered.add(view);
|
||||
}
|
||||
const pickIndex = createPickIndex();
|
||||
const readFrameCamera = createMoverFrameCamera(view);
|
||||
let loader = null;
|
||||
const loadGltf = (url) => {
|
||||
loader ??= createGltfLoader();
|
||||
@@ -137,7 +110,9 @@ export function createNavaraMovers(view, { overlay }) {
|
||||
overlay.registerPickable(key, wrapper);
|
||||
pickIndex.add(options.id, wrapper, (index) => store.pickIds[index]);
|
||||
}
|
||||
overlay.add(mover.mesh, () => updateMover(view, overlay.ctx, mover));
|
||||
overlay.add(mover.mesh, (time) =>
|
||||
updateMover(readFrameCamera, overlay.ctx, mover, time),
|
||||
);
|
||||
return {
|
||||
ready: mover.ready,
|
||||
commit() {
|
||||
@@ -158,10 +133,12 @@ export function createNavaraMovers(view, { overlay }) {
|
||||
const mover = createModelMover(options, store, {
|
||||
load: loadGltf,
|
||||
onLoad: request,
|
||||
overlay: aces ? overlay : null,
|
||||
});
|
||||
const handle = view.addMesh({
|
||||
[DESC_NAME]: {
|
||||
mover,
|
||||
readFrameCamera,
|
||||
kind: options.kind,
|
||||
pickable: options.pickable,
|
||||
onPickable: (wrapper) =>
|
||||
@@ -178,6 +155,9 @@ export function createNavaraMovers(view, { overlay }) {
|
||||
destroy() {
|
||||
pickIndex.remove(options.id);
|
||||
handle.delete();
|
||||
// A descriptor deleted before its first update may never own a mesh.
|
||||
// Release the pending load as well; dispose is idempotent.
|
||||
mover.dispose();
|
||||
request();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,14 +18,12 @@ import {
|
||||
Vector3,
|
||||
} from 'three';
|
||||
import { footprintPerDepth, originFor } from './pack.js';
|
||||
import { createModelTint } from './modelTint.js';
|
||||
|
||||
/**
|
||||
* Ambient light on the tinted surface, as an emissive share of it: Cesium
|
||||
* lights models with image-based lighting besides the sun, so a tinted
|
||||
* aircraft reads near-white at any sun angle; Navara's sky light alone left
|
||||
* them grey-brown at dawn. 0.5 matched Cesium's model luminance at the near
|
||||
* pose (qa-flights-engines --models=on: median 190 vs 193 on Cesium; 139
|
||||
* without it).
|
||||
* Ambient share of the base surface: Cesium supplies image-based lighting
|
||||
* besides the sun. Navara's sky light alone left it grey-brown at dawn.
|
||||
* The instance colour is applied after lighting, independently of this share.
|
||||
*/
|
||||
export const MODEL_AMBIENT = 0.5;
|
||||
|
||||
@@ -47,13 +45,12 @@ const scratchTranslation = new Vector3();
|
||||
const scratchScale = new Vector3();
|
||||
|
||||
/**
|
||||
* Mixes the instance colour, decoded from sRGB (as Cesium takes colours;
|
||||
* the material works in linear), into the material's colour by uGevBlend (the
|
||||
* set's colorBlend, as Cesium's colorBlendAmount); the instance alpha is the
|
||||
* model's opacity (Cesium: a model colour's alpha makes it translucent; the
|
||||
* aircraft fade with distance through it), not a blend weight.
|
||||
* Cesium's ModelFS applies modelColorStage after lightingStage in sRGB.
|
||||
* Mix the lit output in that space, then return to linear for Navara's look.
|
||||
* Alpha remains opacity before alpha testing, independently of the mix.
|
||||
*/
|
||||
function blendInstanceColor(material, uniforms) {
|
||||
function blendInstanceColor(material, uniforms, displayTint) {
|
||||
const alpha = 'diffuseColor.a *= vGevColor.a;';
|
||||
material.onBeforeCompile = (shader) => {
|
||||
Object.assign(shader.uniforms, uniforms);
|
||||
shader.vertexShader = shader.vertexShader
|
||||
@@ -68,29 +65,36 @@ function blendInstanceColor(material, uniforms) {
|
||||
shader.fragmentShader = shader.fragmentShader
|
||||
.replace(
|
||||
'void main() {',
|
||||
'uniform float uGevBlend;\nuniform float uGevAmbient;\nvarying vec4 vGevColor;\nvoid main() {',
|
||||
`uniform float uGevBlend;\nuniform float uGevAmbient;\nvarying vec4 vGevColor;\nvoid main() {`,
|
||||
)
|
||||
.replace(
|
||||
'#include <emissivemap_fragment>',
|
||||
'#include <emissivemap_fragment>\ntotalEmissiveRadiance += diffuseColor.rgb * uGevAmbient;',
|
||||
)
|
||||
.replace('#include <map_fragment>', `#include <map_fragment>\n${alpha}`)
|
||||
.replace(
|
||||
'#include <map_fragment>',
|
||||
'#include <map_fragment>\ndiffuseColor.rgb = mix(diffuseColor.rgb, sRGBTransferEOTF(vGevColor).rgb, uGevBlend);\ndiffuseColor.a *= vGevColor.a;',
|
||||
'#include <opaque_fragment>',
|
||||
`#include <opaque_fragment>\nif (!${displayTint} && uGevBlend > 0.0) {\nvec3 gevLitSrgb = sRGBTransferOETF(vec4(gl_FragColor.rgb, 1.0)).rgb;\nvec3 gevMixedColor = sRGBTransferEOTF(vec4(clamp(mix(gevLitSrgb, vGevColor.rgb, uGevBlend), 0.0, 1.0), 1.0)).rgb;\ngl_FragColor.rgb = gevMixedColor;\n}`,
|
||||
);
|
||||
};
|
||||
material.customProgramCacheKey = () => 'gev-mover-model-blend';
|
||||
material.customProgramCacheKey = () =>
|
||||
`gev-mover-model-display-color-${displayTint ? 'overlay' : 'linear'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} options normalized model set options
|
||||
* @param {object} store the set's mover store
|
||||
* @param {{ load: (url: string) => Promise<{ scene: object }>, onLoad?: () => void }} ports
|
||||
* @param {{ load: (url: string) => Promise<{ scene: object }>, onLoad?: () => void, overlay?: object }} ports
|
||||
*/
|
||||
export function createModelMover(options, store, { load, onLoad = () => {} }) {
|
||||
export function createModelMover(
|
||||
options,
|
||||
store,
|
||||
{ load, onLoad = () => {}, overlay = null },
|
||||
) {
|
||||
const { capacity, model } = options;
|
||||
const uniforms = {
|
||||
uGevAmbient: { value: MODEL_AMBIENT },
|
||||
uGevModelExposure: overlay?.uniforms.uGevModelExposure ?? { value: 1 },
|
||||
uGevBlend: {
|
||||
value: model.colorBlend
|
||||
? model.colorBlend.mode === 'replace'
|
||||
@@ -109,6 +113,13 @@ export function createModelMover(options, store, { load, onLoad = () => {} }) {
|
||||
let disposed = false;
|
||||
const colorAttributes = [];
|
||||
const materials = [];
|
||||
// GLTFLoader returns a fresh asset per load. Cloned materials still share
|
||||
// its textures; Material.dispose() does not release their GPU allocations.
|
||||
const textures = new Set();
|
||||
const disposeTextures = () => {
|
||||
for (const texture of textures) texture.dispose();
|
||||
textures.clear();
|
||||
};
|
||||
// Each instance's drawn diameter (px) at its last write (CPU picks).
|
||||
const drawnPx = new Float32Array(capacity);
|
||||
// Translucent only while an instance is faded (opaque models keep the
|
||||
@@ -175,18 +186,41 @@ export function createModelMover(options, store, { load, onLoad = () => {} }) {
|
||||
index * 4,
|
||||
);
|
||||
}
|
||||
for (const { mesh } of parts) {
|
||||
for (const { mesh, tint } of parts) {
|
||||
if (tint) {
|
||||
tint.count = store.count;
|
||||
tint.visible = store.count > 0;
|
||||
}
|
||||
mesh.count = store.count;
|
||||
mesh.visible = store.count > 0;
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
for (const attribute of colorAttributes) attribute.needsUpdate = true;
|
||||
syncTransparency();
|
||||
};
|
||||
|
||||
const group = { meshes: [], ready: null };
|
||||
group.ready = load(model.url).then((gltf) => {
|
||||
if (disposed) return;
|
||||
const group = { meshes: [], pickMeshes: [], ready: null };
|
||||
const asset = load(model.url).catch(() => {
|
||||
// A missing asset keeps the layer's glyph fallback. Do not leak the
|
||||
// rejected loader promise (or its URL) through descriptor creation.
|
||||
if (!disposed) console.warn('[globe] Model asset unavailable');
|
||||
return null;
|
||||
});
|
||||
group.ready = asset.then((gltf) => {
|
||||
if (!gltf) return;
|
||||
const root = gltf.scene;
|
||||
root.traverse((child) => {
|
||||
for (const material of Array.isArray(child.material)
|
||||
? child.material
|
||||
: [child.material])
|
||||
if (material)
|
||||
for (const value of Object.values(material))
|
||||
if (value?.isTexture) textures.add(value);
|
||||
});
|
||||
if (disposed) {
|
||||
disposeTextures();
|
||||
return;
|
||||
}
|
||||
root.updateMatrixWorld(true);
|
||||
const sphere = new Box3()
|
||||
.setFromObject(root)
|
||||
@@ -201,8 +235,7 @@ export function createModelMover(options, store, { load, onLoad = () => {} }) {
|
||||
color: material.color,
|
||||
});
|
||||
else material = material.clone();
|
||||
blendInstanceColor(material, uniforms);
|
||||
materials.push(material);
|
||||
blendInstanceColor(material, uniforms, Boolean(overlay));
|
||||
const geometry = child.geometry.clone();
|
||||
const color = new InstancedBufferAttribute(
|
||||
new Float32Array(capacity * 4).fill(1),
|
||||
@@ -216,21 +249,37 @@ export function createModelMover(options, store, { load, onLoad = () => {} }) {
|
||||
mesh.frustumCulled = false;
|
||||
mesh.renderOrder = options.order;
|
||||
mesh.count = 0;
|
||||
parts.push({ mesh, local: child.matrixWorld.clone() });
|
||||
mesh.visible = false;
|
||||
const tint = overlay
|
||||
? createModelTint(mesh, uniforms, overlay.uniforms)
|
||||
: null;
|
||||
if (tint) {
|
||||
// Still present in the scene depth and GPU pick registry. Only colour
|
||||
// moves to the display pass, where it is toned before raster resolve.
|
||||
material.colorWrite = false;
|
||||
overlay.add(tint);
|
||||
}
|
||||
materials.push(material);
|
||||
if (tint) materials.push(tint.material);
|
||||
parts.push({ mesh, tint, local: child.matrixWorld.clone() });
|
||||
group.meshes.push(mesh);
|
||||
group.pickMeshes.push(tint ?? mesh);
|
||||
});
|
||||
loaded = true;
|
||||
if (origin)
|
||||
for (const { mesh } of parts)
|
||||
for (const { mesh, tint } of parts) {
|
||||
mesh.position.set(origin.x, origin.y, origin.z);
|
||||
tint?.position.copy(mesh.position);
|
||||
}
|
||||
write(0, store.count);
|
||||
onLoad(group.meshes);
|
||||
});
|
||||
|
||||
const placeAt = (next) => {
|
||||
origin = { x: next.x, y: next.y, z: next.z };
|
||||
for (const { mesh } of parts) {
|
||||
for (const { mesh, tint } of parts) {
|
||||
mesh.position.set(origin.x, origin.y, origin.z);
|
||||
tint?.position.copy(mesh.position);
|
||||
mesh.updateMatrix();
|
||||
mesh.updateMatrixWorld(true);
|
||||
}
|
||||
@@ -258,7 +307,15 @@ export function createModelMover(options, store, { load, onLoad = () => {} }) {
|
||||
z: store.positions[2],
|
||||
});
|
||||
if (range) write(range.from, Math.min(range.to, store.count));
|
||||
for (const { mesh } of parts) mesh.count = store.count;
|
||||
for (const { mesh, tint } of parts) {
|
||||
if (tint) {
|
||||
tint.count = store.count;
|
||||
tint.visible = store.count > 0;
|
||||
}
|
||||
mesh.count = store.count;
|
||||
// Retained empty banks must also skip three's material/uniform work.
|
||||
mesh.visible = store.count > 0;
|
||||
}
|
||||
},
|
||||
/** Each drawn frame: pixel limits need every instance rewritten. */
|
||||
update({
|
||||
@@ -278,12 +335,19 @@ export function createModelMover(options, store, { load, onLoad = () => {} }) {
|
||||
return true;
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
for (const { mesh } of parts) {
|
||||
for (const { mesh, tint } of parts) {
|
||||
if (tint) {
|
||||
overlay.remove(tint);
|
||||
tint.material.dispose();
|
||||
tint.dispose();
|
||||
}
|
||||
mesh.geometry.dispose();
|
||||
mesh.material.dispose();
|
||||
mesh.dispose?.();
|
||||
}
|
||||
disposeTextures();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Mesh,
|
||||
MeshStandardMaterial,
|
||||
Vector3,
|
||||
Texture,
|
||||
} from 'three';
|
||||
import { createMoverStore, normalizeMoverSetOptions } from '../../movers.js';
|
||||
import { createModelMover, modelPixelDistance } from './modelMover.js';
|
||||
@@ -91,7 +92,7 @@ test('a far model keeps its minimum pixel size', async () => {
|
||||
assert.equal(mover.pickRadiusPx(1), null);
|
||||
});
|
||||
|
||||
test("the instance colour (sRGB, decoded) tints by the set's blend amount; its alpha is opacity, as Cesium's model colour", async () => {
|
||||
test("the instance colour mixes after lighting in sRGB, as Cesium's model colour", async () => {
|
||||
const store = createMoverStore(options);
|
||||
const mover = createModelMover(options, store, {
|
||||
load: async () => fakeGltf(),
|
||||
@@ -108,14 +109,22 @@ test("the instance colour (sRGB, decoded) tints by the set's blend amount; its a
|
||||
uniforms: {},
|
||||
vertexShader: 'void main() {\n#include <begin_vertex>\n}',
|
||||
fragmentShader:
|
||||
'void main() {\n#include <map_fragment>\n#include <emissivemap_fragment>\n}',
|
||||
'void main() {\n#include <map_fragment>\n#include <emissivemap_fragment>\n#include <opaque_fragment>\n}',
|
||||
};
|
||||
mesh.material.onBeforeCompile(shader);
|
||||
assert.match(shader.fragmentShader, /diffuseColor\.a \*= vGevColor\.a;/);
|
||||
// Cesium ModelFS runs modelColorStage after lightingStage, in sRGB.
|
||||
assert.match(shader.fragmentShader, /vec3 gevLitSrgb = sRGBTransferOETF/);
|
||||
assert.match(
|
||||
shader.fragmentShader,
|
||||
/diffuseColor\.rgb = mix\(diffuseColor\.rgb, sRGBTransferEOTF\(vGevColor\)\.rgb, uGevBlend\);\s*diffuseColor\.a \*= vGevColor\.a;/,
|
||||
/mix\(gevLitSrgb, vGevColor\.rgb, uGevBlend\)/,
|
||||
);
|
||||
// Ambient light on the tinted surface (Cesium's image-based lighting).
|
||||
assert.ok(
|
||||
shader.fragmentShader.indexOf('vec3 gevLitSrgb') >
|
||||
shader.fragmentShader.indexOf('#include <opaque_fragment>'),
|
||||
);
|
||||
assert.doesNotMatch(shader.fragmentShader, /diffuseColor\.rgb = mix/);
|
||||
// Ambient light belongs to the original surface before the colour mix.
|
||||
assert.match(
|
||||
shader.fragmentShader,
|
||||
/totalEmissiveRadiance \+= diffuseColor\.rgb \* uGevAmbient;/,
|
||||
@@ -134,3 +143,183 @@ test("the minimum pixel size is taken at Cesium's distance: view depth less the
|
||||
assert.equal(modelPixelDistance(0, 0, -10, ahead, 30), 0);
|
||||
assert.equal(modelPixelDistance(4000, 0, -3000, null, 0), 5000);
|
||||
});
|
||||
|
||||
test('disposing a model releases shared textures once, as well as each instanced mesh', async () => {
|
||||
const gltf = fakeGltf();
|
||||
const texture = new Texture();
|
||||
gltf.scene.children[0].material.map = texture;
|
||||
const second = gltf.scene.children[0].clone();
|
||||
gltf.scene.add(second);
|
||||
let released = 0;
|
||||
texture.addEventListener('dispose', () => released++);
|
||||
const mover = createModelMover(options, createMoverStore(options), {
|
||||
load: async () => gltf,
|
||||
});
|
||||
await mover.group.ready;
|
||||
let geometries = 0,
|
||||
materials = 0;
|
||||
for (const mesh of mover.group.meshes) {
|
||||
mesh.geometry.addEventListener('dispose', () => geometries++);
|
||||
mesh.material.addEventListener('dispose', () => materials++);
|
||||
}
|
||||
assert.equal(released, 0, 'textures remain live while the model draws');
|
||||
mover.dispose();
|
||||
mover.dispose();
|
||||
assert.equal(released, 1);
|
||||
assert.equal(geometries, 2);
|
||||
assert.equal(materials, 2);
|
||||
});
|
||||
|
||||
test('a model removed before its load completes releases the arriving textures', async () => {
|
||||
let finish;
|
||||
const gltf = fakeGltf();
|
||||
const texture = new Texture();
|
||||
gltf.scene.children[0].material.map = texture;
|
||||
let released = 0,
|
||||
loaded = 0;
|
||||
texture.addEventListener('dispose', () => released++);
|
||||
const mover = createModelMover(options, createMoverStore(options), {
|
||||
load: () =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
onLoad: () => loaded++,
|
||||
});
|
||||
mover.dispose();
|
||||
finish(gltf);
|
||||
await mover.group.ready;
|
||||
assert.equal(released, 1);
|
||||
assert.equal(loaded, 0);
|
||||
assert.equal(mover.ready(), false);
|
||||
assert.equal(mover.group.meshes.length, 0);
|
||||
});
|
||||
|
||||
test('a failed model load stays not-ready without an unhandled rejection or leaking the URL', async (t) => {
|
||||
const warnings = [];
|
||||
t.mock.method(console, 'warn', (message) => warnings.push(message));
|
||||
const mover = createModelMover(options, createMoverStore(options), {
|
||||
load: async () => {
|
||||
throw new Error('unavailable asset URL');
|
||||
},
|
||||
});
|
||||
await mover.group.ready;
|
||||
assert.equal(mover.ready(), false);
|
||||
assert.equal(mover.group.meshes.length, 0);
|
||||
assert.deepEqual(warnings, ['[globe] Model asset unavailable']);
|
||||
mover.dispose();
|
||||
});
|
||||
|
||||
test('an empty retained model bank leaves the render traversal and restores its cached instances', async () => {
|
||||
const store = createMoverStore(options);
|
||||
const mover = createModelMover(options, store, {
|
||||
load: async () => fakeGltf(),
|
||||
});
|
||||
await mover.group.ready;
|
||||
const [mesh] = mover.group.meshes;
|
||||
assert.equal(mesh.visible, false, 'loaded before its first instance');
|
||||
store.set(0, { position: { x: 6378137, y: 0, z: 0 } });
|
||||
store.setCount(1);
|
||||
mover.commit(store.dirty());
|
||||
store.clean();
|
||||
assert.equal(mesh.visible, true);
|
||||
const before = mesh.instanceMatrix.array.slice();
|
||||
store.setCount(0);
|
||||
mover.commit(store.dirty());
|
||||
store.clean();
|
||||
assert.equal(mesh.visible, false);
|
||||
assert.equal(mover.ready(), true, 'the asset remains cached');
|
||||
store.setCount(1);
|
||||
mover.commit(store.dirty());
|
||||
assert.equal(mesh.visible, true);
|
||||
assert.deepEqual(mesh.instanceMatrix.array, before);
|
||||
mover.dispose();
|
||||
});
|
||||
|
||||
test('display tint shares transforms, preserves fade opacity and leaves no overlay resource after destruction', async () => {
|
||||
const children = new Set();
|
||||
const overlay = {
|
||||
uniforms: {
|
||||
tGevSceneDepth: { value: null },
|
||||
uGevResolution: { value: [1280, 800] },
|
||||
},
|
||||
add: (mesh) => children.add(mesh),
|
||||
remove: (mesh) => children.delete(mesh),
|
||||
};
|
||||
const store = createMoverStore(options);
|
||||
const mover = createModelMover(options, store, {
|
||||
load: async () => fakeGltf(),
|
||||
overlay,
|
||||
});
|
||||
store.set(0, {
|
||||
position: { x: 6378137, y: 0, z: 0 },
|
||||
color: [1, 0.2, 0.6, 0.4],
|
||||
});
|
||||
store.setCount(1);
|
||||
mover.commit(store.dirty());
|
||||
await mover.group.ready;
|
||||
const [base] = mover.group.meshes;
|
||||
const [tint] = children;
|
||||
assert.equal(tint.geometry, base.geometry);
|
||||
assert.equal(tint.instanceMatrix, base.instanceMatrix);
|
||||
assert.deepEqual(tint.position, base.position);
|
||||
assert.equal(tint.count, 1);
|
||||
assert.equal(tint.material.toneMapped, false);
|
||||
assert.equal(tint.material.depthWrite, false);
|
||||
const shader = {
|
||||
uniforms: {},
|
||||
vertexShader: 'void main() {\n#include <begin_vertex>\n}',
|
||||
fragmentShader:
|
||||
'void main() {\n#include <logdepthbuf_fragment>\n#include <opaque_fragment>\n}',
|
||||
};
|
||||
tint.material.onBeforeCompile(shader);
|
||||
assert.equal(shader.uniforms.tGevSceneDepth, overlay.uniforms.tGevSceneDepth);
|
||||
assert.match(shader.fragmentShader, /if \(gevOverlayHidden\(\)\) discard/);
|
||||
assert.match(
|
||||
shader.fragmentShader,
|
||||
/mix\(gevLitSrgb, vGevColor.rgb, uGevBlend\)/,
|
||||
);
|
||||
assert.equal(base.material.colorWrite, false);
|
||||
assert.equal(
|
||||
mover.group.pickMeshes[0],
|
||||
tint,
|
||||
'GPU picking keeps its color-writing material',
|
||||
);
|
||||
store.setCount(0);
|
||||
mover.commit(store.dirty());
|
||||
assert.equal(tint.count, 0);
|
||||
assert.equal(tint.visible, false);
|
||||
let disposed = 0;
|
||||
tint.material.addEventListener('dispose', () => disposed++);
|
||||
mover.dispose();
|
||||
mover.dispose();
|
||||
assert.equal(disposed, 1);
|
||||
assert.equal(children.size, 0);
|
||||
});
|
||||
|
||||
test('the display pass preserves unlit model colour without a lighting tone curve', async () => {
|
||||
const children = new Set();
|
||||
const overlay = {
|
||||
uniforms: {},
|
||||
add: (mesh) => children.add(mesh),
|
||||
remove: (mesh) => children.delete(mesh),
|
||||
};
|
||||
const spec = { ...options, model: { ...options.model, unlit: true } };
|
||||
const mover = createModelMover(spec, createMoverStore(spec), {
|
||||
load: async () => fakeGltf(),
|
||||
overlay,
|
||||
});
|
||||
await mover.group.ready;
|
||||
const [mesh] = children;
|
||||
assert.equal(mesh.material.isMeshBasicMaterial, true);
|
||||
const shader = {
|
||||
uniforms: {},
|
||||
vertexShader: 'void main() {\n#include <begin_vertex>\n}',
|
||||
fragmentShader: 'void main() {\n#include <opaque_fragment>\n}',
|
||||
};
|
||||
mesh.material.onBeforeCompile(shader);
|
||||
assert.doesNotMatch(
|
||||
shader.fragmentShader,
|
||||
/gevModelAces\(gl_FragColor.rgb\)/,
|
||||
);
|
||||
mover.dispose();
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
// Models use the scene's depth, but their lit display colour is resolved after
|
||||
// the view's HDR effects. This keeps MSAA from spreading HDR tint into edges.
|
||||
import { InstancedMesh } from 'three';
|
||||
import { OVERLAY_DEPTH_FRAGMENT } from '../overlayDepth.js';
|
||||
|
||||
// three's pinned ACESFilmicToneMapping, with the view's public exposure.
|
||||
const ACES = `
|
||||
uniform float uGevModelExposure;
|
||||
vec3 gevModelAces(vec3 color) {
|
||||
const mat3 inputMatrix = mat3(0.59719,0.076,0.0284, 0.35458,0.90834,0.13383, 0.04823,0.01566,0.83777);
|
||||
const mat3 outputMatrix = mat3(1.60475,-0.10208,-0.00327, -0.53108,1.10813,-0.07276, -0.07367,-0.00605,1.07602);
|
||||
color = inputMatrix * (color * uGevModelExposure / 0.6);
|
||||
color = (color * (color + 0.0245786) - 0.000090537) /
|
||||
(color * (0.983729 * color + 0.432951) + 0.238081);
|
||||
return clamp(outputMatrix * color, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
export function createModelTint(mesh, uniforms, overlayUniforms) {
|
||||
const material = mesh.material.clone();
|
||||
material.depthTest = false;
|
||||
material.depthWrite = false;
|
||||
material.toneMapped = false;
|
||||
const lit = material.isMeshBasicMaterial
|
||||
? 'gl_FragColor.rgb'
|
||||
: 'gevModelAces(gl_FragColor.rgb)';
|
||||
material.onBeforeCompile = (shader) => {
|
||||
Object.assign(shader.uniforms, uniforms, overlayUniforms, {
|
||||
uGevDepthMode: { value: 1 },
|
||||
});
|
||||
shader.vertexShader = shader.vertexShader
|
||||
.replace(
|
||||
'void main() {',
|
||||
'attribute vec4 gevColor;\nvarying vec4 vGevColor;\nvarying float vGevDepthFree;\nvoid main() {',
|
||||
)
|
||||
.replace(
|
||||
'#include <begin_vertex>',
|
||||
'#include <begin_vertex>\nvGevColor = gevColor;\nvGevDepthFree = 0.0;',
|
||||
);
|
||||
shader.fragmentShader = shader.fragmentShader
|
||||
.replace(
|
||||
'void main() {',
|
||||
`uniform float uGevBlend;\nuniform float uGevAmbient;\nvarying vec4 vGevColor;\n${ACES}\n${OVERLAY_DEPTH_FRAGMENT}\nvoid main() {`,
|
||||
)
|
||||
.replace(
|
||||
'#include <logdepthbuf_fragment>',
|
||||
'#include <logdepthbuf_fragment>\nif (gevOverlayHidden()) discard;',
|
||||
)
|
||||
.replace(
|
||||
'#include <map_fragment>',
|
||||
'#include <map_fragment>\ndiffuseColor.a *= vGevColor.a;',
|
||||
)
|
||||
.replace(
|
||||
'#include <emissivemap_fragment>',
|
||||
'#include <emissivemap_fragment>\ntotalEmissiveRadiance += diffuseColor.rgb * uGevAmbient;',
|
||||
)
|
||||
.replace(
|
||||
'#include <opaque_fragment>',
|
||||
`#include <opaque_fragment>\nvec3 gevLitSrgb = sRGBTransferOETF(vec4(${lit}, 1.0)).rgb;\ngl_FragColor.rgb = sRGBTransferEOTF(vec4(mix(gevLitSrgb, vGevColor.rgb, uGevBlend), 1.0)).rgb;`,
|
||||
);
|
||||
};
|
||||
material.customProgramCacheKey = () => 'gev-model-lit-display-color';
|
||||
const tint = new InstancedMesh(
|
||||
mesh.geometry,
|
||||
material,
|
||||
mesh.instanceMatrix.count,
|
||||
);
|
||||
tint.userData.gevModelTint = true;
|
||||
tint.instanceMatrix = mesh.instanceMatrix;
|
||||
tint.frustumCulled = false;
|
||||
tint.renderOrder = mesh.renderOrder;
|
||||
return tint;
|
||||
}
|
||||
+52
-27
@@ -1,44 +1,26 @@
|
||||
// gev's screen overlays on Navara: sprites, points and lines (engine.movers
|
||||
// and engine.lines) drawn into the frame after Navara's tone mapping and
|
||||
// before its anti-aliasing (SMAA/FXAA) and the gev sharpening, so their
|
||||
// and engine.lines) drawn into the frame after Navara's tone mapping,
|
||||
// anti-aliasing (SMAA/FXAA) and the gev sharpening, so their
|
||||
// colours are the colours asked for (as Cesium draws billboards and
|
||||
// polylines), not tone-mapped or graded with the look. A public Navara
|
||||
// EffectDesc whose pass renders one three scene over the input buffer; the
|
||||
// post-processing chain has no depth buffer, so each overlay material tests
|
||||
// the scene depth from Navara's MRT pass itself (overlayDepth.js). The
|
||||
// pass costs nothing while the scene is empty. Models stay in Navara's
|
||||
// scene, lit and graded like the 3D tiles.
|
||||
// pass costs nothing while no geometry is visible. Models retain scene
|
||||
// depth; their lit display colour is resolved here so HDR effects and MSAA
|
||||
// cannot spread a tint into neighbouring pixels.
|
||||
|
||||
import { EffectDesc, Pass as NavaraPass } from '@navaramap/three';
|
||||
import { Pass } from 'postprocessing';
|
||||
import { GevOverlayPass } from './overlayPass.js';
|
||||
import { RGBADepthPacking, Scene } from 'three';
|
||||
|
||||
export const OVERLAY_EFFECT_KEY = 'gevOverlay';
|
||||
|
||||
class GevOverlayPass extends Pass {
|
||||
constructor(scene, camera, overlay) {
|
||||
super('GevOverlayPass', scene, camera);
|
||||
this.needsSwap = false;
|
||||
this.overlay = overlay;
|
||||
}
|
||||
|
||||
render(renderer, inputBuffer) {
|
||||
if (!this.scene.children.length) return;
|
||||
const resolution = this.overlay.uniforms.uGevResolution.value;
|
||||
resolution[0] = inputBuffer?.width ?? 0;
|
||||
resolution[1] = inputBuffer?.height ?? 0;
|
||||
const autoClear = renderer.autoClear;
|
||||
renderer.autoClear = false;
|
||||
renderer.setRenderTarget(this.renderToScreen ? null : inputBuffer);
|
||||
renderer.render(this.scene, this.camera);
|
||||
renderer.autoClear = autoClear;
|
||||
}
|
||||
}
|
||||
|
||||
class GevOverlayEffectDesc extends EffectDesc {
|
||||
static key = OVERLAY_EFFECT_KEY;
|
||||
static insertAfter = ['toneMapping'];
|
||||
static insertBefore = ['gevSharpen', 'smaa', 'fxaa', 'final'];
|
||||
// Navara takes the first existing anchor, so the latest pass comes first.
|
||||
static insertAfter = ['fxaa', 'smaa', 'gevSharpen', 'toneMapping'];
|
||||
static insertBefore = ['final'];
|
||||
|
||||
constructor(view, ctx, config) {
|
||||
super(view, ctx, config);
|
||||
@@ -77,11 +59,14 @@ export function createNavaraOverlay(view) {
|
||||
view.registerEffect(OVERLAY_EFFECT_KEY, GevOverlayEffectDesc);
|
||||
const scene = new Scene();
|
||||
const frameHooks = new Map();
|
||||
const lights = new Map();
|
||||
let ctx = null;
|
||||
const pendingPicks = new Map();
|
||||
const overlay = {
|
||||
scene,
|
||||
modelTints: new Set(),
|
||||
uniforms: {
|
||||
uGevModelExposure: { value: 1 },
|
||||
tGevSceneDepth: { value: null },
|
||||
uGevResolution: { value: [0, 0] },
|
||||
},
|
||||
@@ -91,7 +76,42 @@ export function createNavaraOverlay(view) {
|
||||
ctx.registerPickableMesh(key, wrapper);
|
||||
pendingPicks.clear();
|
||||
},
|
||||
hasDrawables() {
|
||||
for (const child of scene.children)
|
||||
if (
|
||||
!child.isLight &&
|
||||
child.visible &&
|
||||
(child.count === undefined || child.count > 0)
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
},
|
||||
beforeFrame(time) {
|
||||
overlay.uniforms.uGevModelExposure.value = view.toneMappingExposure ?? 1;
|
||||
// Public descriptor lights are copied, never reparented from Navara.
|
||||
if (overlay.modelTints.size && ctx) {
|
||||
for (const light of ctx.scenes.light.children) {
|
||||
if (!light.isLight) continue;
|
||||
let copy = lights.get(light);
|
||||
if (!copy) {
|
||||
copy = light.clone(false);
|
||||
copy.castShadow = false;
|
||||
copy.matrixAutoUpdate = false;
|
||||
lights.set(light, copy);
|
||||
scene.add(copy);
|
||||
}
|
||||
copy.color.copy(light.color);
|
||||
copy.intensity = light.intensity;
|
||||
copy.matrix.copy(light.matrixWorld);
|
||||
copy.sh?.copy(light.sh);
|
||||
copy.groundColor?.copy(light.groundColor);
|
||||
if (copy.target && light.target) {
|
||||
copy.target.matrixAutoUpdate = false;
|
||||
copy.target.matrix.copy(light.target.matrixWorld);
|
||||
copy.target.updateMatrixWorld(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const hook of frameHooks.values()) hook(time);
|
||||
},
|
||||
get ctx() {
|
||||
@@ -107,10 +127,12 @@ export function createNavaraOverlay(view) {
|
||||
uniforms: overlay.uniforms,
|
||||
add(object, onFrame) {
|
||||
scene.add(object);
|
||||
if (object.userData.gevModelTint) overlay.modelTints.add(object);
|
||||
if (onFrame) frameHooks.set(object, onFrame);
|
||||
},
|
||||
remove(object) {
|
||||
scene.remove(object);
|
||||
overlay.modelTints.delete(object);
|
||||
frameHooks.delete(object);
|
||||
},
|
||||
registerPickable(key, wrapper) {
|
||||
@@ -127,6 +149,9 @@ export function createNavaraOverlay(view) {
|
||||
destroy() {
|
||||
handle.delete();
|
||||
scene.clear();
|
||||
overlay.modelTints.clear();
|
||||
for (const light of lights.values()) light.dispose?.();
|
||||
lights.clear();
|
||||
frameHooks.clear();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Pass } from 'postprocessing';
|
||||
|
||||
export class GevOverlayPass extends Pass {
|
||||
constructor(scene, camera, overlay) {
|
||||
super('GevOverlayPass', scene, camera);
|
||||
this.needsSwap = false;
|
||||
this.overlay = overlay;
|
||||
}
|
||||
render(renderer, inputBuffer) {
|
||||
if (!this.overlay.hasDrawables()) return;
|
||||
const resolution = this.overlay.uniforms.uGevResolution.value;
|
||||
resolution[0] = inputBuffer?.width ?? 0;
|
||||
resolution[1] = inputBuffer?.height ?? 0;
|
||||
const autoClear = renderer.autoClear;
|
||||
renderer.autoClear = false;
|
||||
try {
|
||||
renderer.setRenderTarget(this.renderToScreen ? null : inputBuffer);
|
||||
renderer.render(this.scene, this.camera);
|
||||
} finally {
|
||||
renderer.autoClear = autoClear;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Scene, PerspectiveCamera } from 'three';
|
||||
import { GevOverlayPass } from './overlayPass.js';
|
||||
test('overlay draws in place only with visible objects and restores renderer state on failure', () => {
|
||||
let active = false;
|
||||
const overlay = {
|
||||
hasDrawables: () => active,
|
||||
uniforms: { uGevResolution: { value: [0, 0] } },
|
||||
};
|
||||
const pass = new GevOverlayPass(
|
||||
new Scene(),
|
||||
new PerspectiveCamera(),
|
||||
overlay,
|
||||
);
|
||||
const input = { width: 1280, height: 800 };
|
||||
let target,
|
||||
renders = 0;
|
||||
const renderer = {
|
||||
autoClear: true,
|
||||
setRenderTarget: (value) => {
|
||||
target = value;
|
||||
},
|
||||
render: () => {
|
||||
renders++;
|
||||
},
|
||||
};
|
||||
pass.render(renderer, input);
|
||||
assert.equal(renders, 0);
|
||||
active = true;
|
||||
pass.render(renderer, input);
|
||||
assert.equal(target, input);
|
||||
assert.equal(renders, 1);
|
||||
assert.equal(pass.needsSwap, false);
|
||||
assert.equal(renderer.autoClear, true);
|
||||
assert.deepEqual(overlay.uniforms.uGevResolution.value, [1280, 800]);
|
||||
renderer.render = () => {
|
||||
throw new Error('render failed');
|
||||
};
|
||||
assert.throws(() => pass.render(renderer, input), /render failed/);
|
||||
assert.equal(renderer.autoClear, true);
|
||||
pass.dispose();
|
||||
});
|
||||
@@ -86,8 +86,8 @@ class UnsharpMaskEffect extends Effect {
|
||||
/** config.gevSharpen: { amount } (Cesium's `amount` uniform). */
|
||||
export class SharpenEffectDesc extends EffectDesc {
|
||||
static key = SHARPEN_EFFECT_KEY;
|
||||
static insertAfter = ['gevOverlay', 'toneMapping'];
|
||||
static insertBefore = ['smaa', 'fxaa', 'final'];
|
||||
static insertAfter = ['toneMapping'];
|
||||
static insertBefore = ['smaa', 'fxaa', 'gevOverlay', 'final'];
|
||||
|
||||
constructor(view, ctx, config) {
|
||||
super(view, ctx, config);
|
||||
|
||||
@@ -49,7 +49,7 @@ export function resolveNavaraLook(search = '') {
|
||||
}
|
||||
|
||||
// postprocessing's ToneMappingMode.ACES_FILMIC (Navara's default is AGX, 7).
|
||||
const ACES_FILMIC = 6;
|
||||
export const ACES_FILMIC = 6;
|
||||
|
||||
// Camera heights (m) where the cinematic look switches the globe to
|
||||
// atmosphere lighting (night side and terminator) and back. The gap keeps a
|
||||
|
||||
@@ -26,6 +26,12 @@ export function aircraftQaDrawn(state) {
|
||||
width: sprite.width,
|
||||
show: sprite.show,
|
||||
rotation: sprite.rotation,
|
||||
trackedRotation:
|
||||
id === state._trackedIcao
|
||||
? (state._trackedEntity?.rotation ??
|
||||
state._lastTrackedRotation ??
|
||||
null)
|
||||
: null,
|
||||
alpha: sprite.color?.alpha ?? null,
|
||||
position: sprite.position
|
||||
? {
|
||||
@@ -43,6 +49,7 @@ export function aircraftQaDrawn(state) {
|
||||
ready: model.ready,
|
||||
// Where it is drawn: its transform's translation.
|
||||
position: translationOf(model.modelMatrix),
|
||||
matrix: model.modelMatrix ? Array.from(model.modelMatrix) : null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
@@ -56,6 +63,7 @@ export function aircraftQaDrawn(state) {
|
||||
scale: tracked.scale,
|
||||
show: tracked.show,
|
||||
ready: tracked.ready,
|
||||
matrix: tracked.modelMatrix ? Array.from(tracked.modelMatrix) : null,
|
||||
},
|
||||
});
|
||||
return out;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { aircraftQaDrawn } from './qaDrawn.js';
|
||||
|
||||
for (const native of [false, true]) {
|
||||
test(`heading probe copies model transforms and reads tracked glyph (${native ? 'native' : 'engine'})`, () => {
|
||||
const matrix = Float64Array.from({ length: 16 }, (_, i) => i);
|
||||
const sprite = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
rotation: 0.4,
|
||||
show: false,
|
||||
};
|
||||
const model = { modelMatrix: matrix, show: true, ready: true };
|
||||
const state = {
|
||||
_billboards: new Map([['a', sprite]]),
|
||||
_models: new Map([['a', model]]),
|
||||
_trackedIcao: 'a',
|
||||
_lastTrackedRotation: 0.7,
|
||||
_trackedModel: model,
|
||||
_trackedEntity: native
|
||||
? {
|
||||
billboard: {
|
||||
rotation: {
|
||||
getValue: () => {
|
||||
throw new Error(
|
||||
'Probe must not evaluate a reactive property',
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: { rotation: 0.7 },
|
||||
};
|
||||
const [fleet, tracked] = aircraftQaDrawn(state);
|
||||
assert.equal(fleet.trackedRotation, 0.7);
|
||||
assert.equal(fleet.rotation, 0.4);
|
||||
assert.equal(tracked.model.matrix[12], 12);
|
||||
fleet.model.matrix[12] = -1;
|
||||
tracked.model.matrix[12] = -2;
|
||||
assert.equal(matrix[12], 12);
|
||||
assert.equal(aircraftQaDrawn(state)[1].model.matrix[12], 12);
|
||||
});
|
||||
}
|
||||
@@ -57,6 +57,13 @@ const fieldsNoPick = {
|
||||
show: undefined,
|
||||
};
|
||||
const scratchColor = new Float64Array(4);
|
||||
// Sprite setters write synchronously into the mover store; reuse their
|
||||
// field records rather than allocating one per contact and assignment.
|
||||
const spritePositionField = { position: null };
|
||||
const spriteShowField = { show: true };
|
||||
const spriteRotationField = { rotation: 0 };
|
||||
const spriteScaleField = { scale: 1 };
|
||||
const spriteColorField = { color: scratchColor };
|
||||
function writeFields(set, index, fields) {
|
||||
const scratch = fieldsNoPick;
|
||||
for (const key in fields) scratch[key] = fields[key];
|
||||
@@ -371,7 +378,8 @@ export function createAircraftNavaraRenderer({
|
||||
|
||||
set position(value) {
|
||||
geo.cartesian3.clone(value, this._position);
|
||||
this.write({ position: this._position });
|
||||
spritePositionField.position = this._position;
|
||||
this.write(spritePositionField);
|
||||
}
|
||||
|
||||
get show() {
|
||||
@@ -379,8 +387,11 @@ export function createAircraftNavaraRenderer({
|
||||
}
|
||||
|
||||
set show(value) {
|
||||
this._show = value !== false;
|
||||
this.write({ show: this._show });
|
||||
const next = value !== false;
|
||||
if (next === this._show) return;
|
||||
this._show = next;
|
||||
spriteShowField.show = next;
|
||||
this.write(spriteShowField);
|
||||
}
|
||||
|
||||
get rotation() {
|
||||
@@ -388,8 +399,10 @@ export function createAircraftNavaraRenderer({
|
||||
}
|
||||
|
||||
set rotation(value) {
|
||||
if (value === this._rotation) return;
|
||||
this._rotation = value;
|
||||
this.write({ rotation: value });
|
||||
spriteRotationField.rotation = value;
|
||||
this.write(spriteRotationField);
|
||||
}
|
||||
|
||||
get scale() {
|
||||
@@ -397,8 +410,10 @@ export function createAircraftNavaraRenderer({
|
||||
}
|
||||
|
||||
set scale(value) {
|
||||
if (value === this._scale) return;
|
||||
this._scale = value;
|
||||
this.write({ scale: this.drawnScale() });
|
||||
spriteScaleField.scale = this.drawnScale();
|
||||
this.write(spriteScaleField);
|
||||
}
|
||||
|
||||
get width() {
|
||||
@@ -406,8 +421,10 @@ export function createAircraftNavaraRenderer({
|
||||
}
|
||||
|
||||
set width(value) {
|
||||
if (value === this._width) return;
|
||||
this._width = value;
|
||||
this.write({ scale: this.drawnScale() });
|
||||
spriteScaleField.scale = this.drawnScale();
|
||||
this.write(spriteScaleField);
|
||||
}
|
||||
|
||||
get height() {
|
||||
@@ -423,8 +440,17 @@ export function createAircraftNavaraRenderer({
|
||||
}
|
||||
|
||||
set color(value) {
|
||||
if (
|
||||
value !== this._color &&
|
||||
(value?.red ?? 1) === this._color.red &&
|
||||
(value?.green ?? 1) === this._color.green &&
|
||||
(value?.blue ?? 1) === this._color.blue &&
|
||||
(value?.alpha ?? 1) === this._color.alpha
|
||||
)
|
||||
return;
|
||||
this._color = new StoredColor(value);
|
||||
this.write({ color: rgbaOf(this._color) });
|
||||
rgbaOf(this._color);
|
||||
this.write(spriteColorField);
|
||||
}
|
||||
|
||||
get image() {
|
||||
|
||||
@@ -293,3 +293,59 @@ test('trails are engine lines in the trail pick namespace; the head follows each
|
||||
renderer.destroy();
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('unchanged sprite style does not upload or request another frame', async () => {
|
||||
const { engine, renderer } = await setup();
|
||||
const sprite = renderer.fleet.add({
|
||||
id: 'same',
|
||||
position: geo.cartesian3.fromDegrees(0, 0, 1000),
|
||||
image: aircraftIcon('light'),
|
||||
});
|
||||
await Promise.resolve();
|
||||
const [listed] = setsOf(engine, 'flights-sprites');
|
||||
const part = engine.fake.moverSet(listed.id);
|
||||
const before = part.commits;
|
||||
const color = sprite.color;
|
||||
for (let n = 0; n < 1000; n++) {
|
||||
sprite.show = true;
|
||||
sprite.rotation = 0;
|
||||
sprite.scale = 1;
|
||||
sprite.width = 20;
|
||||
sprite.color = WHITE;
|
||||
}
|
||||
await Promise.resolve();
|
||||
assert.equal(part.commits, before);
|
||||
assert.equal(sprite.color, color);
|
||||
renderer.destroy();
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('reused sprite field records are copied, including a mutated color getter', async () => {
|
||||
const { engine, renderer } = await setup();
|
||||
const a = renderer.fleet.add({
|
||||
id: 'a',
|
||||
position: geo.cartesian3.fromDegrees(0, 0, 1000),
|
||||
image: aircraftIcon('light'),
|
||||
});
|
||||
const b = renderer.fleet.add({
|
||||
id: 'b',
|
||||
position: a.position,
|
||||
image: aircraftIcon('light'),
|
||||
});
|
||||
await Promise.resolve();
|
||||
const [listed] = setsOf(engine, 'flights-sprites');
|
||||
const { store } = engine.fake.moverSet(listed.id);
|
||||
const position = geo.cartesian3.create(7, 8, 9);
|
||||
a.position = position;
|
||||
position.x = 20;
|
||||
b.position = position;
|
||||
a.color.alpha = 0.25;
|
||||
a.color = a.color;
|
||||
b.color = WHITE.withAlpha(0.75);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual([...store.positions.subarray(0, 6)], [7, 8, 9, 20, 8, 9]);
|
||||
assert.equal(store.colors[3], 0.25);
|
||||
assert.equal(store.colors[7], 0.75);
|
||||
renderer.destroy();
|
||||
engine.destroy();
|
||||
});
|
||||
@@ -1048,7 +1048,11 @@ export function createTracking({
|
||||
} else if (flightState._renderer.fleet.visible()) {
|
||||
// Fire-and-forget refresh; only while the layer is actually enabled.
|
||||
// The query reads the renderer's camera (no viewer argument needed).
|
||||
void flightsLayer.update(null);
|
||||
void flightsLayer.update(null).catch((error) => {
|
||||
// This refresh has no lifecycle-manager caller. Disabling flights
|
||||
// cancels it locally; retain visibility of unexpected failures.
|
||||
if (error?.name !== 'AbortError') console.error(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createTracking } from './tracking.js';
|
||||
|
||||
function handoff(update) {
|
||||
return createTracking({
|
||||
flightState: {
|
||||
_renderer: { fleet: { attached: () => true, visible: () => true } },
|
||||
},
|
||||
services: Object.fromEntries(
|
||||
[
|
||||
'context',
|
||||
'aircraftPresentation',
|
||||
'geoid',
|
||||
'groundFloor',
|
||||
'focus',
|
||||
'militaryRegistry',
|
||||
'readout',
|
||||
'selection',
|
||||
].map((name) => [name, {}]),
|
||||
),
|
||||
parts: {},
|
||||
layer: { update },
|
||||
});
|
||||
}
|
||||
|
||||
test('a military-off refresh owns cancellation when flights are disabled before it settles', async (t) => {
|
||||
const errors = [];
|
||||
t.mock.method(console, 'error', (error) => errors.push(error));
|
||||
let reject,
|
||||
calls = 0;
|
||||
const tracking = handoff(() => {
|
||||
calls++;
|
||||
return new Promise((_resolve, fail) => {
|
||||
reject = fail;
|
||||
});
|
||||
});
|
||||
tracking._onMilitaryActiveChange(false);
|
||||
assert.equal(calls, 1);
|
||||
reject(new DOMException('Flights update aborted', 'AbortError'));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test('unexpected military-off refresh failures remain visible instead of being swallowed', async (t) => {
|
||||
const error = new Error('fixture update failure');
|
||||
const errors = [];
|
||||
t.mock.method(console, 'error', (value) => errors.push(value));
|
||||
handoff(async () => {
|
||||
throw error;
|
||||
})._onMilitaryActiveChange(false);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(errors, [error]);
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from './card.js';
|
||||
import { createLocalAdsbEnrichment, localAdsbClass } from './enrichment.js';
|
||||
import { createLocalAdsbModels } from './models.js';
|
||||
import { localAdsbQaDrawn } from './qaDrawn.js';
|
||||
import { LocalAdsbMotion } from './motion.js';
|
||||
import {
|
||||
ENTITY_PREFIX,
|
||||
@@ -714,6 +715,18 @@ export function createLocalAdsbLayer({
|
||||
void receiver.destroy?.();
|
||||
},
|
||||
|
||||
...(import.meta.env?.DEV
|
||||
? {
|
||||
qaDrawn() {
|
||||
return localAdsbQaDrawn(markers, models?.qaModels?.(), {
|
||||
enabled,
|
||||
cameraPosition: renderer?.view.camera?.positionWC,
|
||||
isVisible: markerVisible,
|
||||
});
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
getStats() {
|
||||
const current = receiver.getState();
|
||||
const at = now();
|
||||
|
||||
@@ -178,6 +178,7 @@ export function createLocalAdsbModels({
|
||||
}
|
||||
|
||||
return {
|
||||
...(import.meta.env?.DEV ? { qaModels: () => models } : {}),
|
||||
/** Ids whose model admission is current; the layer enriches these. */
|
||||
get eligible() {
|
||||
return eligible;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// On-demand QA snapshots of the cached local renderer state. Never read
|
||||
// an entity CallbackProperty: that would evaluate rendering during a probe.
|
||||
export function localAdsbQaDrawn(
|
||||
markers,
|
||||
models,
|
||||
{ enabled, cameraPosition, isVisible },
|
||||
) {
|
||||
const rows = [];
|
||||
for (const marker of markers.values()) {
|
||||
const position = marker.position;
|
||||
const distance =
|
||||
position && cameraPosition
|
||||
? Math.hypot(
|
||||
position.x - cameraPosition.x,
|
||||
position.y - cameraPosition.y,
|
||||
position.z - cameraPosition.z,
|
||||
)
|
||||
: Infinity;
|
||||
const show = Boolean(
|
||||
enabled &&
|
||||
!marker.modelOwnsVisual &&
|
||||
isVisible(marker) &&
|
||||
distance <= 2_000_000,
|
||||
);
|
||||
const model = models?.get(marker.id);
|
||||
const matrix = model?.modelMatrix;
|
||||
rows.push({
|
||||
id: marker.record.icao,
|
||||
show,
|
||||
rotation: marker.lastRotation,
|
||||
alpha: show ? 1 : 0,
|
||||
position: position
|
||||
? { x: position.x, y: position.y, z: position.z }
|
||||
: null,
|
||||
model: model
|
||||
? {
|
||||
show: model.show,
|
||||
ready: model.ready,
|
||||
computedScale: model.computedScale,
|
||||
position: matrix
|
||||
? { x: matrix[12], y: matrix[13], z: matrix[14] }
|
||||
: null,
|
||||
matrix: matrix ? Array.from(matrix) : null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { localAdsbQaDrawn } from './qaDrawn.js';
|
||||
function setup() {
|
||||
const marker = {
|
||||
id: 'local-adsb:abc123',
|
||||
record: { icao: 'abc123' },
|
||||
position: { x: 6378137, y: 2, z: 3 },
|
||||
lastRotation: 0.7,
|
||||
modelOwnsVisual: false,
|
||||
};
|
||||
Object.defineProperty(marker, 'entity', {
|
||||
get() {
|
||||
throw new Error('A snapshot must not evaluate native callbacks');
|
||||
},
|
||||
});
|
||||
const matrix = new Float64Array(16);
|
||||
matrix[12] = 6378138;
|
||||
matrix[13] = 4;
|
||||
matrix[14] = 5;
|
||||
const model = {
|
||||
ready: true,
|
||||
show: true,
|
||||
computedScale: 12,
|
||||
modelMatrix: matrix,
|
||||
};
|
||||
const markers = new Map([[marker.id, marker]]),
|
||||
models = new Map([[marker.id, model]]);
|
||||
const options = {
|
||||
enabled: true,
|
||||
cameraPosition: { x: 6379137, y: 2, z: 3 },
|
||||
isVisible: () => true,
|
||||
};
|
||||
return { marker, matrix, markers, models, options };
|
||||
}
|
||||
test('local QA copies the drawn model and reads cached glyph rotation without callbacks', () => {
|
||||
const f = setup();
|
||||
const [row] = localAdsbQaDrawn(f.markers, f.models, f.options);
|
||||
assert.equal(row.id, 'abc123');
|
||||
assert.equal(row.rotation, 0.7);
|
||||
assert.equal(row.alpha, 1);
|
||||
assert.equal(row.model.computedScale, 12);
|
||||
assert.equal(row.model.ready, true);
|
||||
row.position.x = 0;
|
||||
row.model.matrix[12] = 0;
|
||||
row.model.position.x = 0;
|
||||
assert.equal(f.marker.position.x, 6378137);
|
||||
assert.equal(f.matrix[12], 6378138);
|
||||
});
|
||||
test('local QA respects disabled, horizon, model handoff and the two-million-metre glyph limit', () => {
|
||||
for (const change of [
|
||||
(f) => {
|
||||
f.options.enabled = false;
|
||||
},
|
||||
(f) => {
|
||||
f.options.isVisible = () => false;
|
||||
},
|
||||
(f) => {
|
||||
f.marker.modelOwnsVisual = true;
|
||||
},
|
||||
(f) => {
|
||||
f.options.cameraPosition.x += 2_000_000;
|
||||
},
|
||||
]) {
|
||||
const f = setup();
|
||||
change(f);
|
||||
const [row] = localAdsbQaDrawn(f.markers, f.models, f.options);
|
||||
assert.equal(row.show, false);
|
||||
assert.equal(row.alpha, 0);
|
||||
assert.equal(row.model.ready, true);
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
|
||||
import {
|
||||
DEFAULT_DATA_MODE,
|
||||
@@ -7,6 +8,8 @@ import {
|
||||
isHardwareRenderer,
|
||||
isHeadfulMode,
|
||||
parseDataMode,
|
||||
vesselCardCaptureUrl,
|
||||
installSyntheticVesselFeed,
|
||||
vesselCardManifestFilename,
|
||||
vesselCardProvenanceLines,
|
||||
vesselCardScreenshotFilename,
|
||||
@@ -89,3 +92,80 @@ test('vessel-card QA distinguishes hardware evidence from software rendering', (
|
||||
assert.equal(isHardwareRenderer({ vendor: 'Google Inc.', renderer: 'ANGLE (SwiftShader)' }), false);
|
||||
assert.equal(isHardwareRenderer({ vendor: 'unavailable', renderer: 'unavailable' }), false);
|
||||
});
|
||||
|
||||
test('synthetic card URL removes competing qaFeed while live mode preserves it', () => {
|
||||
const input =
|
||||
'http://127.0.0.1:4193/?engine=navara&qaFeed=vessels:fixture#v=2';
|
||||
const synthetic = new URL(vesselCardCaptureUrl(input, 'synthetic'));
|
||||
assert.equal(synthetic.searchParams.has('qaFeed'), false);
|
||||
assert.equal(synthetic.searchParams.get('engine'), 'navara');
|
||||
assert.equal(synthetic.hash, '#v=2');
|
||||
assert.equal(
|
||||
new URL(vesselCardCaptureUrl(input, 'live')).searchParams.get('qaFeed'),
|
||||
'vessels:fixture',
|
||||
);
|
||||
});
|
||||
|
||||
function fixtureWindow() {
|
||||
const rows = [{ mmsi: '990100000', lat: 51.95, lon: 4.1, name: 'QA' }];
|
||||
const clock = { nowMs: 1000 };
|
||||
const calls = [];
|
||||
const window = {
|
||||
fetch: (...args) => {
|
||||
calls.push(args);
|
||||
return Promise.resolve('passthrough');
|
||||
},
|
||||
};
|
||||
runInNewContext(`(${installSyntheticVesselFeed.toString()})(rows)`, {
|
||||
rows,
|
||||
window,
|
||||
location: {
|
||||
href: 'http://127.0.0.1:4193/',
|
||||
origin: 'http://127.0.0.1:4193',
|
||||
},
|
||||
URL,
|
||||
Response,
|
||||
Date: { now: () => clock.nowMs },
|
||||
});
|
||||
return { rows, clock, calls, window };
|
||||
}
|
||||
|
||||
test('a late vessel refresh retains the exact card fixture with fresh receipt stamps', async () => {
|
||||
const { window, rows, clock, calls } = fixtureWindow();
|
||||
const first = await (await window.fetch('/api/vessels?lat=51.95')).json();
|
||||
clock.nowMs = 5000;
|
||||
const late = await (
|
||||
await window.fetch(
|
||||
new Request('http://127.0.0.1:4193/api/vessels?lat=29.7'),
|
||||
)
|
||||
).json();
|
||||
assert.deepEqual(
|
||||
first.rows.map((row) => row.mmsi),
|
||||
['990100000'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
late.rows.map((row) => row.mmsi),
|
||||
['990100000'],
|
||||
);
|
||||
assert.equal(late.newestPositionAt, 5000);
|
||||
assert.equal(late.rows[0].last_position_epoch, 5);
|
||||
const urlInput = await (
|
||||
await window.fetch(new URL('http://127.0.0.1:4193/api/vessels'))
|
||||
).json();
|
||||
assert.deepEqual(
|
||||
urlInput.rows.map((row) => row.mmsi),
|
||||
['990100000'],
|
||||
);
|
||||
assert.equal(rows[0].last_position_epoch, undefined);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('the card fixture leaves other paths and external origins to the original fetch', async () => {
|
||||
const { window, calls } = fixtureWindow();
|
||||
assert.equal(await window.fetch('/api/vessels/track'), 'passthrough');
|
||||
assert.equal(
|
||||
await window.fetch('https://example.invalid/api/vessels'),
|
||||
'passthrough',
|
||||
);
|
||||
assert.equal(calls.length, 2);
|
||||
});
|
||||
@@ -82,6 +82,36 @@ for (const [name, files, pattern] of [
|
||||
},
|
||||
/reaches browser globals/,
|
||||
],
|
||||
[
|
||||
'W1 shared focusDeemphasis keeps native imports in render',
|
||||
{ 'src/data/focusDeemphasis.js': "import 'cesium';" },
|
||||
/cesium only in the layer's render/,
|
||||
],
|
||||
[
|
||||
'W1 shared trailRenderer keeps native imports in render',
|
||||
{ 'src/data/trailRenderer.js': "import 'cesium';" },
|
||||
/cesium only in the layer's render/,
|
||||
],
|
||||
[
|
||||
'W1 shared contactTrailRenderer keeps native imports in render',
|
||||
{ 'src/data/contactTrailRenderer.js': "import 'cesium';" },
|
||||
/cesium only in the layer's render/,
|
||||
],
|
||||
[
|
||||
'W1 shared iconOrientation keeps native imports in render',
|
||||
{ 'src/data/iconOrientation.js': "import 'cesium';" },
|
||||
/cesium only in the layer's render/,
|
||||
],
|
||||
[
|
||||
'W1 shared modelVisualAnchor keeps native imports in render',
|
||||
{ 'src/data/modelVisualAnchor.js': "import 'cesium';" },
|
||||
/cesium only in the layer's render/,
|
||||
],
|
||||
[
|
||||
'W1 shared trackingClickGesture keeps native imports in render',
|
||||
{ 'src/data/trackingClickGesture.js': "import 'cesium';" },
|
||||
/cesium only in the layer's render/,
|
||||
],
|
||||
[
|
||||
'geo module imports cesium',
|
||||
{ 'src/geo/demo.js': "import 'cesium';" },
|
||||
|
||||
@@ -19,7 +19,10 @@ import {
|
||||
photoImages,
|
||||
THUMB_HOST,
|
||||
} from '../../scripts/fixtures/street-level/mapillaryGraph.mjs';
|
||||
import { redact } from '../../scripts/qa-browserEvidence.mjs';
|
||||
import {
|
||||
redact,
|
||||
isExpectedPreviewSetupError,
|
||||
} from '../../scripts/qa-browserEvidence.mjs';
|
||||
|
||||
// The press helpers wait for paint; Node has no frames, so a tick stands in.
|
||||
globalThis.requestAnimationFrame ??= (callback) =>
|
||||
@@ -639,3 +642,41 @@ test('CI runs the gates hermetically against a production build, strict, with ev
|
||||
'a missed header press fails CI now that the rail settles',
|
||||
);
|
||||
});
|
||||
|
||||
test('preview setup API 404 is expected only with explicit preview mode', () => {
|
||||
const message = {
|
||||
type: () => 'error',
|
||||
text: () =>
|
||||
'Failed to load resource: the server responded with a status of 404 (Not Found)',
|
||||
location: () => ({ url: 'http://127.0.0.1:4195/api/setup/status' }),
|
||||
};
|
||||
const options = { preview: true, baseUrl: 'http://127.0.0.1:4195/' };
|
||||
assert.equal(isExpectedPreviewSetupError(message, options), true);
|
||||
assert.equal(
|
||||
isExpectedPreviewSetupError(message, { ...options, preview: false }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('preview mode never waives other errors, origins, paths or missing locations', () => {
|
||||
const options = { preview: true, baseUrl: 'http://127.0.0.1:4195/' };
|
||||
for (const [text, url] of [
|
||||
['Failed to load resource: 404', 'http://127.0.0.1:4195/api/flights'],
|
||||
['Failed to load resource: 404', 'https://example.com/api/setup/status'],
|
||||
['Failed to load resource: 500', 'http://127.0.0.1:4195/api/setup/status'],
|
||||
['Unhandled TypeError: 404', 'http://127.0.0.1:4195/api/setup/status'],
|
||||
['Failed to load resource: 404', undefined],
|
||||
]) {
|
||||
assert.equal(
|
||||
isExpectedPreviewSetupError(
|
||||
{
|
||||
type: () => 'error',
|
||||
text: () => text,
|
||||
location: () => ({ url }),
|
||||
},
|
||||
options,
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { watchW1Page } from '../../scripts/qa-browserEvidence.mjs';
|
||||
const baseUrl = 'http://127.0.0.1:4195/';
|
||||
const message = (type, text, url) => ({
|
||||
type: () => type,
|
||||
text: () => text,
|
||||
location: () => ({ url }),
|
||||
});
|
||||
test('W1 main and share pages aggregate console, runtime and crash errors', () => {
|
||||
const report = {},
|
||||
main = new EventEmitter(),
|
||||
shared = new EventEmitter();
|
||||
watchW1Page(main, report, { name: 'main', baseUrl });
|
||||
watchW1Page(shared, report, { name: 'share', baseUrl });
|
||||
main.emit('console', message('warning', 'ordinary warning', baseUrl));
|
||||
shared.emit(
|
||||
'console',
|
||||
message('error', 'late error', baseUrl + 'api/x?key=sample-secret'),
|
||||
);
|
||||
shared.emit('pageerror', new Error('runtime failure'));
|
||||
shared.emit('error', new Error('renderer crashed'));
|
||||
assert.deepEqual(report.runtimePages, ['main', 'share']);
|
||||
assert.equal(report.browserErrors.length, 3);
|
||||
assert.match(report.browserErrors[0], /\[share\] late error/);
|
||||
assert.ok(!JSON.stringify(report).includes('sample-secret'));
|
||||
});
|
||||
test('W1 preview exception stays exact on a share page', () => {
|
||||
const report = {},
|
||||
shared = new EventEmitter();
|
||||
watchW1Page(shared, report, { name: 'share', baseUrl, preview: true });
|
||||
const failure = (url) =>
|
||||
shared.emit(
|
||||
'console',
|
||||
message(
|
||||
'error',
|
||||
'Failed to load resource: the server responded with a status of 404',
|
||||
url,
|
||||
),
|
||||
);
|
||||
failure(baseUrl + 'api/setup/status');
|
||||
failure(baseUrl + 'api/other');
|
||||
failure('https://example.invalid/api/setup/status');
|
||||
assert.deepEqual(report.expectedPreviewErrors, ['/api/setup/status: 404']);
|
||||
assert.equal(report.browserErrors.length, 2);
|
||||
});
|
||||
test('W1 response diagnostics retain page/status/path without query credentials', () => {
|
||||
const report = {},
|
||||
shared = new EventEmitter();
|
||||
watchW1Page(shared, report, { name: 'share', baseUrl });
|
||||
shared.emit('response', {
|
||||
status: () => 503,
|
||||
url: () => baseUrl + 'api/vessels?token=sample-secret',
|
||||
});
|
||||
assert.deepEqual(report.failedResponses, [
|
||||
{ page: 'share', status: 503, resource: baseUrl + 'api/vessels' },
|
||||
]);
|
||||
assert.ok(!JSON.stringify(report).includes('sample-secret'));
|
||||
});
|
||||
Reference in new issue
Block a user