mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 07:12:21 +00:00
fix(vessels, transit, rail): fleet models keep their cards and detection, stand on the floor, clip ships at the waterline and follow NVG/FLIR
- a vehicle drawn as a model keeps its selected card and stays in the detection overlay - transit and rail models drop the 1.5 m floor lift their icons carry - vessel models are clipped below the waterline - models switch to an unlit white look under NVG/FLIR and back - unknown transit modes keep their icon - aircraft model specs are cached per set; no key-array copies per frame
This commit is contained in:
1 parent
058ed0ffeb
commit
a44c42eb94
11 files changed
+342
-89
No files matched your search
@@ -46,6 +46,11 @@ export function createFleetModel(
|
||||
|
||||
const tintShaders = new Map();
|
||||
|
||||
/** Models under the NVG/FLIR styles render unlit, as aircraft models do. */
|
||||
const IR_UNLIT_SHADER = new Cesium.CustomShader({
|
||||
lightingModel: Cesium.LightingModel.UNLIT,
|
||||
});
|
||||
|
||||
/**
|
||||
* The custom shader that paints a route colour where a transit model's
|
||||
* `_TINT` vertex attribute marks it: drawn colour = vertex colour ×
|
||||
@@ -131,23 +136,34 @@ const scratchScale = new Cesium.Cartesian3();
|
||||
* `modelOwnsVisual` back from each item and hides that icon.
|
||||
*
|
||||
* Each frame the layer passes its vehicles as items:
|
||||
* `{position, headingDeg, spec, tint, pickId, show}`, `position` the point
|
||||
* the model's origin stands on (the waterline, the road or rail top; the
|
||||
* spec's `elevationM` is added here), `tint` a Cesium.Color for a tinted
|
||||
* model's route colour (or null), `show` false to keep it hidden.
|
||||
* `{position, headingDeg, spec, tint, pickId, show, groundLiftM}`,
|
||||
* `position` the point the model's origin stands on (the waterline, the road
|
||||
* or rail top; the spec's `elevationM` is added here), `groundLiftM` any lift
|
||||
* the layer's icon carries above that point (taken off again), `tint` a
|
||||
* Cesium.Color for a tinted model's route colour (or null), `show` false to
|
||||
* keep it hidden.
|
||||
*
|
||||
* Under the luminance-mapped NVG/FLIR styles (`irBoost`) models render
|
||||
* unlit flat white, as aircraft models do. With `waterline`, each model is
|
||||
* clipped at its origin's horizontal plane and only the part above it is
|
||||
* drawn: a ship's hull below the waterline would otherwise show through a
|
||||
* plain globe, which Cesium does not depth-test models against.
|
||||
* @param {object} options
|
||||
* @param {object} options.viewer Cesium viewer.
|
||||
* @param {() => object|null} [options.display] `{models3d, models3dMode}`.
|
||||
* @param {() => object|null} [options.display] `{models3d, models3dMode,
|
||||
* irBoost}`.
|
||||
* @param {object} options.policy `{altitudeCeilingM, minimumPixelSize,
|
||||
* cap, capAll, addM, addAllM, keepM, keepAllM, intervalMs}`.
|
||||
* @param {(url: string) => string} [options.resolveAsset]
|
||||
* @param {(spec: object, options: object) => Promise<object>} [options.createModel]
|
||||
* @param {boolean} [options.waterline] Clip each model at its waterline.
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createFleetModelLayer({
|
||||
viewer,
|
||||
display = () => null,
|
||||
policy,
|
||||
waterline = false,
|
||||
resolveAsset = (url) => url,
|
||||
createModel = (spec, options) =>
|
||||
createFleetModel(spec, options, { resolveAsset }),
|
||||
@@ -163,6 +179,8 @@ export function createFleetModelLayer({
|
||||
let lastEligibilityAt = -Infinity;
|
||||
let eligible = new Set();
|
||||
let settling = 0;
|
||||
/** Whether the models drawn now were built for the NVG/FLIR styles. */
|
||||
let irBoost = false;
|
||||
const scratchHpr = new Cesium.HeadingPitchRoll();
|
||||
const scratchSphere = new Cesium.BoundingSphere(Cesium.Cartesian3.ZERO, 1);
|
||||
const scratchCarto = new Cesium.Cartographic();
|
||||
@@ -177,7 +195,7 @@ export function createFleetModelLayer({
|
||||
}
|
||||
|
||||
function releaseAll() {
|
||||
for (const id of [...models.keys()]) release(id);
|
||||
for (const id of models.keys()) release(id);
|
||||
pending.clear();
|
||||
generation += 1;
|
||||
}
|
||||
@@ -193,11 +211,23 @@ export function createFleetModelLayer({
|
||||
}
|
||||
|
||||
function shaderFor(item) {
|
||||
if (irBoost) return IR_UNLIT_SHADER;
|
||||
return item.spec.tinted && item.tint
|
||||
? fleetTintShader(item.tint)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** The clipping that keeps a model's part above its waterline. */
|
||||
function waterlineClipping() {
|
||||
return new Cesium.ClippingPlaneCollection({
|
||||
// The planes sit in the model matrix's frame: its origin is the
|
||||
// waterline and its Z axis the local vertical, so the plane z = 0 with
|
||||
// its normal up keeps the hull above the water.
|
||||
planes: [new Cesium.ClippingPlane(Cesium.Cartesian3.UNIT_Z, 0)],
|
||||
edgeWidth: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function ensure(id, item, cap) {
|
||||
const key = item.spec.key;
|
||||
const current = models.get(id);
|
||||
@@ -213,6 +243,14 @@ export function createFleetModelLayer({
|
||||
minimumPixelSize: policy.minimumPixelSize,
|
||||
scale: item.spec.scale,
|
||||
customShader: shaderFor(item),
|
||||
...(irBoost
|
||||
? {
|
||||
color: Cesium.Color.WHITE,
|
||||
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
||||
colorBlendAmount: 1,
|
||||
}
|
||||
: {}),
|
||||
...(waterline ? { clippingPlanes: waterlineClipping() } : {}),
|
||||
id: item.pickId ?? id,
|
||||
});
|
||||
} catch {
|
||||
@@ -232,7 +270,8 @@ export function createFleetModelLayer({
|
||||
}
|
||||
model.id = item.pickId ?? id;
|
||||
model._gevSpecKey = key;
|
||||
model._gevTint = item.spec.tinted ? item.tint : null;
|
||||
model._gevTint = item.spec.tinted && !irBoost ? item.tint : null;
|
||||
model._gevIrBoost = irBoost;
|
||||
// Admitted, not yet the visual: placement turns it on.
|
||||
model.show = false;
|
||||
collection.add(model);
|
||||
@@ -272,14 +311,14 @@ export function createFleetModelLayer({
|
||||
addDistSq: addM * addM,
|
||||
isModeled: (id) => models.has(id),
|
||||
});
|
||||
for (const id of [...models.keys(), ...pending.keys()])
|
||||
if (!eligible.has(id)) release(id);
|
||||
for (const id of models.keys()) if (!eligible.has(id)) release(id);
|
||||
for (const id of pending.keys()) if (!eligible.has(id)) release(id);
|
||||
for (const id of eligible) void ensure(id, items.get(id), cap);
|
||||
}
|
||||
|
||||
/** Where the model's origin stands: the item's point raised by the spec's elevation. */
|
||||
function placement(item) {
|
||||
const lift = item.spec.elevationM || 0;
|
||||
const lift = (item.spec.elevationM || 0) - (item.groundLiftM || 0);
|
||||
if (!lift) return item.position;
|
||||
const carto = Cesium.Cartographic.fromCartesian(
|
||||
item.position,
|
||||
@@ -320,6 +359,14 @@ export function createFleetModelLayer({
|
||||
for (const item of items.values()) item.modelOwnsVisual = false;
|
||||
return false;
|
||||
}
|
||||
// A model's look under NVG/FLIR is set when it is built (a custom
|
||||
// shader on a ready model does not take), so a style change rebuilds.
|
||||
const boost = prefs.irBoost === true;
|
||||
if (boost !== irBoost) {
|
||||
releaseAll();
|
||||
irBoost = boost;
|
||||
lastEligibilityAt = -Infinity;
|
||||
}
|
||||
if (nowMs - lastEligibilityAt >= policy.intervalMs) {
|
||||
lastEligibilityAt = nowMs;
|
||||
refreshEligibility(items, prefs);
|
||||
@@ -345,7 +392,7 @@ export function createFleetModelLayer({
|
||||
lastEligibilityAt = -Infinity;
|
||||
continue;
|
||||
}
|
||||
const tint = item.spec.tinted ? item.tint : null;
|
||||
const tint = item.spec.tinted && !irBoost ? item.tint : null;
|
||||
if (tint && !Cesium.Color.equals(tint, model._gevTint)) {
|
||||
model.customShader = fleetTintShader(tint);
|
||||
model._gevTint = tint;
|
||||
@@ -366,7 +413,7 @@ export function createFleetModelLayer({
|
||||
item.modelOwnsVisual = true;
|
||||
drawn = true;
|
||||
}
|
||||
for (const id of [...models.keys()]) if (!items.has(id)) release(id);
|
||||
for (const id of models.keys()) if (!items.has(id)) release(id);
|
||||
return drawn;
|
||||
},
|
||||
|
||||
|
||||
@@ -358,3 +358,130 @@ test('Vessels, Transit and Rail draw fleet models under the shared 3D toggle', (
|
||||
'Local ADS-B, Vessels, Transit and Rail follow the Flights 3D toggle',
|
||||
);
|
||||
});
|
||||
|
||||
/** A layer whose models are all ready as soon as they are created. */
|
||||
function readyLayer({ display, waterline = false } = {}) {
|
||||
const viewer = fakeViewer();
|
||||
const created = [];
|
||||
const layer = createFleetModelLayer({
|
||||
viewer,
|
||||
display: () => display,
|
||||
policy: POLICY,
|
||||
waterline,
|
||||
createModel: async (spec, options) => {
|
||||
const model = fakeModel(options);
|
||||
model.ready = true;
|
||||
created.push(model);
|
||||
return model;
|
||||
},
|
||||
});
|
||||
return { layer, created };
|
||||
}
|
||||
|
||||
test('a model stands on the floor its icon is lifted above', async () => {
|
||||
const { layer, created } = readyLayer({ display: { models3d: true } });
|
||||
const tram = transitModelSpec(set, { mode: 'tram' });
|
||||
const item = itemAt(10, tram, { pickId: 't', groundLiftM: 1.5 });
|
||||
const items = new Map([['t', item]]);
|
||||
layer.frame(items, 0);
|
||||
await flush();
|
||||
layer.frame(items, 100);
|
||||
const origin = Cesium.Matrix4.getTranslation(
|
||||
created[0].modelMatrix,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const drop =
|
||||
Cesium.Cartographic.fromCartesian(item.position).height -
|
||||
Cesium.Cartographic.fromCartesian(origin).height;
|
||||
assert.ok(Math.abs(drop - 1.5) < 1e-3, `dropped ${drop} m`);
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
test('under NVG/FLIR models are rebuilt unlit white, and rebuilt again on exit', async () => {
|
||||
const display = { models3d: true, irBoost: false };
|
||||
const { layer, created } = readyLayer({ display });
|
||||
const tram = transitModelSpec(set, { mode: 'tram' });
|
||||
const items = new Map([
|
||||
['t', itemAt(10, tram, { pickId: 't', tint: Cesium.Color.RED })],
|
||||
]);
|
||||
layer.frame(items, 0);
|
||||
await flush();
|
||||
assert.equal(created[0].customShader, fleetTintShader(Cesium.Color.RED));
|
||||
display.irBoost = true;
|
||||
layer.frame(items, 100);
|
||||
assert.ok(created[0].destroyed, 'the lit model is released');
|
||||
await flush();
|
||||
const boosted = created[1];
|
||||
assert.equal(boosted.customShader.lightingModel, Cesium.LightingModel.UNLIT);
|
||||
assert.ok(Cesium.Color.equals(boosted.color, Cesium.Color.WHITE));
|
||||
assert.equal(boosted.colorBlendAmount, 1);
|
||||
display.irBoost = false;
|
||||
layer.frame(items, 200);
|
||||
await flush();
|
||||
assert.ok(boosted.destroyed);
|
||||
assert.equal(created[2].customShader, fleetTintShader(Cesium.Color.RED));
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
test('a waterline layer clips each model at its origin, keeping the part above', async () => {
|
||||
const { layer, created } = readyLayer({
|
||||
display: { models3d: true },
|
||||
waterline: true,
|
||||
});
|
||||
const ship = vesselModelSpec(set, { type: '70', length: 200, beam: 32 });
|
||||
layer.frame(new Map([['s', itemAt(10, ship, { pickId: 's' })]]), 0);
|
||||
await flush();
|
||||
const planes = created[0].clippingPlanes;
|
||||
assert.ok(planes instanceof Cesium.ClippingPlaneCollection);
|
||||
assert.equal(planes.length, 1);
|
||||
const plane = planes.get(0);
|
||||
assert.equal(plane.distance, 0);
|
||||
assert.ok(Cesium.Cartesian3.equals(plane.normal, Cesium.Cartesian3.UNIT_Z));
|
||||
assert.equal(planes.unionClippingRegions, false);
|
||||
// Cesium keeps a point where normal · p + distance >= 0: above the waterline.
|
||||
const above = Cesium.Cartesian3.dot(
|
||||
plane.normal,
|
||||
new Cesium.Cartesian3(0, 0, 3),
|
||||
);
|
||||
const below = Cesium.Cartesian3.dot(
|
||||
plane.normal,
|
||||
new Cesium.Cartesian3(0, 0, -3),
|
||||
);
|
||||
assert.ok(above + plane.distance > 0 && below + plane.distance < 0);
|
||||
layer.destroy();
|
||||
|
||||
const plain = readyLayer({ display: { models3d: true } });
|
||||
plain.layer.frame(
|
||||
new Map([['t', itemAt(10, transitModelSpec(set, { mode: 'tram' }))]]),
|
||||
0,
|
||||
);
|
||||
await flush();
|
||||
assert.equal(plain.created[0].clippingPlanes, undefined, 'others unclipped');
|
||||
plain.layer.destroy();
|
||||
});
|
||||
|
||||
test('a vehicle drawn as its model keeps its card and its detection entry', () => {
|
||||
const read = (file) =>
|
||||
fs.readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8');
|
||||
assert.match(
|
||||
read('src/layers/transit/selection.js'),
|
||||
/marker\?\.show !== false \|\| entry\.modelOwnsVisual === true/,
|
||||
);
|
||||
assert.match(
|
||||
read('src/layers/rail/selection.js'),
|
||||
/entry\.marker\?\.show !== false \|\| entry\.modelOwnsVisual === true/,
|
||||
);
|
||||
assert.match(
|
||||
read('src/layers/transit/queries.js'),
|
||||
/marker\.show === false && entry\.modelOwnsVisual !== true/,
|
||||
);
|
||||
assert.match(
|
||||
read('src/layers/vessels/queries.js'),
|
||||
/!visual\.billboard\.show &&\s*!visual\.modelOwnsVisual/,
|
||||
);
|
||||
assert.match(
|
||||
read('src/layers/transit/rendering.js'),
|
||||
/entry\.mode === 'unknown'/,
|
||||
'a vehicle of unknown mode keeps its icon',
|
||||
);
|
||||
});
|
||||
@@ -288,3 +288,11 @@ test('civilian and military globe-view aircraft retain the established 3.0 near
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a layer asking every tick gets one cached spec per type and category', async () => {
|
||||
const { aircraftLayerModelSpec } = await import('../layers/flights/modelSpec.js');
|
||||
const a = aircraftLayerModelSpec({ typeCode: 'A320' }, set);
|
||||
assert.equal(aircraftLayerModelSpec({ typeCode: 'A320' }, set), a);
|
||||
assert.notEqual(aircraftLayerModelSpec({ typeCode: 'B744' }, set), a);
|
||||
assert.ok(Object.isFrozen(a));
|
||||
});
|
||||
@@ -37,17 +37,29 @@ export const UNLOADED_AIRCRAFT_MODEL_SPEC = Object.freeze({
|
||||
*/
|
||||
export function aircraftLayerModelSpec(aircraft, set = fleetModelSet()) {
|
||||
if (!set) return UNLOADED_AIRCRAFT_MODEL_SPEC;
|
||||
const spec = aircraftModelSpec(set, {
|
||||
typeCode: aircraft?.typeCode ?? aircraft?.type ?? null,
|
||||
category: aircraft?.category ?? null,
|
||||
});
|
||||
return Object.freeze({
|
||||
...spec,
|
||||
bellyM: spec.clearanceM,
|
||||
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
|
||||
});
|
||||
// Layers ask on every tick; each type and category is resolved once per set.
|
||||
let cache = specCaches.get(set);
|
||||
if (!cache) specCaches.set(set, (cache = new Map()));
|
||||
const key = aircraftModelSpecCacheKey(aircraft);
|
||||
let layerSpec = cache.get(key);
|
||||
if (!layerSpec) {
|
||||
const spec = aircraftModelSpec(set, {
|
||||
typeCode: aircraft?.typeCode ?? aircraft?.type ?? null,
|
||||
category: aircraft?.category ?? null,
|
||||
});
|
||||
layerSpec = Object.freeze({
|
||||
...spec,
|
||||
bellyM: spec.clearanceM,
|
||||
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
|
||||
});
|
||||
cache.set(key, layerSpec);
|
||||
}
|
||||
return layerSpec;
|
||||
}
|
||||
|
||||
/** @type {WeakMap<object, Map<string, object>>} set -> cache key -> spec */
|
||||
const specCaches = new WeakMap();
|
||||
|
||||
/**
|
||||
* Cache key of an aircraft's model spec: what selects its model.
|
||||
* @param {object|null|undefined} aircraft
|
||||
|
||||
@@ -696,7 +696,9 @@ export function createRendering({ state, services, parts }) {
|
||||
// aircraft layers' icon-to-model rules (services.fleet). The fleet set
|
||||
// loads once, up front, when the layer starts.
|
||||
let fleetModels = null;
|
||||
/** Items drawn last frame, and the map the next frame's are gathered into. */
|
||||
let modelItems = new Map();
|
||||
let spareItems = new Map();
|
||||
let modelCandidatesAtMs = -Infinity;
|
||||
|
||||
/** Load the fleet set and create the layer's models (on enable). */
|
||||
@@ -724,7 +726,13 @@ export function createRendering({ state, services, parts }) {
|
||||
function modelItem(entry, set) {
|
||||
let item = modelItems.get(entry.key);
|
||||
if (!item || item.entry !== entry)
|
||||
item = { entry, pickId: entry.key, modelOwnsVisual: false };
|
||||
item = {
|
||||
entry,
|
||||
pickId: entry.key,
|
||||
modelOwnsVisual: false,
|
||||
// Markers are drawn this far above the track; a model stands on it.
|
||||
groundLiftM: GROUND_FLOOR_LIFT_M,
|
||||
};
|
||||
const specKey = `${entry.category}|${entry.railMode}`;
|
||||
if (item.specKey !== specKey) {
|
||||
item.specKey = specKey;
|
||||
@@ -744,15 +752,16 @@ export function createRendering({ state, services, parts }) {
|
||||
if (!fleetModels) return;
|
||||
if (!fleetModels.active() || state._trains.size === 0) {
|
||||
if (modelItems.size) {
|
||||
fleetModels.frame(new Map(), performance.now());
|
||||
for (const item of modelItems.values()) handBack(item.entry);
|
||||
modelItems = new Map();
|
||||
modelItems.clear();
|
||||
fleetModels.frame(modelItems, performance.now());
|
||||
}
|
||||
return;
|
||||
}
|
||||
const set = fleetModelSet();
|
||||
const now = performance.now();
|
||||
const next = new Map();
|
||||
const next = spareItems;
|
||||
next.clear();
|
||||
if (now - modelCandidatesAtMs >= MODEL_CANDIDATES_MS) {
|
||||
modelCandidatesAtMs = now;
|
||||
const camera = state._viewer?.camera;
|
||||
@@ -781,6 +790,7 @@ export function createRendering({ state, services, parts }) {
|
||||
}
|
||||
for (const [key, item] of modelItems)
|
||||
if (next.get(key) !== item) handBack(item.entry);
|
||||
spareItems = modelItems;
|
||||
modelItems = next;
|
||||
fleetModels.frame(modelItems, now);
|
||||
for (const item of modelItems.values()) {
|
||||
@@ -796,7 +806,8 @@ export function createRendering({ state, services, parts }) {
|
||||
/** Drop every model (layer disabled); `destroy` also removes the collection. */
|
||||
function stopModels(destroy = false) {
|
||||
for (const item of modelItems.values()) item.entry.modelOwnsVisual = false;
|
||||
modelItems = new Map();
|
||||
modelItems.clear();
|
||||
spareItems.clear();
|
||||
modelCandidatesAtMs = -Infinity;
|
||||
if (destroy) {
|
||||
fleetModels?.destroy();
|
||||
|
||||
@@ -19,10 +19,15 @@ import { t } from '../../i18n/index.js';
|
||||
export function createSelection({ state, services, parts }) {
|
||||
const { governorRequestRender } = services.render;
|
||||
|
||||
/** The card's anchor: the marker's current position, read on every paint. */
|
||||
/**
|
||||
* The card's anchor: the marker's current position, read on every paint,
|
||||
* whether the train is drawn as its marker or as its 3D model.
|
||||
*/
|
||||
function anchorFor(entry) {
|
||||
return () => {
|
||||
const p = entry.marker?.show !== false ? entry.marker?.position : null;
|
||||
const drawn =
|
||||
entry.marker?.show !== false || entry.modelOwnsVisual === true;
|
||||
const p = drawn ? entry.marker?.position : null;
|
||||
return p && Number.isFinite(p.x) && Number.isFinite(p.y) ? p : null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,7 +129,9 @@ export function createQueries({ state, parts }) {
|
||||
const visible = [];
|
||||
for (const entry of state._visible) {
|
||||
const marker = entry.marker;
|
||||
if (!marker?.position || marker.show === false) continue;
|
||||
// A vehicle drawn as its 3D model is present though its marker is hidden.
|
||||
if (!marker?.position) continue;
|
||||
if (marker.show === false && entry.modelOwnsVisual !== true) continue;
|
||||
visible.push(entry);
|
||||
}
|
||||
let chosen = visible;
|
||||
|
||||
@@ -468,7 +468,11 @@ export function createRendering({ state, services, parts }) {
|
||||
// icon-to-model rules (services.fleet). The fleet set loads once, up front,
|
||||
// when the layer starts.
|
||||
let fleetModels = null;
|
||||
/** Items drawn last frame, and the map the next frame's are gathered into. */
|
||||
let modelItems = new Map();
|
||||
let spareItems = new Map();
|
||||
/** The lift icons are drawn with above the floor; a model stands on the floor. */
|
||||
const groundLiftM = services.ground?.GROUND_FLOOR_LIFT_M || 0;
|
||||
|
||||
/** Load the fleet set and create the layer's models (on enable). */
|
||||
function startModels() {
|
||||
@@ -500,19 +504,27 @@ export function createRendering({ state, services, parts }) {
|
||||
if (!fleetModels) return;
|
||||
if (!fleetModels.active() || state._vehicles.size === 0) {
|
||||
if (modelItems.size) {
|
||||
fleetModels.frame(new Map(), performance.now());
|
||||
for (const item of modelItems.values()) handBack(item.entry);
|
||||
modelItems = new Map();
|
||||
modelItems.clear();
|
||||
fleetModels.frame(modelItems, performance.now());
|
||||
}
|
||||
return;
|
||||
}
|
||||
const set = fleetModelSet();
|
||||
const next = new Map();
|
||||
const next = spareItems;
|
||||
next.clear();
|
||||
for (const entry of state._visible) {
|
||||
if (!entry.marker || !entry.hasRendered) continue;
|
||||
// A vehicle of no known mode keeps its icon: no model says what it is.
|
||||
if (!entry.marker || !entry.hasRendered || entry.mode === 'unknown')
|
||||
continue;
|
||||
let item = modelItems.get(entry.key);
|
||||
if (!item || item.entry !== entry) {
|
||||
item = { entry, pickId: entry.key, modelOwnsVisual: false };
|
||||
item = {
|
||||
entry,
|
||||
pickId: entry.key,
|
||||
modelOwnsVisual: false,
|
||||
groundLiftM,
|
||||
};
|
||||
}
|
||||
if (item.mode !== entry.mode) {
|
||||
item.mode = entry.mode;
|
||||
@@ -527,6 +539,7 @@ export function createRendering({ state, services, parts }) {
|
||||
}
|
||||
for (const [key, item] of modelItems)
|
||||
if (next.get(key) !== item) handBack(item.entry);
|
||||
spareItems = modelItems;
|
||||
modelItems = next;
|
||||
fleetModels.frame(modelItems, performance.now());
|
||||
for (const item of modelItems.values()) {
|
||||
@@ -542,7 +555,8 @@ export function createRendering({ state, services, parts }) {
|
||||
/** Drop every model (layer disabled); `destroy` also removes the collection. */
|
||||
function stopModels(destroy = false) {
|
||||
for (const item of modelItems.values()) item.entry.modelOwnsVisual = false;
|
||||
modelItems = new Map();
|
||||
modelItems.clear();
|
||||
spareItems.clear();
|
||||
if (destroy) {
|
||||
fleetModels?.destroy();
|
||||
fleetModels = null;
|
||||
|
||||
@@ -24,14 +24,16 @@ export function createSelection({ state, services, parts }) {
|
||||
* (the tracked-aircraft readout uses the same), so the anchor moves with the
|
||||
* sprite at frame rate, through poll arrivals, settling, a late floor and a
|
||||
* reset alike, with no per-frame work here. Null when there is nothing to
|
||||
* anchor to: a hidden or removed marker draws no card.
|
||||
* anchor to: a hidden or removed marker draws no card, unless the vehicle's
|
||||
* 3D model is drawing it in the marker's place.
|
||||
* @param {object} entry
|
||||
* @returns {() => Cesium.Cartesian3|null}
|
||||
*/
|
||||
function anchorFor(entry) {
|
||||
return () => {
|
||||
const marker = entry.marker;
|
||||
const p = marker?.show !== false ? marker?.position : null;
|
||||
const drawn = marker?.show !== false || entry.modelOwnsVisual === true;
|
||||
const p = drawn ? marker?.position : null;
|
||||
return p &&
|
||||
Number.isFinite(p.x) &&
|
||||
Number.isFinite(p.y) &&
|
||||
|
||||
@@ -664,7 +664,13 @@ export function createQueries({
|
||||
if ((idx - start) % stride !== 0) continue;
|
||||
const record = records[idx];
|
||||
const visual = components.rendering.getVisual(record);
|
||||
if (visual.billboard && !visual.billboard.show) continue;
|
||||
// A vessel drawn as its model is present though its chevron is hidden.
|
||||
if (
|
||||
visual.billboard &&
|
||||
!visual.billboard.show &&
|
||||
!visual.modelOwnsVisual
|
||||
)
|
||||
continue;
|
||||
const position = visual.billboard?.position || visual.position;
|
||||
if (!position) continue;
|
||||
result.push({
|
||||
|
||||
@@ -224,8 +224,11 @@ export function createRendering({
|
||||
// on the aircraft layers' icon-to-model rules (services.fleet). The fleet
|
||||
// set loads once, up front, when the layer starts.
|
||||
let fleetModels = null;
|
||||
/** Items drawn last frame, and the map the next frame's are gathered into. */
|
||||
let modelItems = new Map();
|
||||
let spareItems = new Map();
|
||||
let modelCandidatesAtMs = -Infinity;
|
||||
const modelOccluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84);
|
||||
|
||||
function ensureModels(viewer) {
|
||||
const fleet = services.fleet;
|
||||
@@ -238,96 +241,109 @@ export function createRendering({
|
||||
display: fleet.display,
|
||||
policy: fleet.policy,
|
||||
resolveAsset: fleet.resolveAsset,
|
||||
// A hull below the waterline would show through a plain globe.
|
||||
waterline: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a vessel's chevron is drawn: on the camera's side of the Earth,
|
||||
* not the bridge camera's own vessel, and not drawn by its model. The
|
||||
* visibility pass and the model handoff both decide by this.
|
||||
*/
|
||||
function billboardShown(record, occluder) {
|
||||
const visual = getVisual(record);
|
||||
return (
|
||||
isVisible(visual.surfacePosition, occluder) &&
|
||||
record.id !== state.cockpitSubjectId &&
|
||||
!visual.modelOwnsVisual
|
||||
);
|
||||
}
|
||||
|
||||
/** The model item for `record`, its spec rebuilt only when its type or size changes. */
|
||||
function modelItem(record, set) {
|
||||
let item = modelItems.get(record.id);
|
||||
if (!item || item.record !== record)
|
||||
item = { record, pickId: record, tint: null, modelOwnsVisual: false };
|
||||
const specKey = `${record.type}|${record.length}|${record.beam}|${record.name}`;
|
||||
if (!item) {
|
||||
item = { pickId: record, tint: null, modelOwnsVisual: false };
|
||||
modelItems.set(record.id, item);
|
||||
}
|
||||
if (item.specKey !== specKey) {
|
||||
item.specKey = specKey;
|
||||
item.spec = vesselModelSpec(set, record);
|
||||
}
|
||||
const visual = getVisual(record);
|
||||
item.record = record;
|
||||
item.position = visual.waterline;
|
||||
item.position = getVisual(record).waterline;
|
||||
item.headingDeg = vesselCourseDeg(record);
|
||||
item.show = record.id !== state.cockpitSubjectId;
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Gather the vessels within the models' keep radius of the camera. */
|
||||
function gatherModelItems(set, prefs) {
|
||||
/** Gather the vessels within the models' keep radius of the camera into `into`. */
|
||||
function gatherModelItems(set, into) {
|
||||
const camera = state.viewer?.camera;
|
||||
if (!camera?.positionWC) return;
|
||||
const policy = services.fleet.policy;
|
||||
const keepM =
|
||||
prefs?.models3dMode === 'all' ? policy.keepAllM : policy.keepM;
|
||||
const next = new Map();
|
||||
if (camera?.positionWC) {
|
||||
const limitSq = keepM * keepM * 1.44;
|
||||
for (const record of state.records.all) {
|
||||
const visual = getVisual(record);
|
||||
if (!visual.waterline) continue;
|
||||
if (
|
||||
Cesium.Cartesian3.distanceSquared(
|
||||
camera.positionWC,
|
||||
visual.waterline,
|
||||
) > limitSq
|
||||
)
|
||||
continue;
|
||||
next.set(record.id, modelItem(record, set));
|
||||
}
|
||||
services.fleet.display?.()?.models3dMode === 'all'
|
||||
? policy.keepAllM
|
||||
: policy.keepM;
|
||||
const limitSq = keepM * keepM * 1.44;
|
||||
for (const record of state.records.all) {
|
||||
const waterline = getVisual(record).waterline;
|
||||
if (!waterline) continue;
|
||||
if (
|
||||
Cesium.Cartesian3.distanceSquared(camera.positionWC, waterline) >
|
||||
limitSq
|
||||
)
|
||||
continue;
|
||||
into.set(record.id, modelItem(record, set));
|
||||
}
|
||||
for (const [id, item] of modelItems)
|
||||
if (!next.has(id)) handBackVisual(item);
|
||||
modelItems = next;
|
||||
}
|
||||
|
||||
/** Give a vessel's icon back when its model no longer draws it. */
|
||||
function handBackVisual(item) {
|
||||
if (!item.ownedBillboard) return;
|
||||
item.ownedBillboard = false;
|
||||
const billboard = getVisual(item.record).billboard;
|
||||
if (billboard)
|
||||
billboard.show =
|
||||
isVisible(getVisual(item.record).surfacePosition, makeOccluder()) &&
|
||||
item.record.id !== state.cockpitSubjectId;
|
||||
/** Give a vessel's chevron back when its model no longer draws it. */
|
||||
function handBackVisual(record, occluder) {
|
||||
const visual = getVisual(record);
|
||||
if (!visual.modelOwnsVisual) return;
|
||||
visual.modelOwnsVisual = false;
|
||||
if (visual.billboard)
|
||||
visual.billboard.show = billboardShown(record, occluder);
|
||||
}
|
||||
|
||||
function updateModels() {
|
||||
if (!fleetModels) return;
|
||||
const camera = state.viewer?.camera;
|
||||
const occluder = camera?.positionWC ? modelOccluder : null;
|
||||
if (occluder) occluder.cameraPosition = camera.positionWC;
|
||||
if (!fleetModels.active()) {
|
||||
if (modelItems.size) {
|
||||
modelItems.clear();
|
||||
fleetModels.frame(modelItems, performance.now());
|
||||
for (const item of modelItems.values()) handBackVisual(item);
|
||||
modelItems = new Map();
|
||||
for (const record of state.records.all)
|
||||
handBackVisual(record, occluder);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const set = fleetModelSet();
|
||||
const now = performance.now();
|
||||
const next = spareItems;
|
||||
next.clear();
|
||||
if (now - modelCandidatesAtMs >= MODEL_CANDIDATES_MS) {
|
||||
modelCandidatesAtMs = now;
|
||||
gatherModelItems(set, services.fleet.display?.());
|
||||
gatherModelItems(set, next);
|
||||
} else {
|
||||
for (const [id, item] of modelItems) {
|
||||
for (const [id, item] of modelItems)
|
||||
if (state.records.byId.get(id) === item.record)
|
||||
modelItem(item.record, set);
|
||||
else modelItems.delete(id);
|
||||
}
|
||||
next.set(id, modelItem(item.record, set));
|
||||
}
|
||||
for (const [id, item] of modelItems)
|
||||
if (next.get(id) !== item) handBackVisual(item.record, occluder);
|
||||
spareItems = modelItems;
|
||||
modelItems = next;
|
||||
fleetModels.frame(modelItems, now);
|
||||
for (const item of modelItems.values()) {
|
||||
const billboard = getVisual(item.record).billboard;
|
||||
const visual = getVisual(item.record);
|
||||
if (item.modelOwnsVisual) {
|
||||
item.ownedBillboard = true;
|
||||
if (billboard) billboard.show = false;
|
||||
} else handBackVisual(item);
|
||||
visual.modelOwnsVisual = true;
|
||||
if (visual.billboard) visual.billboard.show = false;
|
||||
} else handBackVisual(item.record, occluder);
|
||||
}
|
||||
if (fleetModels.settling) governorRequestRender('ais-vessels-models');
|
||||
}
|
||||
@@ -336,6 +352,7 @@ export function createRendering({
|
||||
fleetModels?.destroy();
|
||||
fleetModels = null;
|
||||
modelItems = new Map();
|
||||
spareItems = new Map();
|
||||
modelCandidatesAtMs = -Infinity;
|
||||
}
|
||||
|
||||
@@ -443,10 +460,11 @@ export function createRendering({
|
||||
const visible = isVisible(visual.surfacePosition, occluder);
|
||||
if (visual.billboard) {
|
||||
// The bridge camera sits on this sprite; showing it would fill the view.
|
||||
// The same rule as billboardShown, its horizon test already made.
|
||||
visual.billboard.show =
|
||||
visible &&
|
||||
record.id !== state.cockpitSubjectId &&
|
||||
!modelItems.get(record.id)?.ownedBillboard;
|
||||
!visual.modelOwnsVisual;
|
||||
if (visible && doRotations && scene) {
|
||||
const rot = screenProjectedRotation(
|
||||
scene,
|
||||
@@ -724,8 +742,9 @@ export function createRendering({
|
||||
fleetModels?.setVisible(show);
|
||||
if (!show && fleetModels) {
|
||||
fleetModels.clear();
|
||||
for (const item of modelItems.values()) item.ownedBillboard = false;
|
||||
modelItems = new Map();
|
||||
for (const item of modelItems.values())
|
||||
getVisual(item.record).modelOwnsVisual = false;
|
||||
modelItems.clear();
|
||||
}
|
||||
vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, show);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user