Draw and inspect the same wind forecast when switching globes

This commit is contained in:
German Khokhlov committed 2026-11-03 03:34:25 +04:00
1 parent d7b3d45135
commit 2b4955c817
11 files changed
+555 -30

No files matched your search

+2
View File
@@ -3677,6 +3677,8 @@
"src/layers/infrastructure/render/navaraStems.js",
"src/layers/weather/render/navara.js",
"src/layers/weather/render/navaraSurface.js",
"src/layers/wind/render/navara.js",
"src/layers/wind/render/flowMesh.js",
"src/layers/ghostMaps/render/navara.js",
"src/layers/shared/render/navaraGroundPicks.js",
"src/layers/cyclones/render/navara.js",
+170
View File
@@ -0,0 +1,170 @@
import puppeteer from 'puppeteer';
import sharp from 'sharp';
import assert from 'node:assert/strict';
import { blockPaidQaMaps } from './qa-map-policy.mjs';
const base = new URL(process.argv[2]);
if (
!['localhost', '127.0.0.1'].includes(base.hostname) ||
['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
base.port,
)
)
throw new Error('Supply an unreserved local keyless preview URL');
base.search = '?engine=navara&welcome=0&look=cinematic';
base.hash =
'v=2&lat=43.6500&lon=-79.3800&alt=2000000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
const b = await puppeteer.launch({
headless: true,
args: ['--use-angle=metal', '--enable-gpu'],
});
try {
const p = await b.newPage();
await blockPaidQaMaps(p);
await p.setViewport({ width: 1024, height: 768 });
const errors = [];
p.on('pageerror', (e) => {
errors.push(e.message);
console.log('error', e.message.split('?')[0]);
});
const reads = [];
await p.setRequestInterception(true);
p.on('request', (req) => {
const u = new URL(req.url());
if (!u.pathname.startsWith('/api/wind/')) return req.continue();
reads.push(u.pathname + u.search);
const model = u.searchParams.get('model'),
kind = u.searchParams.get('overlay');
const count = 36 * 19;
if (u.pathname.endsWith('/manifest'))
return req.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
model,
overlay: kind ?? 'none',
grid: { nx: 36, ny: 19, lo1: 0, la1: 90, dx: 10, dy: 10 },
gridUrl: '/api/wind/grid/' + model + '-qa.bin' + u.search,
cycle: {
validIso: '2026-11-02T12:00:00Z',
runIso: '2026-11-02T06:00:00Z',
},
...(kind
? {
scalar: {
kind,
units: kind === 'temperature' ? '°C' : 'hPa',
level:
kind === 'temperature'
? '2 m above ground'
: 'mean sea level',
},
}
: {}),
}),
});
const values = new Float32Array(count * (kind ? 3 : 2));
values.fill(8, 0, count);
values.fill(6, count, count * 2);
if (kind) values.fill(kind === 'temperature' ? 40 : 1020, count * 2);
return req.respond({
status: 200,
contentType: 'application/octet-stream',
body: Buffer.from(values.buffer),
});
});
p.on('console', (msg) => {
if (msg.type() === 'error' && /Shader|WebGLProgram/i.test(msg.text())) {
errors.push(msg.text());
console.log('GPU', msg.text().slice(0, 2500));
}
});
await p.goto(base.href);
const ready = async (kind) => {
await p.waitForFunction(
(k) =>
window.__godsEyeView?.engine.kind === k &&
!document.querySelector('#globe-engine')?.disabled &&
!document.querySelector('.engine-switch-frame') &&
document.querySelector('#loading-screen.hidden'),
{ timeout: 90000 },
kind,
);
await p.evaluate(
() => window.__godsEyeView.styleManager.initialRestorePromise,
);
};
await ready('navara');
await p.evaluate(async () => {
const { dataManager, tileset } = window.__godsEyeView;
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
throw Error('Keyless preview required');
const mod = dataManager.layers.get('wind').module;
window.qaWind = mod;
mod.setParams({ overlay: 'temperature', paused: true, units: 'm/s' });
await dataManager.setEnabled('wind', true, { origin: 'user' });
});
await p.waitForFunction(
() =>
window.qaWind.getDiagnostics().surface?.ready &&
window.qaWind.getDiagnostics().gpu?.ready,
);
await p.evaluate(() => window.qaWind.setParams({ inspect: true }));
const baseline = reads.length;
for (const kind of ['navara', 'cesium', 'navara']) {
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
await p.select('#globe-engine', kind);
await ready(kind);
}
await p.waitForFunction(
() =>
window.qaWind.getDiagnostics().gpu?.ready &&
window.qaWind.getRowControls().summary.reading?.speed === 10,
{ timeout: 90000 },
);
await new Promise((resolve) => setTimeout(resolve, 800));
const check = await p.evaluate(() => {
const { dataManager } = window.__godsEyeView;
const mod = dataManager.layers.get('wind').module;
return {
same: mod === window.qaWind,
params: mod.getParams(),
gpu: mod.getDiagnostics().gpu.pathCount,
reading: mod.getRowControls().summary.reading,
};
});
assert.equal(check.same, true);
assert.equal(check.params.paused, true);
assert.ok(check.gpu > 1000);
assert.equal(check.reading.scalarValue, '40.0 °C');
assert.equal(check.reading.from, 'SW');
const sample = await sharp(await p.screenshot())
.extract({ left: 530, top: 390, width: 24, height: 24 })
.removeAlpha()
.raw()
.toBuffer();
const [r, g, b] = [0, 1, 2].map((k) => {
let sum = 0;
for (let i = k; i < sample.length; i += 3) sum += sample[i];
return sum / (sample.length / 3);
});
console.log(kind, check.gpu, check.reading.wind, [r, g, b]);
assert.ok(
r > g * 1.35 && r > b * 1.35,
kind + ' temperature field visible',
);
assert.equal(reads.length, baseline);
}
await p.evaluate(() => window.qaWind.setParams({ paused: false }));
await p.waitForFunction(() => window.qaWind.getDiagnostics().gpu.phaseSeconds > 0.2);
await p.evaluate(() => window.qaWind.setParams({ paused: true }));
const frozen = await p.evaluate(() => window.qaWind.getDiagnostics().gpu.phaseSeconds);
await new Promise((resolve) => setTimeout(resolve, 250));
assert.equal(await p.evaluate(() => window.qaWind.getDiagnostics().gpu.phaseSeconds), frozen);
assert.deepEqual(errors, []);
console.log(
'PASS retained forecast grid, pause, temperature field and sampled wind on both engines',
);
} finally {
await b.close();
}
+5 -1
View File
@@ -279,7 +279,11 @@ export function createApplicationCatalog({
],
[
'wind',
() => createWindLayer({ feed: sources.wind, clock: weatherClock }),
() => sessionModel('wind', () => createWindLayer({
feed: sources.wind,
clock: weatherClock,
createNativeRendering: (engine) => rendererFor('wind', engine),
})),
],
...['weather-radar', 'weather-satellite', 'weather-lightning'].map(
(id) => [
+1
View File
@@ -62,6 +62,7 @@ test('the Navara mode registers migrated renderers once', async () => {
'ais-live-vessels',
'military-installations',
'military-awareness',
'wind',
'weather-radar',
'weather-satellite',
'weather-lightning',
+6
View File
@@ -59,6 +59,8 @@ const NAVARA_CREDIT_KEYS = new Set([
'eccc-aqhi',
'wfigs',
's2-ghostmaps',
'wind-gfs',
'ecmwf-ifs',
'weather-noaa',
'weather-lightning',
'weather-cyclones',
@@ -117,6 +119,10 @@ export async function registerNavaraLayerRenderers() {
return (engine, options) => createWeatherNavaraRenderer(engine, navaraViews.get(engine), options);
},
])),
wind: async () => {
const { createWindNavaraRenderer } = await import('../layers/wind/render/navara.js');
return (engine) => createWindNavaraRenderer(engine, navaraViews.get(engine));
},
'weather-cyclones': async () => {
const {createCyclonesNavaraRenderer} = await import('../layers/cyclones/render/navara.js');
return (engine,options) => createCyclonesNavaraRenderer(engine,navaraViews.get(engine).view,options);
+2 -2
View File
@@ -14,9 +14,9 @@ import { shellLift } from '../shellRendering.js';
const KEY = 'gevObservedWeather';
const owners = new WeakMap();
const products = ['Satellite', 'Radar', 'Lightning'];
const products = ['Wind', 'Satellite', 'Radar', 'Lightning'];
const slotFor = (p) =>
p === 'radar' ? 'Radar' : p === 'lightning' ? 'Lightning' : 'Satellite';
p === 'wind' ? 'Wind' : p === 'radar' ? 'Radar' : p === 'lightning' ? 'Lightning' : 'Satellite';
const emptyRect = [0, 0, -1, -1];
const rectArray = (r) => (r ? [r.west, r.south, r.east, r.north] : emptyRect);
+31 -14
View File
@@ -57,10 +57,12 @@ export function createWindLayer({
cesium = Cesium,
container,
createRendering = createWindRendering,
createNativeRendering,
} = {}) {
if (typeof feed?.getSnapshot !== 'function')
throw new TypeError('Wind requires a snapshot source');
let viewer = null;
let engine = null;
let request = null;
let enabled = false;
let rendering = null;
@@ -81,6 +83,10 @@ export function createWindLayer({
inspectionMarker?.clear();
};
const sample = (position) => {
if (!position && engine && !viewer) {
const canvas = engine.host.canvas;
position = engine.camera.pickEllipsoid({ x: canvas.clientWidth / 2, y: canvas.clientHeight / 2 });
}
reading = inspectWindAtCenter(manifest, viewer, cesium, {
position,
units,
@@ -110,9 +116,13 @@ export function createWindLayer({
icon: '🌬',
source: 'GFS / ECMWF IFS · FORECAST',
updateInterval: 3600_000,
init(nextViewer) {
init(nextViewer, options) {
layer.attachRenderer(nextViewer, options);
},
attachRenderer(nextViewer, { engine: nextEngine = null } = {}) {
viewer = nextViewer;
rendering = createRendering({
engine = nextEngine;
rendering = engine?.kind === 'navara' ? createNativeRendering(engine) : createRendering({
cesium,
container: container ?? nextViewer.container,
getViewer: () => viewer,
@@ -125,12 +135,24 @@ export function createWindLayer({
});
rendering.attach();
rendering.setOptions?.({ overlay, paused });
const target = container ?? nextViewer.container;
const target = container ?? nextViewer?.container ?? engine?.host.container;
inspectionMarker = createWindInspectionMarker({
container: target,
viewer,
cesium,
engine,
});
if (enabled && manifest && !manifest.unavailable) {
rendering.setField(manifest);
rendering.start();
}
if (sampledPosition) inspectionMarker.show(sampledPosition);
},
detachRenderer() {
inspectionMarker?.destroy();
inspectionMarker = null;
rendering?.destroy();
rendering = imageryHost = viewer = engine = null;
},
attachShellServices(services) {
imageryHost =
@@ -182,12 +204,12 @@ export function createWindLayer({
error = null;
if (sampledPosition) sample(sampledPosition);
if (snapshot.unavailable) {
rendering.stop();
rendering.clear();
rendering?.stop();
rendering?.clear();
} else {
rendering.setOptions?.({ overlay, paused });
rendering.setField(snapshot);
rendering.start();
rendering?.setOptions?.({ overlay, paused });
rendering?.setField(snapshot);
rendering?.start();
}
return true;
} catch (cause) {
@@ -438,13 +460,8 @@ export function createWindLayer({
destroy() {
layer.disable();
unsubscribeClock?.();
rendering?.destroy();
rendering = null;
imageryHost = null;
viewer = null;
layer.detachRenderer();
rowControlsListener = null;
inspectionMarker?.destroy();
inspectionMarker = null;
},
getStats() {
const diagnostic = rendering?.getDiagnostics?.();
+18 -12
View File
@@ -10,6 +10,7 @@ import { applicationPinia } from '../../ui/vueRuntime.js';
export function createWindInspectionMarker({
container,
viewer,
engine,
cesium,
store = useWindInspectionStore(applicationPinia()),
}) {
@@ -33,36 +34,40 @@ export function createWindInspectionMarker({
show(position) {
clear();
const scene = viewer?.scene;
const canvas = scene?.canvas ?? engine?.host.canvas;
const frames = scene?.postRender ?? engine?.frames.postRender;
if (
!position ||
!container?.ownerDocument ||
!scene?.postRender?.addEventListener ||
!scene.cartesianToCanvasCoordinates
!frames?.addEventListener ||
(!engine?.projector && !scene?.cartesianToCanvasCoordinates)
)
return;
present({ owner, container, visible: false, left: 0, top: 0 });
const occluder = new cesium.EllipsoidalOccluder(
const occluder = !engine && new cesium.EllipsoidalOccluder(
scene.globe?.ellipsoid || cesium.Ellipsoid.WGS84,
viewer.camera.positionWC,
);
const render = () => {
if (!shown()) return;
occluder.cameraPosition = viewer.camera.positionWC;
const point = scene.cartesianToCanvasCoordinates(position);
if (occluder) occluder.cameraPosition = viewer.camera.positionWC;
const point = engine
? engine.projector.worldToWindow(position, {})
: scene.cartesianToCanvasCoordinates(position);
const visible =
point &&
point.x >= 0 &&
point.y >= 0 &&
point.x <= scene.canvas.clientWidth &&
point.y <= scene.canvas.clientHeight &&
(scene.mode !== cesium.SceneMode.SCENE3D ||
occluder.isPointVisible(position));
point.x <= canvas.clientWidth &&
point.y <= canvas.clientHeight &&
(engine ? engine.projector.horizonVisible(position) :
scene.mode !== cesium.SceneMode.SCENE3D || occluder.isPointVisible(position));
if (!visible) {
// A hidden marker keeps its last place, as the element did.
if (marker.visible) present({ visible: false });
return;
}
const canvasRect = scene.canvas.getBoundingClientRect();
const canvasRect = canvas.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const left = point.x + canvasRect.left - containerRect.left;
const top = point.y + canvasRect.top - containerRect.top;
@@ -70,9 +75,10 @@ export function createWindInspectionMarker({
return;
present({ visible: true, left, top });
};
removeRender = scene.postRender.addEventListener(render);
removeRender = frames.addEventListener(render);
render();
scene.requestRender?.();
if (engine) engine.render.request('wind-inspection');
else scene.requestRender?.();
},
clear,
destroy: clear,
+133
View File
@@ -0,0 +1,133 @@
import {
Mesh,
ShaderMaterial,
InstancedBufferGeometry,
InstancedBufferAttribute,
Float32BufferAttribute,
Vector2,
DoubleSide,
} from 'three';
import { fromDegrees } from '../../../geo/cartesian3.js';
import { WIND_DISPLAY_HEIGHT_METERS } from '../streamlines.js';
const KEY = 'gevWindFlow';
const registered = new WeakSet();
const vertexShader = /* glsl */ `
#include <common>
#include <logdepthbuf_pars_vertex>
attribute vec3 begin, end;
attribute vec3 progress;
uniform vec2 viewport;
varying vec4 flow;
void main() {
vec3 world = mix(begin,end,position.x);
vec4 a = projectionMatrix * viewMatrix * vec4(begin,1.0);
vec4 b = projectionMatrix * viewMatrix * vec4(end,1.0);
vec2 delta = (b.xy/max(1.0,b.w)-a.xy/max(1.0,a.w))*viewport;
vec2 perp = vec2(-delta.y,delta.x)/max(0.000001,length(delta));
gl_Position = mix(a,b,position.x);
gl_Position.xy += perp * position.y * 2.4 * gl_Position.w/viewport;
flow = vec4(mix(progress.x,progress.y,position.x),progress.z,position.y,
dot(normalize(world),normalize(cameraPosition-world)));
#include <logdepthbuf_vertex>
}`;
const fragmentShader = /* glsl */ `
#include <common>
#include <logdepthbuf_pars_fragment>
uniform float seconds,heightFade,ghost;
varying vec4 flow;
void main() {
#include <logdepthbuf_fragment>
float behind=fract(seconds*0.16+flow.y-flow.x);
float tail=1.0-smoothstep(0.0,0.32,behind), tip=1.0-smoothstep(0.0,0.045,behind);
float ends=smoothstep(0.0,0.055,flow.x)*(1.0-smoothstep(0.945,1.0,flow.x));
float edge=1.0-smoothstep(0.52,1.0,abs(flow.z));
float alpha=(ghost+0.64*tail+0.16*tip)*ends*edge*smoothstep(0.0,0.065,flow.w)*heightFade;
if(alpha<0.001) discard;
gl_FragColor=vec4(mix(vec3(0.50,0.79,0.90),vec3(0.91,0.99,1.0),tip),alpha);
}`;
export function createWindFlowMesh({ view, MeshDesc }, paths) {
if (!registered.has(view)) {
view.registerMesh(
KEY,
class extends MeshDesc {
constructor(host, ctx, config) {
super(host, ctx, config);
this.flow = config[KEY];
}
getPassKey() {
return 'transparent';
}
createMesh() {
return this.flow;
}
onDestroy() {
this.flow.geometry.dispose();
this.flow.material.dispose();
super.onDestroy();
}
},
);
registered.add(view);
}
const segments = paths.reduce((sum, p) => sum + p.coordinates.length - 1, 0);
const begin = new Float32Array(segments * 3),
end = new Float32Array(segments * 3),
progress = new Float32Array(segments * 3);
let i = 0;
for (const path of paths) {
let previous = null;
const n = path.coordinates.length - 1;
for (let j = 0; j <= n; j++) {
const [lon, lat] = path.coordinates[j];
const next = fromDegrees(lon, lat, WIND_DISPLAY_HEIGHT_METERS);
if (previous) {
begin.set([previous.x, previous.y, previous.z], i);
end.set([next.x, next.y, next.z], i);
progress.set([(j - 1) / n, j / n, path.phase], i);
i += 3;
}
previous = next;
}
}
const geometry = new InstancedBufferGeometry();
geometry.setAttribute(
'position',
new Float32BufferAttribute([0, -1, 0, 0, 1, 0, 1, -1, 0, 1, 1, 0], 3),
);
geometry.setIndex([0, 1, 2, 2, 1, 3]);
for (const [key, array] of Object.entries({ begin, end, progress }))
geometry.setAttribute(key, new InstancedBufferAttribute(array, 3));
geometry.instanceCount = segments;
const uniforms = {
seconds: { value: 0 },
heightFade: { value: 0 },
ghost: { value: 0.25 },
viewport: { value: new Vector2(1, 1) },
};
const material = new ShaderMaterial({
uniforms,
vertexShader,
fragmentShader,
transparent: true,
side: DoubleSide,
depthWrite: false,
toneMapped: false,
});
material.forceSinglePass = true;
const mesh = new Mesh(geometry, material);
mesh.frustumCulled = false;
const holder = view.addMesh({ [KEY]: mesh });
return {
update({ time, fade, still, width, height }) {
mesh.visible = fade > 0;
uniforms.seconds.value = time;
uniforms.heightFade.value = fade;
uniforms.ghost.value = still ? 0.34 : 0.25;
uniforms.viewport.value.set(Math.max(1, width), Math.max(1, height));
},
destroy: () => holder.delete(),
segments,
};
}
+186
View File
@@ -0,0 +1,186 @@
import {
bakeWindStreamlines,
WIND_NARROW_PATH_LIMIT,
WIND_PATH_LIMIT,
} from '../streamlines.js';
import { createFieldRaster } from '../fields.js';
import { fieldUnavailableText, sameWind } from '../rendering.js';
import { createWeatherSurface } from '../../weather/render/navaraSurface.js';
import { WEATHER_SHELL_HEIGHTS } from '../../weather/shellRendering.js';
import { createWindFlowMesh } from './flowMesh.js';
const world = {
west: -Math.PI,
south: -Math.PI / 2,
east: Math.PI,
north: Math.PI / 2,
};
const clamp = (n) => Math.max(0, Math.min(1, n));
export function createWindNavaraRenderer(engine, native) {
const canvas = engine.host.canvas,
media = matchMedia('(prefers-reduced-motion: reduce)');
let snapshot,
field,
mesh,
surface,
removeFrame,
lastAlpha,
lastTime,
seconds = 0,
paths = 0,
fade = 0;
let running = false,
paused = false,
overlay = 'none',
error = null,
budget = 0;
const wake = () => engine.render.request('wind');
const build = () => {
mesh?.destroy();
mesh = null;
budget =
canvas.clientWidth < 700 ? WIND_NARROW_PATH_LIMIT : WIND_PATH_LIMIT;
const baked = bakeWindStreamlines(field, { count: budget });
paths = baked.length;
if (paths) mesh = createWindFlowMesh(native, baked);
};
const paint = () => {
surface?.destroy();
surface = null;
lastAlpha = null;
error = null;
if (!snapshot || overlay === 'none') return;
const raster = createFieldRaster(snapshot, overlay);
if (!raster) {
error = fieldUnavailableText(overlay);
return;
}
const texture = document.createElement('canvas');
texture.width = raster.width;
texture.height = raster.height;
texture
.getContext('2d')
.putImageData(
new ImageData(raster.rgba, raster.width, raster.height),
0,
0,
);
surface = createWeatherSurface(engine, native, 'wind', {
rectangle: world,
height: WEATHER_SHELL_HEIGHTS.wind,
onSettled: wake,
});
surface.setImage(texture);
wake();
};
const frame = () => {
if (!running || document.hidden) {
lastTime = null;
return;
}
const h = engine.camera.pose().cartographic.height;
if (
field &&
budget !==
(canvas.clientWidth < 700 ? WIND_NARROW_PATH_LIMIT : WIND_PATH_LIMIT)
)
build();
fade = clamp((h - 15000) / 45000);
const still = paused || media.matches;
const now = performance.now();
if (!still && fade && lastTime != null)
seconds += Math.min(0.1, (now - lastTime) / 1000);
lastTime = !still && fade ? now : null;
mesh?.update({
time: seconds % 10000,
fade,
still,
width: canvas.clientWidth,
height: canvas.clientHeight,
});
if (surface) {
let alpha =
(overlay === 'temperature' ? 1 : 0.85) *
clamp(Math.log(Math.max(1, h) / 200000) / Math.log(6));
if (engine.maps.photorealActive()) alpha = Math.round(alpha * 10) / 10;
if (
lastAlpha == null ||
Math.abs(alpha - lastAlpha) > 0.005 ||
(alpha === 0 && lastAlpha !== 0)
) {
lastAlpha = alpha;
surface.setAlpha(alpha);
surface.setShow(alpha > 0);
}
}
if (mesh && !still && fade) wake();
};
const reset = () => {
mesh?.destroy();
mesh = null;
surface?.destroy();
surface = null;
field = snapshot = null;
paths = 0;
error = null;
lastAlpha = lastTime = null;
};
const preferenceChanged = () => {
lastTime = null;
wake();
};
return {
attach() {
removeFrame = engine.frames.preRender.addEventListener(frame);
media.addEventListener('change', preferenceChanged);
document.addEventListener('visibilitychange', preferenceChanged);
},
setField(next) {
const nextField = { ...next.grid, u: next.u, v: next.v };
const changed = !sameWind(snapshot, next, field, nextField);
snapshot = next;
field = nextField;
if (changed) build();
paint();
wake();
},
setOptions(next) {
paused = next.paused;
if (overlay !== next.overlay) {
overlay = next.overlay;
paint();
}
wake();
},
start() {
running = true;
wake();
},
stop() {
running = false;
lastTime = null;
},
clear: reset,
destroy() {
running = false;
removeFrame?.();
media.removeEventListener('change', preferenceChanged);
document.removeEventListener('visibilitychange', preferenceChanged);
reset();
},
getParticleCount: () => paths,
getDiagnostics: () => ({
renderMode: 'gpu-streamlines',
reducedMotion: media.matches,
imageryError: error,
gpu: {
pathCount: paths,
phaseSeconds: seconds,
ready: Boolean(mesh),
heightFade: fade,
vertexCount: (mesh?.segments ?? 0) * 4,
},
surface: surface?.getDiagnostics() ?? null,
}),
};
}
+1 -1
View File
@@ -37,7 +37,7 @@ const WIND_FIELD_LOG_RANGE = Math.log(
// Snapshots own immutable decoded arrays. Compare their contents, not grid URLs
// (which include the scalar overlay) or only cycle IDs (which can be revised).
// This bounded scan runs on acquisition, never in the animation loop.
function sameWind(previous, next, previousField, nextField) {
export function sameWind(previous, next, previousField, nextField) {
if (!previous || !next || !previousField || !nextField) return false;
for (const key of ['model', 'level', 'units'])
if (previous[key] !== next[key]) return false;