mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge remote-tracking branch 'origin/main' into fix/1282
# Conflicts: # docs/CURRENT-STATE.md
This commit is contained in:
commit
be44f523e0
325 files changed
+20500
-1424
No files matched your search
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build src/data/local_data/sky/bright_stars.json from the Yale Bright Star
|
||||
* Catalogue, 5th Revised Edition (Hoffleit & Warren 1991).
|
||||
*
|
||||
* Source: CDS VizieR catalogue V/50, https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50
|
||||
* Fetched: https://cdsarc.cds.unistra.fr/ftp/V/50/catalog.gz
|
||||
* Format: fixed-width ASCII, 197 bytes per record, byte layout in
|
||||
* https://cdsarc.cds.unistra.fr/ftp/V/50/ReadMe
|
||||
* License: public domain (Yale University Observatory / NASA ADC); CDS asks
|
||||
* for an acknowledgement, which the Data attribution popover carries.
|
||||
*
|
||||
* Transform (deterministic):
|
||||
* 1. J2000 RA/Dec (bytes 76-90), V magnitude (103-107) and B−V (110-114).
|
||||
* 2. The 14 records without a J2000 position (novae and other objects later
|
||||
* found not to be stars) are dropped.
|
||||
* 3. RA/Dec rounded to 3 decimals of a degree (3.6″ — a fraction of a pixel
|
||||
* at any field of view the globe uses), magnitudes to 2.
|
||||
* 4. Missing B−V is stored as null; the renderer gives it a neutral colour.
|
||||
* 5. Records keep catalogue (HR) order for a stable diff.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/build-bright-stars.mjs [catalog | catalog.gz]
|
||||
* With no argument it downloads the catalogue from CDS.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SOURCE_URL = 'https://cdsarc.cds.unistra.fr/ftp/V/50/catalog.gz';
|
||||
const OUT = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'src',
|
||||
'data',
|
||||
'local_data',
|
||||
'sky',
|
||||
'bright_stars.json',
|
||||
);
|
||||
|
||||
/** Slice 1-based inclusive byte columns the way the CDS ReadMe numbers them. */
|
||||
function columns(line, from, to) {
|
||||
return line.slice(from - 1, to).trim();
|
||||
}
|
||||
|
||||
/** Parse a fixed-width float; blank fields are null. */
|
||||
function parseField(text) {
|
||||
if (text === '') return null;
|
||||
const value = Number(text);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one BSC5 record. Returns null for records without a J2000 position
|
||||
* or V magnitude.
|
||||
*/
|
||||
export function parseBscRecord(line) {
|
||||
const hr = parseField(columns(line, 1, 4));
|
||||
const raH = parseField(columns(line, 76, 77));
|
||||
const raM = parseField(columns(line, 78, 79));
|
||||
const raS = parseField(columns(line, 80, 83));
|
||||
const sign = columns(line, 84, 84);
|
||||
const deD = parseField(columns(line, 85, 86));
|
||||
const deM = parseField(columns(line, 87, 88));
|
||||
const deS = parseField(columns(line, 89, 90));
|
||||
const vmag = parseField(columns(line, 103, 107));
|
||||
const bv = parseField(columns(line, 110, 114));
|
||||
if (
|
||||
hr === null ||
|
||||
raH === null ||
|
||||
raM === null ||
|
||||
raS === null ||
|
||||
deD === null ||
|
||||
deM === null ||
|
||||
deS === null ||
|
||||
vmag === null ||
|
||||
(sign !== '+' && sign !== '-')
|
||||
)
|
||||
return null;
|
||||
const raDeg = (raH + raM / 60 + raS / 3600) * 15;
|
||||
const decDeg = (sign === '-' ? -1 : 1) * (deD + deM / 60 + deS / 3600);
|
||||
return { hr, raDeg, decDeg, vmag, bv };
|
||||
}
|
||||
|
||||
const round = (value, decimals) => {
|
||||
const scale = 10 ** decimals;
|
||||
return Math.round(value * scale) / scale;
|
||||
};
|
||||
|
||||
/** Build the committed pack from the raw catalogue text. */
|
||||
export function buildStarPack(text, { fetched = null } = {}) {
|
||||
const stars = [];
|
||||
let dropped = 0;
|
||||
for (const line of text.split('\n')) {
|
||||
if (line.trim() === '') continue;
|
||||
const record = parseBscRecord(line);
|
||||
if (!record) {
|
||||
dropped += 1;
|
||||
continue;
|
||||
}
|
||||
stars.push(
|
||||
round(record.raDeg, 3),
|
||||
round(record.decDeg, 3),
|
||||
round(record.vmag, 2),
|
||||
record.bv === null ? null : round(record.bv, 2),
|
||||
);
|
||||
}
|
||||
return {
|
||||
meta: {
|
||||
source:
|
||||
'Yale Bright Star Catalogue, 5th Revised Ed. (Hoffleit & Warren 1991), CDS V/50',
|
||||
url: SOURCE_URL,
|
||||
license: 'public domain',
|
||||
fetched,
|
||||
frame: 'ICRF/J2000, epoch 2000.0',
|
||||
columns: ['raDeg', 'decDeg', 'vmag', 'bv'],
|
||||
count: stars.length / 4,
|
||||
dropped,
|
||||
},
|
||||
stars,
|
||||
};
|
||||
}
|
||||
|
||||
async function readCatalogue(file) {
|
||||
if (!file) {
|
||||
const response = await fetch(SOURCE_URL);
|
||||
if (!response.ok) throw new Error(`${SOURCE_URL}: HTTP ${response.status}`);
|
||||
return zlib
|
||||
.gunzipSync(Buffer.from(await response.arrayBuffer()))
|
||||
.toString('latin1');
|
||||
}
|
||||
const bytes = fs.readFileSync(file);
|
||||
return (file.endsWith('.gz') ? zlib.gunzipSync(bytes) : bytes).toString(
|
||||
'latin1',
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [file] = process.argv.slice(2);
|
||||
const text = await readCatalogue(file);
|
||||
const pack = buildStarPack(text, {
|
||||
fetched: new Date().toISOString().slice(0, 10),
|
||||
});
|
||||
// One star per line keeps the JSON compact and the diff readable.
|
||||
const rows = [];
|
||||
for (let i = 0; i < pack.stars.length; i += 4)
|
||||
rows.push(JSON.stringify(pack.stars.slice(i, i + 4)).slice(1, -1));
|
||||
const body = `{"meta":${JSON.stringify(pack.meta)},\n"stars":[\n${rows.join(',\n')}\n]}\n`;
|
||||
fs.mkdirSync(path.dirname(OUT), { recursive: true });
|
||||
fs.writeFileSync(OUT, body);
|
||||
console.log(
|
||||
`wrote ${pack.meta.count} stars (${pack.meta.dropped} dropped) → ${path.relative(process.cwd(), OUT)} (${body.length} bytes)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
|
||||
@@ -0,0 +1,415 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build the soft Milky Way cube map in src/data/local_data/sky/ from the ESO
|
||||
* all-sky Milky Way panorama.
|
||||
*
|
||||
* Source page: https://www.eso.org/public/images/eso0932a/
|
||||
* Fetched: https://cdn.eso.org/images/original/eso0932a.tif
|
||||
* (6000×3000 8-bit sRGB TIFF, 27.7 MB; sha256 in README.md)
|
||||
* Credit: ESO/S. Brunier
|
||||
* License: CC BY 4.0 (https://www.eso.org/public/outreach/copyright/).
|
||||
* Modified: stars removed, blurred, reprojected to ICRF cube
|
||||
* faces and tone-mapped, as below.
|
||||
* Frame: plate carrée in galactic coordinates, centred on the Galactic
|
||||
* Centre, galactic longitude increasing to the left.
|
||||
*
|
||||
* Transform (deterministic):
|
||||
* 1. Resample to WORK_WIDTH×WORK_WIDTH/2, sRGB to linear light.
|
||||
* 2. Remove stars with a morphological opening: the renderer draws
|
||||
* catalogue stars itself, sharp and in the right place.
|
||||
* 3. Paint out the planets the 2008–2010 exposures caught (PLANETS).
|
||||
* 4. Blur, round on the sphere: σ = BLUR_DEG, widening to FAINT_BLUR_DEG
|
||||
* in the faint glow so unresolved stars leave no clumps.
|
||||
* 5. Resample into six WebGL cube faces (uploaded without flipY) with the
|
||||
* ICRF axes: +X to the March equinox, +Z to the north celestial pole.
|
||||
* 6. Median sky to black with a soft knee, brightest 0.01 % to ~97 %
|
||||
* white, on luminance to keep the hue; gamma 1/2.2, dither, WebP.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/build-milky-way.mjs <eso0932a.tif>
|
||||
* Needs the sharp dev dependency.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const FACE_SIZE = 1024;
|
||||
export const BLUR_DEG = 0.4;
|
||||
// Faint glow is grain of unresolved stars and needs the wide blur; bright
|
||||
// parts keep the fine one and their dust lanes. FAINT_RANGE (fraction of
|
||||
// black-to-white) is where the two blend.
|
||||
export const FAINT_BLUR_DEG = 2;
|
||||
const FAINT_RANGE = [0.005, 0.08];
|
||||
const WORK_WIDTH = 4096;
|
||||
const OPEN_RADIUS = 3;
|
||||
const WEBP_QUALITY = 96;
|
||||
// The median sky is black: above it the glow away from the plane reads as
|
||||
// grey blotches. SOFT_KNEE eases the cut in so it draws no edges.
|
||||
const BLACK_PERCENTILE = 0.5;
|
||||
const SOFT_KNEE = 0.03;
|
||||
const WHITE_PERCENTILE = 0.9999;
|
||||
const OUT_DIR = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'src',
|
||||
'data',
|
||||
'local_data',
|
||||
'sky',
|
||||
);
|
||||
|
||||
/**
|
||||
* Bright planets in the panorama, galactic longitude/latitude in degrees and
|
||||
* the radius painted over with the surrounding sky. By position both are
|
||||
* Jupiter, in Sagittarius (2008) and Aquarius (2010); no catalogue star lies
|
||||
* under either.
|
||||
*/
|
||||
export const PLANETS = Object.freeze([
|
||||
{ l: 13.4, b: -16.6, radiusDeg: 2.5 },
|
||||
{ l: 69.0, b: -58.4, radiusDeg: 2.5 },
|
||||
]);
|
||||
|
||||
/** Cube faces in WebGL upload order. */
|
||||
export const CUBE_FACES = Object.freeze([
|
||||
'positiveX',
|
||||
'negativeX',
|
||||
'positiveY',
|
||||
'negativeY',
|
||||
'positiveZ',
|
||||
'negativeZ',
|
||||
]);
|
||||
export const FACE_FILE_SUFFIX = Object.freeze({
|
||||
positiveX: 'px',
|
||||
negativeX: 'nx',
|
||||
positiveY: 'py',
|
||||
negativeY: 'ny',
|
||||
positiveZ: 'pz',
|
||||
negativeZ: 'nz',
|
||||
});
|
||||
|
||||
/**
|
||||
* ICRF (J2000 equatorial) → galactic rotation, rows = galactic axes
|
||||
* (Hipparcos catalogue, ESA SP-1200 vol. 1 §1.5.3).
|
||||
*/
|
||||
export const ICRF_TO_GALACTIC = Object.freeze([
|
||||
[-0.0548755604162154, -0.873437090234885, -0.4838350155487132],
|
||||
[0.4941094278755837, -0.4448296299600112, 0.7469822444972189],
|
||||
[-0.867666149019004, -0.1980763734312015, 0.4559837761750669],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Direction (not normalized) that the WebGL cube-map lookup sends to texel
|
||||
* coordinate (s, t) ∈ [0, 1]² of `face`, t = 0 being the first image row.
|
||||
* Inverse of the OpenGL ES 3.0 §3.8.10 major-axis table.
|
||||
*/
|
||||
export function cubeFaceDirection(face, s, t) {
|
||||
const sc = 2 * s - 1;
|
||||
const tc = 2 * t - 1;
|
||||
switch (face) {
|
||||
case 'positiveX':
|
||||
return [1, -tc, -sc];
|
||||
case 'negativeX':
|
||||
return [-1, -tc, sc];
|
||||
case 'positiveY':
|
||||
return [sc, 1, tc];
|
||||
case 'negativeY':
|
||||
return [sc, -1, -tc];
|
||||
case 'positiveZ':
|
||||
return [sc, -tc, 1];
|
||||
case 'negativeZ':
|
||||
return [-sc, -tc, -1];
|
||||
default:
|
||||
throw new RangeError(`Unknown cube face ${face}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Rotate an ICRF direction into galactic coordinates. */
|
||||
export function icrfToGalactic([x, y, z]) {
|
||||
return ICRF_TO_GALACTIC.map((row) => row[0] * x + row[1] * y + row[2] * z);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a direction to (u, v) ∈ [0, 1)² of a plate carrée centred on
|
||||
* longitude 0 with longitude increasing to the left and v = 0 at +90°.
|
||||
*/
|
||||
export function directionToPlateCarree(x, y, z) {
|
||||
const longitude = Math.atan2(y, x);
|
||||
const latitude = Math.atan2(z, Math.hypot(x, y));
|
||||
let u = 0.5 - longitude / (2 * Math.PI);
|
||||
u -= Math.floor(u);
|
||||
return [u, 0.5 - latitude / Math.PI];
|
||||
}
|
||||
|
||||
/** Box widths whose three passes approximate a Gaussian of `sigma` (Kovesi). */
|
||||
function boxRadii(sigma) {
|
||||
const ideal = Math.sqrt((12 * sigma * sigma) / 3 + 1);
|
||||
let lower = Math.floor(ideal);
|
||||
if (lower % 2 === 0) lower -= 1;
|
||||
const upper = lower + 2;
|
||||
const m = Math.round(
|
||||
(12 * sigma * sigma - 3 * lower * lower - 12 * lower - 9) /
|
||||
(-4 * lower - 4),
|
||||
);
|
||||
return [0, 1, 2].map((i) => ((i < m ? lower : upper) - 1) / 2);
|
||||
}
|
||||
|
||||
/** Index along a line, wrapped or clamped at the ends. */
|
||||
function lineIndex(i, length, wrap) {
|
||||
if (wrap) return ((i % length) + length) % length;
|
||||
return i < 0 ? 0 : i >= length ? length - 1 : i;
|
||||
}
|
||||
|
||||
/** Running-sum box blur of one strided line. */
|
||||
function boxLine(src, dst, offset, stride, length, radius, wrap) {
|
||||
if (radius < 1) {
|
||||
for (let i = 0; i < length; i++)
|
||||
dst[offset + i * stride] = src[offset + i * stride];
|
||||
return;
|
||||
}
|
||||
const r = wrap ? Math.min(radius, Math.floor((length - 1) / 2)) : radius;
|
||||
const at = (i) => src[offset + lineIndex(i, length, wrap) * stride];
|
||||
let sum = 0;
|
||||
for (let i = -r; i <= r; i++) sum += at(i);
|
||||
const scale = 1 / (2 * r + 1);
|
||||
for (let i = 0; i < length; i++) {
|
||||
dst[offset + i * stride] = sum * scale;
|
||||
sum += at(i + r + 1) - at(i - r);
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimum or maximum over a (2r+1) window of one strided line. */
|
||||
function extremumLine(src, dst, offset, stride, length, radius, wrap, pick) {
|
||||
for (let i = 0; i < length; i++) {
|
||||
let value = src[offset + lineIndex(i - radius, length, wrap) * stride];
|
||||
for (let k = -radius + 1; k <= radius; k++)
|
||||
value = pick(
|
||||
value,
|
||||
src[offset + lineIndex(i + k, length, wrap) * stride],
|
||||
);
|
||||
dst[offset + i * stride] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Separable minimum or maximum filter over a plate-carrée plane, in place. */
|
||||
function extremumPlane(plane, width, height, radius, pick) {
|
||||
const scratch = new Float32Array(plane.length);
|
||||
for (let y = 0; y < height; y++)
|
||||
extremumLine(plane, scratch, y * width, 1, width, radius, true, pick);
|
||||
for (let x = 0; x < width; x++)
|
||||
extremumLine(scratch, plane, x, width, height, radius, false, pick);
|
||||
}
|
||||
|
||||
/** Blur one plate-carrée channel in place, round on the sphere. */
|
||||
function blurPlane(plane, width, height, sigmaPx) {
|
||||
const scratch = new Float32Array(plane.length);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const latitude = (0.5 - (y + 0.5) / height) * Math.PI;
|
||||
const sigmaX = Math.min(
|
||||
width / 4,
|
||||
sigmaPx / Math.max(Math.cos(latitude), 1e-3),
|
||||
);
|
||||
for (const radius of boxRadii(sigmaX)) {
|
||||
boxLine(plane, scratch, y * width, 1, width, Math.round(radius), true);
|
||||
plane.set(scratch.subarray(y * width, (y + 1) * width), y * width);
|
||||
}
|
||||
}
|
||||
for (const radius of boxRadii(sigmaPx)) {
|
||||
for (let x = 0; x < width; x++)
|
||||
boxLine(plane, scratch, x, width, height, Math.round(radius), false);
|
||||
plane.set(scratch);
|
||||
}
|
||||
}
|
||||
|
||||
/** Unit vector for galactic longitude and latitude in degrees. */
|
||||
function galacticVector(l, b) {
|
||||
const lr = (l * Math.PI) / 180;
|
||||
const br = (b * Math.PI) / 180;
|
||||
return [
|
||||
Math.cos(br) * Math.cos(lr),
|
||||
Math.cos(br) * Math.sin(lr),
|
||||
Math.sin(br),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a disc of the sky with the median of the ring just outside it, per
|
||||
* channel. A median, not a mean: the planet's diffraction spikes reach far
|
||||
* past the disc and would lift a mean well above the sky. The blur that
|
||||
* follows hides the seam.
|
||||
*/
|
||||
function paintOut(planes, width, height, { l, b, radiusDeg }) {
|
||||
const centre = galacticVector(l, b);
|
||||
const inner = Math.cos((radiusDeg * Math.PI) / 180);
|
||||
const outer = Math.cos(((radiusDeg + 1) * Math.PI) / 180);
|
||||
const disc = [];
|
||||
const ring = [];
|
||||
const span = Math.ceil(((radiusDeg + 1) / 180) * height);
|
||||
const row = Math.round((0.5 - b / 180) * height);
|
||||
for (let y = Math.max(0, row - span); y < Math.min(height, row + span); y++) {
|
||||
const latitude = 90 - ((y + 0.5) * 180) / height;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const longitude = (0.5 - (x + 0.5) / width) * 360;
|
||||
const p = galacticVector(longitude, latitude);
|
||||
const cos = p[0] * centre[0] + p[1] * centre[1] + p[2] * centre[2];
|
||||
if (cos >= inner) disc.push(y * width + x);
|
||||
else if (cos >= outer) ring.push(y * width + x);
|
||||
}
|
||||
}
|
||||
for (const plane of planes) {
|
||||
const values = Float32Array.from(ring, (index) => plane[index]).sort();
|
||||
const median = values[values.length >> 1];
|
||||
for (const index of disc) plane[index] = median;
|
||||
}
|
||||
}
|
||||
|
||||
/** Bilinear sample with longitude wrap and latitude clamp. */
|
||||
function samplePlane(plane, width, height, u, v) {
|
||||
const fx = u * width - 0.5;
|
||||
const fy = Math.min(Math.max(v * height - 0.5, 0), height - 1);
|
||||
const x0 = Math.floor(fx);
|
||||
const y0 = Math.floor(fy);
|
||||
const ax = fx - x0;
|
||||
const ay = fy - y0;
|
||||
const xa = ((x0 % width) + width) % width;
|
||||
const xb = (xa + 1) % width;
|
||||
const yb = Math.min(y0 + 1, height - 1);
|
||||
const top = plane[y0 * width + xa] * (1 - ax) + plane[y0 * width + xb] * ax;
|
||||
const bottom =
|
||||
plane[yb * width + xa] * (1 - ax) + plane[yb * width + xb] * ax;
|
||||
return top * (1 - ay) + bottom * ay;
|
||||
}
|
||||
|
||||
/** Deterministic hash noise in [-0.5, 0.5) for dithering. */
|
||||
function dither(index, channel) {
|
||||
let h = (index * 374761393 + channel * 668265263) | 0;
|
||||
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
||||
h ^= h >>> 16;
|
||||
return (h >>> 0) / 4294967296 - 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Black level with a soft knee: 0 at and below the level, then a quadratic
|
||||
* ease that joins the straight line, so faint sky fades out instead of
|
||||
* ending in a contour.
|
||||
*/
|
||||
export function softBlack(x, knee = SOFT_KNEE) {
|
||||
return x <= 0 ? 0 : (x * x) / (x + knee);
|
||||
}
|
||||
|
||||
export function smoothstep(edge0, edge1, x) {
|
||||
const t = Math.min(Math.max((x - edge0) / (edge1 - edge0), 0), 1);
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per pixel, keep `fine` where `weight(luminance of coarse)` is 1 and take
|
||||
* `coarse` where it is 0, blending in between. The weight follows the coarse
|
||||
* copy, so it is smooth itself and draws no seams along dark lanes. Writes
|
||||
* into `fine`.
|
||||
*/
|
||||
function blendByBrightness(fine, coarse, weight) {
|
||||
const [r] = fine;
|
||||
for (let i = 0; i < r.length; i++) {
|
||||
const w = weight((coarse[0][i] + coarse[1][i] + coarse[2][i]) / 3);
|
||||
for (let c = 0; c < 3; c++)
|
||||
fine[c][i] = coarse[c][i] + (fine[c][i] - coarse[c][i]) * w;
|
||||
}
|
||||
}
|
||||
|
||||
/** Value at `fraction` of the sorted luminance. */
|
||||
function luminancePercentile(planes, fraction) {
|
||||
const [r, g, b] = planes;
|
||||
const lum = new Float32Array(r.length);
|
||||
for (let i = 0; i < lum.length; i++) lum[i] = (r[i] + g[i] + b[i]) / 3;
|
||||
lum.sort();
|
||||
return lum[Math.min(lum.length - 1, Math.floor(fraction * lum.length))];
|
||||
}
|
||||
|
||||
const srgbToLinear = (value) =>
|
||||
value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
|
||||
async function decodePanorama(sharp, file) {
|
||||
const width = WORK_WIDTH;
|
||||
const height = WORK_WIDTH / 2;
|
||||
const raw = await sharp(file)
|
||||
.resize(width, height, { fit: 'fill', kernel: 'lanczos3' })
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
const lut = Float32Array.from({ length: 256 }, (_, i) =>
|
||||
srgbToLinear(i / 255),
|
||||
);
|
||||
const planes = [0, 1, 2].map(() => new Float32Array(width * height));
|
||||
for (let i = 0; i < width * height; i++)
|
||||
for (let c = 0; c < 3; c++) planes[c][i] = lut[raw[i * 3 + c]];
|
||||
return { width, height, planes };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [file] = process.argv.slice(2);
|
||||
if (!file) throw new Error('usage: build-milky-way.mjs <eso0932a.tif>');
|
||||
const { default: sharp } = await import('sharp');
|
||||
const { width, height, planes } = await decodePanorama(sharp, file);
|
||||
const sigmaPx = (BLUR_DEG / 180) * height;
|
||||
for (const plane of planes) {
|
||||
extremumPlane(plane, width, height, OPEN_RADIUS, Math.min);
|
||||
extremumPlane(plane, width, height, OPEN_RADIUS, Math.max);
|
||||
}
|
||||
// After the opening: it sinks the starry sky to the dark gaps between
|
||||
// stars, and a fill taken before it would stay brighter than the sky.
|
||||
for (const planet of PLANETS) paintOut(planes, width, height, planet);
|
||||
const wide = planes.map((plane) => Float32Array.from(plane));
|
||||
for (const plane of planes) blurPlane(plane, width, height, sigmaPx);
|
||||
for (const plane of wide)
|
||||
blurPlane(plane, width, height, (FAINT_BLUR_DEG / 180) * height);
|
||||
// Levels from the fine blur, so the blend weight and the output share
|
||||
// them; the blend moves the median only in the faint sky it smooths.
|
||||
const black = luminancePercentile(planes, BLACK_PERCENTILE);
|
||||
const white = luminancePercentile(planes, WHITE_PERCENTILE);
|
||||
blendByBrightness(planes, wide, (luminance) =>
|
||||
smoothstep(
|
||||
FAINT_RANGE[0],
|
||||
FAINT_RANGE[1],
|
||||
(luminance - black) / (white - black),
|
||||
),
|
||||
);
|
||||
let total = 0;
|
||||
for (const face of CUBE_FACES) {
|
||||
const pixels = Buffer.alloc(FACE_SIZE * FACE_SIZE * 3);
|
||||
for (let j = 0; j < FACE_SIZE; j++) {
|
||||
for (let i = 0; i < FACE_SIZE; i++) {
|
||||
const direction = cubeFaceDirection(
|
||||
face,
|
||||
(i + 0.5) / FACE_SIZE,
|
||||
(j + 0.5) / FACE_SIZE,
|
||||
);
|
||||
const [u, v] = directionToPlateCarree(...icrfToGalactic(direction));
|
||||
const index = j * FACE_SIZE + i;
|
||||
const rgb = [0, 1, 2].map((c) =>
|
||||
samplePlane(planes[c], width, height, u, v),
|
||||
);
|
||||
const luminance = (rgb[0] + rgb[1] + rgb[2]) / 3;
|
||||
const level = softBlack((luminance - black) / (white - black));
|
||||
for (let c = 0; c < 3; c++) {
|
||||
// Scale every channel by the luminance curve to keep the hue.
|
||||
const linear = luminance > 0 ? (rgb[c] / luminance) * level : 0;
|
||||
const encoded = Math.min(linear, 1) ** (1 / 2.2) * 255;
|
||||
pixels[index * 3 + c] = Math.min(
|
||||
255,
|
||||
Math.max(0, Math.round(encoded + dither(index, c))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const out = path.join(OUT_DIR, `milkyway_${FACE_FILE_SUFFIX[face]}.webp`);
|
||||
const info = await sharp(pixels, {
|
||||
raw: { width: FACE_SIZE, height: FACE_SIZE, channels: 3 },
|
||||
})
|
||||
.webp({ quality: WEBP_QUALITY, smartSubsample: true, effort: 6 })
|
||||
.toFile(out);
|
||||
total += info.size;
|
||||
console.log(`${path.relative(process.cwd(), out)}: ${info.size} bytes`);
|
||||
}
|
||||
console.log(`total ${total} bytes`);
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
|
||||
@@ -24,11 +24,10 @@ const renderer = (file) =>
|
||||
/^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test(
|
||||
file,
|
||||
);
|
||||
// Two compatibility composition entries intentionally select standalone defaults.
|
||||
// One compatibility composition entry intentionally selects standalone defaults.
|
||||
const compatibilityEdge = (from, to) =>
|
||||
(from === 'server/providers/local.js' &&
|
||||
to === 'server/standalone/key-setup.js') ||
|
||||
(from === 'src/ui.js' && to === 'src/standalone/catalog.js');
|
||||
from === 'server/providers/local.js' &&
|
||||
to === 'server/standalone/key-setup.js';
|
||||
const portableExport = (key) =>
|
||||
key.startsWith('./sources/') ||
|
||||
/\/source$/.test(key) ||
|
||||
|
||||
@@ -48,7 +48,8 @@ export function defaultGatewayFromRouteTable(text) {
|
||||
|
||||
/**
|
||||
* Decide this launch's trusted-peer list. An operator value always wins, so a
|
||||
* compose override stays authoritative; otherwise the gateway alone is
|
||||
* compose override stays authoritative; `none` trusts no additional peers.
|
||||
* An unset or blank value means the gateway alone is
|
||||
* trusted. No gateway trusts nothing and Provider Settings stays loopback-only,
|
||||
* exactly as under `npm run dev`.
|
||||
* @param {{env?: NodeJS.ProcessEnv, routeTable?: string}} input
|
||||
@@ -56,6 +57,7 @@ export function defaultGatewayFromRouteTable(text) {
|
||||
*/
|
||||
export function resolveTrustedPeers({ env = process.env, routeTable = '' } = {}) {
|
||||
const explicit = String(env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim();
|
||||
if (explicit.toLowerCase() === 'none') return '';
|
||||
if (explicit) return explicit;
|
||||
return defaultGatewayFromRouteTable(routeTable) ?? '';
|
||||
}
|
||||
@@ -109,15 +111,17 @@ async function start() {
|
||||
String(process.env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim(),
|
||||
);
|
||||
const trustedPeers = resolveTrustedPeers({ routeTable: readRouteTable() });
|
||||
// Set even an empty list before Vite loads .env: the resolved launcher policy
|
||||
// must remain authoritative when there is no trusted gateway.
|
||||
process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers;
|
||||
if (trustedPeers) {
|
||||
// Set before Vite is imported: the dev server snapshots process.env during
|
||||
// configuration, and the admission gate reads it per request.
|
||||
process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers;
|
||||
console.log(
|
||||
fromOperator
|
||||
? `[Docker] Provider Settings trusted peers: ${trustedPeers} (GEV_KEY_SETUP_TRUSTED_PEERS).`
|
||||
: `[Docker] Provider Settings trusts the container gateway (${trustedPeers}).`,
|
||||
);
|
||||
} else if (fromOperator) {
|
||||
console.log('[Docker] Provider Settings trusts no additional peers (GEV_KEY_SETUP_TRUSTED_PEERS=none).');
|
||||
} else {
|
||||
console.log('[Docker] No default gateway; Provider Settings stays loopback-only.');
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
".prettierrc.json",
|
||||
"package.json",
|
||||
"scripts/format-scope.json",
|
||||
"src/ui/styles/form-field.css",
|
||||
"scripts/qa-form-fields.mjs",
|
||||
"scripts/package-boundaries.json",
|
||||
"scripts/format.mjs",
|
||||
"scripts/qa-layer-visibility.mjs",
|
||||
@@ -120,6 +122,7 @@
|
||||
"server/providers/cctv/normalize.js",
|
||||
"src/data/cctvMissingNumbers.test.mjs",
|
||||
"server/providers/cctv/sources.js",
|
||||
"src/data/cctvSourcePins.test.mjs",
|
||||
"server/providers/radio.js",
|
||||
"server/providers/radio/catalog.js",
|
||||
"server/providers/radio/constants.js",
|
||||
@@ -282,8 +285,7 @@
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/sceneControls.test.mjs",
|
||||
"scripts/qa-scene-controls.mjs",
|
||||
"src/ui.js",
|
||||
"src/standalone/ui.js",
|
||||
"scripts/qa-action-buttons.mjs",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/panelLayoutController.js",
|
||||
"src/ui/panelPositionControls.js",
|
||||
@@ -306,6 +308,7 @@
|
||||
"src/ui/styles/layers.css",
|
||||
"src/ui/styles/controls.css",
|
||||
"src/ui/styles/slider.css",
|
||||
"src/ui/styles/action-button.css",
|
||||
"src/ui/styles/cctv.css",
|
||||
"src/ui/styles/foundation.css",
|
||||
"src/ui/styles/first-run.css",
|
||||
@@ -1069,5 +1072,20 @@
|
||||
"src/tooling/changelogFragments.test.mjs",
|
||||
"src/data/gbfsProxyRateLimit.test.mjs",
|
||||
"src/data/routeSegment.test.mjs",
|
||||
"src/terrainHeightsBounds.test.mjs"
|
||||
"src/terrainHeightsBounds.test.mjs",
|
||||
"scripts/build-bright-stars.mjs",
|
||||
"scripts/build-milky-way.mjs",
|
||||
"src/sky/stars.test.mjs",
|
||||
"src/sky/starrySky.test.mjs",
|
||||
"src/tooling/skyAssets.test.mjs",
|
||||
"src/tooling/skyAssets.paintOut.test.mjs",
|
||||
"scripts/qa-anime-sky.mjs",
|
||||
"scripts/qa-sky-model-lighting.mjs",
|
||||
"server/providers/overpass/requestAdmission.test.mjs",
|
||||
"src/tooling/trackArrivalObservation.test.mjs",
|
||||
"src/ui/styles/status-badge.css",
|
||||
"src/ui/statusBadge.test.mjs",
|
||||
"src/ui/cctvBadges.test.mjs",
|
||||
"scripts/qa-status-badges.mjs",
|
||||
"src/dockerTrustedPeers.test.mjs"
|
||||
]
|
||||
@@ -22,7 +22,14 @@
|
||||
},
|
||||
"viewer": {
|
||||
"exports": ["./application/viewer"],
|
||||
"modules": ["src/app/viewer.js", "src/app/atmosphereCompat.js"],
|
||||
"modules": [
|
||||
"src/app/viewer.js",
|
||||
"src/app/atmosphereCompat.js",
|
||||
"src/sky/starrySky.js",
|
||||
"src/sky/stars.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/renderGovernor.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"vite-build": {
|
||||
@@ -187,6 +194,8 @@
|
||||
"exports": ["./server/providers/cctv"],
|
||||
"modules": [
|
||||
"server/providers/cctv.js",
|
||||
"server/providers/common/same-site.js",
|
||||
"src/localRequestGate.mjs",
|
||||
"server/providers/cctv/cap.js",
|
||||
"server/providers/cctv/groundHeights.js",
|
||||
"server/providers/cctv/catalog.js",
|
||||
@@ -252,6 +261,7 @@
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/rate-limit.js",
|
||||
"server/providers/common/request.js",
|
||||
"server/providers/common/same-site.js",
|
||||
"server/providers/overpass.js",
|
||||
"server/providers/overpass/cache.js",
|
||||
"server/providers/overpass/constants.js",
|
||||
@@ -261,6 +271,7 @@
|
||||
"server/providers/places/routes.js",
|
||||
"src/data/placeProviderPayloads.js",
|
||||
"src/data/routeSteps.js",
|
||||
"src/localRequestGate.mjs",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/sources/overpass.js",
|
||||
@@ -426,7 +437,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -451,7 +463,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -467,7 +480,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -496,9 +510,11 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/ui/keyGuidance.js",
|
||||
"src/ui/statusText.js"
|
||||
"src/ui/statusText.js",
|
||||
"src/ui/statusBadge.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -514,7 +530,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -529,7 +546,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -539,6 +557,7 @@
|
||||
"src/ui/cctv.js",
|
||||
"src/ui/cctvBindings.js",
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/formField.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
@@ -547,7 +566,9 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/unitLabels.js"
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/ui/statusBadge.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -571,6 +592,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/layerNames.js"
|
||||
],
|
||||
"external": []
|
||||
@@ -604,6 +626,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
@@ -614,10 +637,12 @@
|
||||
"src/ui/scenes.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/actionButton.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": []
|
||||
@@ -626,6 +651,8 @@
|
||||
"exports": ["./ui/shell"],
|
||||
"modules": [
|
||||
"src/i18n/marker.js",
|
||||
"src/ui/mobileShell.js",
|
||||
"src/exclusiveSurfaces.js",
|
||||
"src/ui/placeNames.js",
|
||||
"src/hudLayouts.js",
|
||||
"src/hudLayoutPolicy.js",
|
||||
@@ -675,6 +702,7 @@
|
||||
"src/ui/cctv.js",
|
||||
"src/ui/cctvBindings.js",
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/formField.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
@@ -737,6 +765,7 @@
|
||||
"src/ui/rightPanelRail.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/actionButton.js",
|
||||
"src/ui/scenes.js",
|
||||
"src/ui/shareRestoration.js",
|
||||
"src/ui/shellElements.js",
|
||||
@@ -768,10 +797,12 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/ui/keyGuidance.js",
|
||||
"src/ui/layerNames.js",
|
||||
"src/ui/statusText.js"
|
||||
"src/ui/statusText.js",
|
||||
"src/ui/statusBadge.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -849,6 +880,7 @@
|
||||
"src/ui/scenes.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/actionButton.js",
|
||||
"src/contextModePolicy.js",
|
||||
"src/director/clock.js",
|
||||
"src/director/timeline.js",
|
||||
@@ -885,6 +917,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
@@ -950,6 +983,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
@@ -995,7 +1029,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1055,7 +1090,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1084,6 +1120,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/layers/recentImagery/display.js"
|
||||
],
|
||||
@@ -1112,7 +1149,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1143,6 +1181,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": ["cesium", "satellite.js"]
|
||||
@@ -1174,7 +1213,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1216,6 +1256,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/layers/installations/classLabel.js"
|
||||
],
|
||||
"external": ["cesium", "@mapbox/vector-tile", "pbf"]
|
||||
@@ -1250,6 +1291,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
@@ -1298,7 +1340,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium", "pbf", "@mapbox/vector-tile"]
|
||||
},
|
||||
@@ -1326,7 +1369,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1373,7 +1417,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["hls.js", "cesium"]
|
||||
},
|
||||
@@ -1434,6 +1479,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/layers/streetLevel/providers/mapillary/policy.js"
|
||||
],
|
||||
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
|
||||
@@ -1461,7 +1507,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium", "@mapbox/vector-tile", "pbf"]
|
||||
},
|
||||
@@ -1500,7 +1547,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1521,7 +1569,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1576,7 +1625,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1632,6 +1682,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/statusText.js",
|
||||
"src/voice/providerCommands.js",
|
||||
"src/voice/providerCapabilities.js",
|
||||
@@ -1720,6 +1771,8 @@
|
||||
"src/app/surfaceServices.js",
|
||||
"src/app/tools.js",
|
||||
"src/app/viewer.js",
|
||||
"src/sky/starrySky.js",
|
||||
"src/sky/stars.js",
|
||||
"src/app/atmosphereCompat.js",
|
||||
"src/bloom.js",
|
||||
"src/camera.js",
|
||||
@@ -1801,7 +1854,6 @@
|
||||
"src/data/localGeojson.js",
|
||||
"src/data/localGeojsonCore.js",
|
||||
"src/data/localGeojsonLod.js",
|
||||
"src/data/localLayers.js",
|
||||
"src/data/local_data/natural_earth/marine.json",
|
||||
"src/data/local_data/natural_earth/regions.json",
|
||||
"src/data/local_data/neighborhoods/san-francisco.json",
|
||||
@@ -1855,6 +1907,7 @@
|
||||
"src/data/vesselLabels.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/firstRunExperience.js",
|
||||
"src/exclusiveSurfaces.js",
|
||||
"src/hud.js",
|
||||
"src/hudLocality.js",
|
||||
"src/hudLocalityFormat.js",
|
||||
@@ -2142,6 +2195,8 @@
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/mobileShell.js",
|
||||
"src/exclusiveSurfaces.js",
|
||||
"src/ui/localePicker.js",
|
||||
"src/ui/cyberSonarControls.js",
|
||||
"src/ui/locationNavigation.js",
|
||||
@@ -2151,6 +2206,7 @@
|
||||
"src/ui/cctv.js",
|
||||
"src/ui/cctvBindings.js",
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/formField.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
@@ -2214,6 +2270,7 @@
|
||||
"src/ui/rightPanelRail.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/actionButton.js",
|
||||
"src/ui/scenes.js",
|
||||
"src/ui/shareRestoration.js",
|
||||
"src/ui/shellElements.js",
|
||||
@@ -2489,6 +2546,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/layers/recentImagery/display.js",
|
||||
"src/ui/keyGuidance.js",
|
||||
@@ -2506,7 +2564,8 @@
|
||||
"src/voice/providerCapabilities.js",
|
||||
"src/voice/geminiSession.js",
|
||||
"src/voice/geminiAudio.js",
|
||||
"src/voice/geminiCaptureWorklet.js"
|
||||
"src/voice/geminiCaptureWorklet.js",
|
||||
"src/ui/statusBadge.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -3063,6 +3122,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/layers/recentImagery/display.js",
|
||||
"src/layers/installations/classLabel.js",
|
||||
@@ -3169,6 +3229,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/index.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
@@ -3189,7 +3250,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -3223,7 +3285,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -3246,7 +3309,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -3337,6 +3401,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
@@ -3391,6 +3456,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/statusText.js",
|
||||
"src/voice/providerCapabilities.js"
|
||||
],
|
||||
@@ -3643,7 +3709,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium", "egm96-universal", "pbf"]
|
||||
},
|
||||
@@ -3685,7 +3752,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium", "pbf"]
|
||||
},
|
||||
@@ -3737,7 +3805,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -3753,6 +3822,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/statusText.js",
|
||||
"src/ui/layerNames.js"
|
||||
],
|
||||
@@ -3945,6 +4015,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/statusText.js",
|
||||
"src/ui/layerNames.js"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Scenes/CCTV action-button acceptance against dev or production preview:
|
||||
* QA_BASE_URL=http://127.0.0.1:4179 node scripts/qa-action-buttons.mjs
|
||||
* Uses the installed CCTV controller with fixture camera/action ports to prove
|
||||
* native dispatch/disabled/teardown without providers or world calibration writes.
|
||||
* Real Scene authoring/playback remains covered by qa-scene-controls.mjs.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
let failures = 0;
|
||||
const check = (name, passed, detail) => {
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!passed) {
|
||||
failures++;
|
||||
console.log(JSON.stringify(detail));
|
||||
}
|
||||
};
|
||||
const shots = 'qa-shots/action-buttons';
|
||||
fs.mkdirSync(shots, { recursive: true });
|
||||
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://127.0.0.1:4179'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const inventory = await page.evaluate(() => {
|
||||
const ids = [
|
||||
'scene-new-btn',
|
||||
'scene-delete-btn',
|
||||
'scene-capture-btn',
|
||||
'scene-update-shot-btn',
|
||||
'scene-start-btn',
|
||||
'scene-stop-btn',
|
||||
'scene-next-btn',
|
||||
'scene-export-btn',
|
||||
'scene-import-btn',
|
||||
'scene-download-btn',
|
||||
'cctv-nearest-btn',
|
||||
'cctv-prev-btn',
|
||||
'cctv-next-btn',
|
||||
'cctv-focus-btn',
|
||||
'cctv-calib-save-btn',
|
||||
'cctv-calib-reset-btn',
|
||||
];
|
||||
const ordinary = ids.map((id) => document.getElementById(id));
|
||||
const generated = [
|
||||
...document.querySelectorAll(
|
||||
'.scene-shot-btn, [data-director-authoring] button',
|
||||
),
|
||||
];
|
||||
const toggles = [
|
||||
'cctv-enable-btn',
|
||||
'cctv-coverage-btn',
|
||||
'cctv-auto-hop-btn',
|
||||
'cctv-projection-btn',
|
||||
'cctv-adjust-btn',
|
||||
].map((id) => document.getElementById(id));
|
||||
return {
|
||||
shared: [...ordinary, ...generated].every(
|
||||
(node) =>
|
||||
node?.type === 'button' &&
|
||||
node.classList.contains('gev-action-button'),
|
||||
),
|
||||
generated: generated.length,
|
||||
layout: ordinary.every((node) => {
|
||||
const style = getComputedStyle(node);
|
||||
return style.minWidth === '72px' && style.flexGrow === '1';
|
||||
}),
|
||||
togglesSeparate: toggles.every(
|
||||
(node) => node && !node.classList.contains('gev-action-button'),
|
||||
),
|
||||
compactDanger: !!document.querySelector(
|
||||
'.scene-shot-danger.gev-action-button--compact.gev-action-button--danger',
|
||||
),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'static and generated actions share the native component',
|
||||
inventory.shared && inventory.generated >= 4 && inventory.compactDanger,
|
||||
inventory,
|
||||
);
|
||||
check(
|
||||
'persistent CCTV toggles retain their existing controls',
|
||||
inventory.togglesSeparate,
|
||||
);
|
||||
check('ordinary actions retain their panel sizing', inventory.layout);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const owner = manager._cctvControls;
|
||||
owner._cctvUnsubscribe?.();
|
||||
owner._cctvUnsubscribe = null;
|
||||
window.__qaButtonCalls = [];
|
||||
const record = (...args) => window.__qaButtonCalls.push(args);
|
||||
owner.actions = {
|
||||
...owner.actions,
|
||||
isEnabled: () => true,
|
||||
toggleEnabled: async () => {
|
||||
record('enable');
|
||||
return true;
|
||||
},
|
||||
runExplicitFocus: (select, focus) => focus(select()),
|
||||
setParams: (params, options) => record('params', params, options),
|
||||
showToast: () => {},
|
||||
};
|
||||
owner.cctv = {
|
||||
focusNearest: () => {
|
||||
record('nearest');
|
||||
return 'a';
|
||||
},
|
||||
cycleCamera: (direction) => {
|
||||
record('cycle', direction);
|
||||
return 'b';
|
||||
},
|
||||
focusCamera: (id, seconds) => record('focus', id, seconds),
|
||||
};
|
||||
window.__qaCameraState = {
|
||||
enabled: true,
|
||||
activeCameraId: 'a',
|
||||
cameras: [
|
||||
{ id: 'a', city: 'Fixture', name: 'A' },
|
||||
{ id: 'b', city: 'Fixture', name: 'B' },
|
||||
],
|
||||
activeCamera: { id: 'a', name: 'A', basePose: {} },
|
||||
};
|
||||
owner._renderCctvState(window.__qaCameraState);
|
||||
manager.setPanelCollapsed('cctv-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
await page.focus('#cctv-next-btn');
|
||||
await page.keyboard.press('Space');
|
||||
await page.waitForFunction(() => window.__qaButtonCalls.length === 3);
|
||||
check(
|
||||
'native Space dispatches Next and its focus once',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
JSON.stringify(window.__qaButtonCalls) ===
|
||||
JSON.stringify([['enable'], ['cycle', 1], ['focus', 'b', 1.4]]),
|
||||
),
|
||||
);
|
||||
check(
|
||||
'native keyboard activation keeps a visible focus ring',
|
||||
await page.$eval(
|
||||
'#cctv-next-btn',
|
||||
(node) =>
|
||||
document.activeElement === node &&
|
||||
getComputedStyle(node).outlineStyle === 'solid',
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__qaButtonCalls = [];
|
||||
});
|
||||
await page.focus('#cctv-prev-btn');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(() => window.__qaButtonCalls.length === 3);
|
||||
check(
|
||||
'native Enter dispatches Previous once',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
JSON.stringify(window.__qaButtonCalls) ===
|
||||
JSON.stringify([['enable'], ['cycle', -1], ['focus', 'b', 1.4]]),
|
||||
),
|
||||
);
|
||||
|
||||
for (const [id, expected] of [
|
||||
['cctv-nearest-btn', [['enable'], ['nearest'], ['focus', 'a', 1.8]]],
|
||||
[
|
||||
'cctv-focus-btn',
|
||||
[
|
||||
['enable'],
|
||||
['focus', 'a', 1.9],
|
||||
['params', { selectedCameraId: 'a' }, { origin: 'user' }],
|
||||
],
|
||||
],
|
||||
]) {
|
||||
await page.evaluate(() => {
|
||||
window.__qaButtonCalls = [];
|
||||
});
|
||||
await page.click(`#${id}`);
|
||||
await page.waitForFunction(() => window.__qaButtonCalls.length === 3);
|
||||
const calls = await page.evaluate(() => window.__qaButtonCalls);
|
||||
check(
|
||||
`${id} retains its existing action and focus timing`,
|
||||
JSON.stringify(calls) === JSON.stringify(expected),
|
||||
calls,
|
||||
);
|
||||
}
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__qaButtonCalls = [];
|
||||
});
|
||||
await page.click('#cctv-calib-save-btn');
|
||||
await page.click('#cctv-calib-reset-btn');
|
||||
const calibration = await page.evaluate(() => window.__qaButtonCalls);
|
||||
check(
|
||||
'Save and Reset retain camera identity and user-origin dispatch',
|
||||
JSON.stringify(calibration) ===
|
||||
JSON.stringify([
|
||||
[
|
||||
'params',
|
||||
{ selectedCameraId: 'a', calibration: { cameraId: 'a', save: true } },
|
||||
{ origin: 'user' },
|
||||
],
|
||||
[
|
||||
'params',
|
||||
{
|
||||
selectedCameraId: 'a',
|
||||
calibration: { cameraId: 'a', reset: true },
|
||||
},
|
||||
{ origin: 'user' },
|
||||
],
|
||||
]),
|
||||
calibration,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__qaButtonCalls = [];
|
||||
window.__godsEyeView.styleManager._cctvControls._renderCctvState({
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
await page.click('#cctv-next-btn');
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const node = document.getElementById('cctv-next-btn');
|
||||
return node.disabled && Number(getComputedStyle(node).opacity) === 0.45;
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
check(
|
||||
'disabled actions stay inert and keep disabled presentation',
|
||||
await page.$eval(
|
||||
'#cctv-next-btn',
|
||||
(node) =>
|
||||
node.disabled &&
|
||||
getComputedStyle(node).opacity === '0.45' &&
|
||||
window.__qaButtonCalls.length === 0,
|
||||
),
|
||||
);
|
||||
|
||||
for (const width of [1440, 620, 390]) {
|
||||
await page.setViewport({ width, height: 900 });
|
||||
for (const panel of ['scene-panel', 'cctv-panel']) {
|
||||
await page.evaluate((panel) => {
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
}, panel);
|
||||
const fit = await page.$eval(`#${panel}`, (root) => {
|
||||
const buttons = [...root.querySelectorAll('.gev-action-button')];
|
||||
const rect = root.getBoundingClientRect();
|
||||
return {
|
||||
count: buttons.length,
|
||||
fits:
|
||||
buttons.length > 0 &&
|
||||
buttons.every((button) => {
|
||||
const box = button.getBoundingClientRect();
|
||||
return (
|
||||
box.width > 0 &&
|
||||
box.left >= rect.left &&
|
||||
box.right <= rect.right + 1 &&
|
||||
box.left >= 0 &&
|
||||
box.right <= innerWidth + 1 &&
|
||||
button.scrollWidth <= button.clientWidth
|
||||
);
|
||||
}),
|
||||
};
|
||||
});
|
||||
check(`${panel} actions fit at ${width}px`, fit.fits, fit);
|
||||
if (width !== 620)
|
||||
await page.screenshot({ path: `${shots}/${panel}-${width}.png` });
|
||||
}
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
owner.destroy();
|
||||
document.getElementById('cctv-next-btn').disabled = false;
|
||||
window.__qaButtonCalls = [];
|
||||
});
|
||||
await page.click('#cctv-next-btn');
|
||||
check(
|
||||
'destroyed controllers revoke action listeners',
|
||||
await page.evaluate(() => window.__qaButtonCalls.length === 0),
|
||||
);
|
||||
const localized = new URL(page.url());
|
||||
localized.searchParams.set('lang', 'ru');
|
||||
await page.goto(localized.href, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
for (const panel of ['scene-panel', 'cctv-panel']) {
|
||||
await page.evaluate((panel) => {
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
}, panel);
|
||||
const translated = await page.$eval(`#${panel}`, (root) => {
|
||||
const buttons = [...root.querySelectorAll('.gev-action-button')];
|
||||
return (
|
||||
document.documentElement.lang === 'ru' &&
|
||||
buttons.length > 0 &&
|
||||
buttons.every((node) => node.scrollWidth <= node.clientWidth)
|
||||
);
|
||||
});
|
||||
check(`Russian ${panel} action labels fit at 390px`, translated);
|
||||
await page.screenshot({ path: `${shots}/${panel}-ru-390.png` });
|
||||
}
|
||||
check('no uncaught browser errors', errors.length === 0, errors);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`RESULT: ${failures} failures`);
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-anime-sky — the anime style must leave the sky without bands.
|
||||
*
|
||||
* Anime's luminance bands turn the faint Milky Way into blotches, so
|
||||
* src/ui/visualEffects.js fades the glow out with the style. Headless against
|
||||
* a Vite dev server, both sky assets must be ready. Normal must show the
|
||||
* Galactic Centre glow (at least 2 % of pixels at luma >= 10/255), then
|
||||
* Anime must retain stars while at most 0.5 % reaches the first cel band.
|
||||
* The Normal baseline measured 43 % in headless Chromium at 1000×700;
|
||||
* the 2 % floor leaves room for GPU differences while rejecting a black sky.
|
||||
*
|
||||
* Usage: node scripts/qa-anime-sky.mjs [--url http://localhost:4333/]
|
||||
* Exit: 0 pass, 1 fail, 2 the anime style was not reached.
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const i = process.argv.indexOf('--url');
|
||||
const URL_ =
|
||||
i > 0 && process.argv[i + 1] ? process.argv[i + 1] : 'http://localhost:4333/';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: ['--no-sandbox', '--ignore-gpu-blocklist'],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
const host = new URL(request.url()).hostname;
|
||||
if (host.endsWith('halfpixel.ai') || host.includes('worldfx'))
|
||||
request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', String(e).slice(0, 300)));
|
||||
await page.setViewport({ width: 1000, height: 700 });
|
||||
await page.evaluateOnNewDocument(() =>
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed'),
|
||||
);
|
||||
await page.goto(URL_, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.viewer?.scene, {
|
||||
timeout: 120000,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const sky = window.__godsEyeView.viewer.scene.skyBox;
|
||||
return (
|
||||
sky?._milkyWayCubeMap &&
|
||||
sky?._starCommand &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden')
|
||||
);
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const r = await page.evaluate(async () => {
|
||||
const entry = performance
|
||||
.getEntriesByType('resource')
|
||||
.map((e) => e.name)
|
||||
.find((n) => /\.vite\/deps\/cesium\.js/.test(n));
|
||||
if (!entry)
|
||||
throw new Error(
|
||||
'run against the Vite dev server (needs .vite/deps/cesium.js)',
|
||||
);
|
||||
const C = await import(entry),
|
||||
g = window.__godsEyeView,
|
||||
v = g.viewer,
|
||||
s = v.scene;
|
||||
g.styleManager.setStyle('normal', { userInitiated: true });
|
||||
const t = v.clock.currentTime;
|
||||
await C.Transforms.preloadIcrfFixed(
|
||||
new C.TimeInterval({
|
||||
start: C.JulianDate.addDays(t, -1, new C.JulianDate()),
|
||||
stop: C.JulianDate.addDays(t, 1, new C.JulianDate()),
|
||||
}),
|
||||
);
|
||||
const m = C.Transforms.computeIcrfToFixedMatrix(t);
|
||||
const ra = C.Math.toRadians(266.4),
|
||||
dec = C.Math.toRadians(-29);
|
||||
const look = C.Matrix3.multiplyByVector(
|
||||
m,
|
||||
new C.Cartesian3(
|
||||
Math.cos(dec) * Math.cos(ra),
|
||||
Math.cos(dec) * Math.sin(ra),
|
||||
Math.sin(dec),
|
||||
),
|
||||
new C.Cartesian3(),
|
||||
);
|
||||
const north = C.Matrix3.multiplyByVector(
|
||||
m,
|
||||
C.Cartesian3.UNIT_Z,
|
||||
new C.Cartesian3(),
|
||||
);
|
||||
const right = C.Cartesian3.normalize(
|
||||
C.Cartesian3.cross(look, north, new C.Cartesian3()),
|
||||
new C.Cartesian3(),
|
||||
);
|
||||
s.camera.cancelFlight();
|
||||
s.tweens.removeAll();
|
||||
s.camera.setView({
|
||||
destination: C.Cartesian3.multiplyByScalar(look, 3e7, new C.Cartesian3()),
|
||||
orientation: {
|
||||
direction: look,
|
||||
up: C.Cartesian3.cross(right, look, new C.Cartesian3()),
|
||||
},
|
||||
});
|
||||
const gl = s.context._gl,
|
||||
w = gl.drawingBufferWidth,
|
||||
h = gl.drawingBufferHeight,
|
||||
px = new Uint8Array(w * h * 4);
|
||||
const measure = async () => {
|
||||
await new Promise((res) => setTimeout(res, 2500));
|
||||
s.requestRender();
|
||||
s.render();
|
||||
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px);
|
||||
let banded = 0;
|
||||
let visible = 0;
|
||||
for (let i = 0; i < px.length; i += 4) {
|
||||
const luma = 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
|
||||
if (luma >= 51) banded++;
|
||||
if (luma >= 10) visible++;
|
||||
}
|
||||
return { banded, visible };
|
||||
};
|
||||
const normal = await measure();
|
||||
g.styleManager.setStyle('anime', { userInitiated: true });
|
||||
const anime = await measure();
|
||||
let animeOn = false;
|
||||
for (let i = 0; i < s.postProcessStages.length; i++) {
|
||||
const stage = s.postProcessStages.get(i);
|
||||
if (stage.name === 'godsEyeView_anime')
|
||||
animeOn = stage.enabled && stage.uniforms.intensity > 0.99;
|
||||
}
|
||||
return {
|
||||
total: px.length / 4,
|
||||
normal,
|
||||
...anime,
|
||||
assetsReady: Boolean(s.skyBox._milkyWayCubeMap && s.skyBox._starCommand),
|
||||
visibility: s.skyBox.milkyWayVisibility,
|
||||
animeOn,
|
||||
};
|
||||
});
|
||||
const tag =
|
||||
!r.animeOn || !r.assetsReady
|
||||
? 'UNREACHED'
|
||||
: r.normal.visible >= 0.02 * r.total &&
|
||||
r.banded > 0 &&
|
||||
r.banded <= 0.005 * r.total &&
|
||||
r.visibility === 0
|
||||
? 'PASS'
|
||||
: 'FAIL';
|
||||
console.log(
|
||||
`${tag}: ${r.banded} of ${r.total} sky pixels at or above the first cel band (luma >= 0.2)\n ${JSON.stringify(r)}`,
|
||||
);
|
||||
process.exitCode = tag === 'PASS' ? 0 : tag === 'FAIL' ? 1 : 2;
|
||||
} catch (error) {
|
||||
console.error(`UNREACHED: ${error.message}`);
|
||||
process.exitCode = 2;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -370,6 +370,9 @@ try {
|
||||
await page.screenshot({ path: 'qa-shots/camera-controls/angle.png' });
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
// Phones present CCTV in the Cameras tab's sheet.
|
||||
await page.click('#mobile-tab-bar [data-mobile-sheet="cameras"]');
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
await page.screenshot({ path: 'qa-shots/camera-controls/narrow.png' });
|
||||
check(
|
||||
'narrow camera panel remains within the viewport',
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* passed with the painter stubbed out — the number was right and nothing was
|
||||
* drawn. Every assertion here now comes from the RENDERED path instead:
|
||||
*
|
||||
* ids/counts — `styleManager.getDetectionDiagnostics()` (src/ui.js), the
|
||||
* ids/counts — `styleManager.getDetectionDiagnostics()` (src/ui/shellFacade.js), the
|
||||
* overlay's own published `visibleCount` / `labeledKeys`, so a contact only
|
||||
* counts once the sweep, the arbiter and the placement solve have all run.
|
||||
* plate scale — tapped off `CanvasRenderingContext2D` while the frame paints.
|
||||
|
||||
@@ -34,10 +34,6 @@
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, dflt) => {
|
||||
|
||||
@@ -52,7 +52,6 @@ try {
|
||||
id,
|
||||
value,
|
||||
);
|
||||
const text = () => page.$eval('#cesium-credits', (e) => e.innerText);
|
||||
const shot = async (name, osm, tiles) => {
|
||||
await sleep(300);
|
||||
const state = await page.$eval('#cesium-credits', (e) => {
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
#!/usr/bin/env node
|
||||
/** Native form-field regression gate. CCTV ports are fixtures, never live writes. */
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
let failures = 0;
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const check = (name, passed, detail) => {
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!passed) {
|
||||
failures++;
|
||||
console.log(JSON.stringify(detail));
|
||||
}
|
||||
};
|
||||
fs.mkdirSync('qa-shots/form-fields', { recursive: true });
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://127.0.0.1:4183'}/?welcome=0`,
|
||||
{
|
||||
waitUntil: 'domcontentloaded',
|
||||
},
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const inventory = await page.evaluate(() => {
|
||||
const selects = [
|
||||
'scene-select',
|
||||
'cctv-camera-select',
|
||||
'hud-layout-select',
|
||||
'draw-color-select',
|
||||
'radio-filter',
|
||||
'sdr-gain-select',
|
||||
].map((id) => document.getElementById(id));
|
||||
return {
|
||||
ids: selects.map((node) => node.id),
|
||||
shared:
|
||||
selects.length === 6 &&
|
||||
selects.every((node) => node.classList.contains('gev-field')),
|
||||
native: selects.every(
|
||||
(node) =>
|
||||
node instanceof HTMLSelectElement && !node.hasAttribute('role'),
|
||||
),
|
||||
dark: selects.every(
|
||||
(node) => getComputedStyle(node).colorScheme === 'dark',
|
||||
),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'all six panel dropdowns retain native select semantics and shared surface',
|
||||
inventory.shared && inventory.native,
|
||||
inventory,
|
||||
);
|
||||
check('native option menus keep the dark scheme', inventory.dark);
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.setPanelCollapsed('pp-toggles', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
manager.setPanelCollapsed('scene-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
manager.setPanelCollapsed('cctv-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
window.__qaFormChanges = [];
|
||||
document
|
||||
.getElementById('scene-select')
|
||||
.addEventListener('change', (event) =>
|
||||
window.__qaFormChanges.push(event.target.value),
|
||||
);
|
||||
});
|
||||
const ownerSetup = await page.evaluate(() => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
owner._cctvUnsubscribe?.();
|
||||
owner._cctvUnsubscribe = null;
|
||||
window.__qaFormCalls = [];
|
||||
owner.actions = {
|
||||
...owner.actions,
|
||||
isEnabled: () => true,
|
||||
toggleEnabled: async () => true,
|
||||
setParams: (...args) => window.__qaFormCalls.push(args),
|
||||
runExplicitFocus: (select, focus) => focus(select()),
|
||||
showToast: () => {},
|
||||
};
|
||||
owner.cctv = {
|
||||
setParams() {},
|
||||
focusCamera: (id) => window.__qaFormCalls.push(['focus', id]),
|
||||
selectCamera: (id) => id,
|
||||
};
|
||||
owner._cctvState = {
|
||||
enabled: true,
|
||||
activeCameraId: 'a',
|
||||
cameras: [
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' },
|
||||
],
|
||||
activeCamera: {
|
||||
id: 'a',
|
||||
headingDeg: 30,
|
||||
pitchDeg: -10,
|
||||
fovDeg: 45,
|
||||
rangeM: 120,
|
||||
mountHeightM: 8,
|
||||
calibration: {},
|
||||
basePose: {
|
||||
headingDeg: 20,
|
||||
pitchDeg: -10,
|
||||
fovDeg: 45,
|
||||
rangeM: 120,
|
||||
mountHeightM: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
owner._renderCctvState(owner._cctvState);
|
||||
const input = document.getElementById('sdr-frequency-input');
|
||||
return {
|
||||
min: input.min,
|
||||
max: input.max,
|
||||
step: input.step,
|
||||
type: input.type,
|
||||
shared: input.classList.contains('gev-field'),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'FM frequency retains native bounds, precision and shared surface',
|
||||
ownerSetup.min === '87.5' &&
|
||||
ownerSetup.max === '108' &&
|
||||
ownerSetup.step === '0.1' &&
|
||||
ownerSetup.type === 'number' &&
|
||||
ownerSetup.shared,
|
||||
ownerSetup,
|
||||
);
|
||||
const validity = await page.$eval('#sdr-frequency-input', (input) => {
|
||||
const original = input.value;
|
||||
input.value = '87.4';
|
||||
const below = input.validity.rangeUnderflow;
|
||||
input.value = '108.1';
|
||||
const above = input.validity.rangeOverflow;
|
||||
input.value = '98.55';
|
||||
const step = input.validity.stepMismatch;
|
||||
input.value = '';
|
||||
const empty = input.validity.valid;
|
||||
input.value = original;
|
||||
return below && above && step && empty;
|
||||
});
|
||||
check(
|
||||
'native numeric validity preserves underflow, overflow, steps and empty drafts',
|
||||
validity,
|
||||
);
|
||||
await page.select('#cctv-camera-select', 'b');
|
||||
await page.waitForFunction(() => window.__qaFormCalls.length === 2);
|
||||
const cameraSelection = await page.evaluate(() => window.__qaFormCalls);
|
||||
check(
|
||||
'camera dropdown retains selection, focus and user-origin dispatch',
|
||||
cameraSelection[0][0] === 'focus' &&
|
||||
cameraSelection[0][1] === 'b' &&
|
||||
cameraSelection[1][0].selectedCameraId === 'b' &&
|
||||
cameraSelection[1][1].origin === 'user',
|
||||
cameraSelection,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__qaFormCalls = [];
|
||||
document.getElementById('cctv-camera-select').disabled = true;
|
||||
});
|
||||
await page.focus('#scene-select');
|
||||
await page.focus('#cctv-camera-select');
|
||||
check(
|
||||
'disabled native selects cannot receive keyboard focus',
|
||||
await page.evaluate(
|
||||
() => document.activeElement.id !== 'cctv-camera-select',
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('cctv-camera-select').disabled = false;
|
||||
});
|
||||
const begin = async (field) => {
|
||||
await page.evaluate((field) => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
owner._beginCctvCalValueEdit(
|
||||
document.querySelector(`[data-cal-field="${field}"]`),
|
||||
);
|
||||
}, field);
|
||||
};
|
||||
for (const [field, step, value] of [
|
||||
['heading', '0.1', '30.0'],
|
||||
['pitch', '0.1', '-10.0'],
|
||||
['fov', '1', '45'],
|
||||
['range', '1', '120'],
|
||||
['height', '1', '8'],
|
||||
['north', '0.1', '0.0'],
|
||||
['east', '0.1', '0.0'],
|
||||
]) {
|
||||
await begin(field);
|
||||
const result = await page.$eval('.cctv-cal-input', (input) => ({
|
||||
step: input.step,
|
||||
value: input.value,
|
||||
native: input.type === 'number',
|
||||
shared: input.classList.contains('gev-field'),
|
||||
label: input.getAttribute('aria-label'),
|
||||
focus: document.activeElement === input,
|
||||
}));
|
||||
check(
|
||||
`${field} editor retains precision, initial value and focus`,
|
||||
result.step === step &&
|
||||
result.value === value &&
|
||||
result.native &&
|
||||
result.shared &&
|
||||
!!result.label &&
|
||||
result.focus,
|
||||
result,
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
check(
|
||||
'Escape cancels each field without a calibration write',
|
||||
await page.evaluate(() => window.__qaFormCalls.length === 0),
|
||||
);
|
||||
await begin('heading');
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.type('.cctv-cal-input', '100');
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.styleManager._cctvControls._cctvState.activeCamera.basePose.headingDeg = 60;
|
||||
});
|
||||
await page.keyboard.press('Enter');
|
||||
const commit = await page.evaluate(() => window.__qaFormCalls);
|
||||
check(
|
||||
'Enter commits once against the frozen camera base',
|
||||
commit.length === 1 &&
|
||||
commit[0][0].calibration.patch.headingDeg === 80 &&
|
||||
commit[0][0].calibration.cameraId === 'a' &&
|
||||
commit[0][1].origin === 'user',
|
||||
commit,
|
||||
);
|
||||
await begin('north');
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.type('.cctv-cal-input', '12.5');
|
||||
await page.$eval('.cctv-cal-input', (input) => input.blur());
|
||||
const blur = await page.evaluate(() => window.__qaFormCalls);
|
||||
check(
|
||||
'blur commits a decimal offset exactly once',
|
||||
blur.length === 2 && blur[1][0].calibration.patch.offsetNorthM === 12.5,
|
||||
blur,
|
||||
);
|
||||
await begin('east');
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.keyboard.press('Enter');
|
||||
check(
|
||||
'an empty numeric draft does not write calibration',
|
||||
await page.evaluate(() => window.__qaFormCalls.length === 2),
|
||||
);
|
||||
await begin('heading');
|
||||
await page.type('.cctv-cal-input', '90');
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.styleManager._cctvControls._cctvState.activeCameraId =
|
||||
'b';
|
||||
});
|
||||
await page.keyboard.press('Enter');
|
||||
check(
|
||||
'a stale camera editor cannot commit to the next camera',
|
||||
await page.evaluate(() => window.__qaFormCalls.length === 2),
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
if (
|
||||
await page.$eval(
|
||||
'#draw-toggle',
|
||||
(node) => node.getAttribute('aria-pressed') !== 'true',
|
||||
)
|
||||
)
|
||||
await page.click('#draw-toggle');
|
||||
for (const lang of ['en']) {
|
||||
await page.evaluate((lang) => {
|
||||
document.documentElement.lang = lang;
|
||||
}, lang);
|
||||
for (const width of [1440, 620, 390]) {
|
||||
await page.setViewport({ width, height: 900 });
|
||||
const layout = await page.evaluate(() => {
|
||||
const ids = [
|
||||
'scene-select',
|
||||
'cctv-camera-select',
|
||||
'hud-layout-select',
|
||||
'draw-color-select',
|
||||
];
|
||||
return ids.map((id) => {
|
||||
const node = document.getElementById(id),
|
||||
rect = node.getBoundingClientRect(),
|
||||
panel = node.closest('.panel-collapsible')?.getBoundingClientRect();
|
||||
return {
|
||||
id,
|
||||
width: rect.width,
|
||||
fits: rect.width > 0 && rect.width <= (panel?.width || innerWidth),
|
||||
radius: getComputedStyle(node).borderRadius,
|
||||
};
|
||||
});
|
||||
});
|
||||
check(
|
||||
`${lang} fields fit their panels at ${width}px`,
|
||||
layout.every((item) => item.fits),
|
||||
layout,
|
||||
);
|
||||
if (width === 390)
|
||||
await page.screenshot({
|
||||
path: `qa-shots/form-fields/${lang}-${width}.png`,
|
||||
});
|
||||
}
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.lang = 'en';
|
||||
document.documentElement.dataset.uiTheme = 'cyber';
|
||||
});
|
||||
const cyber = await page.$eval(
|
||||
'#scene-select',
|
||||
(node) => getComputedStyle(node).backgroundColor,
|
||||
);
|
||||
check(
|
||||
'Cyber keeps its native field theme',
|
||||
cyber === 'rgba(26, 35, 39, 0.96)',
|
||||
cyber,
|
||||
);
|
||||
await begin('heading');
|
||||
await page.evaluate(() => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
window.__qaDetachedInput = document.querySelector('.cctv-cal-input');
|
||||
owner.destroy();
|
||||
window.__qaDetachedInput.dispatchEvent(new Event('blur'));
|
||||
});
|
||||
check(
|
||||
'disposal removes the editor and prevents late commits',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
!document.querySelector('.cctv-cal-input') &&
|
||||
window.__qaFormCalls.length === 2,
|
||||
),
|
||||
);
|
||||
// Scene selection can restore application settings. Exercise it after the
|
||||
// fixture-owned CCTV phase so those asynchronous effects cannot replace ports.
|
||||
page.once('dialog', (dialog) => dialog.accept('Baseline selection fixture'));
|
||||
await page.click('#scene-new-btn');
|
||||
await page.waitForFunction(() =>
|
||||
[...document.querySelectorAll('#scene-select option')].some(
|
||||
(option) => option.textContent === 'Baseline selection fixture',
|
||||
),
|
||||
);
|
||||
page.once('dialog', (dialog) => dialog.accept('QA selection fixture'));
|
||||
await page.click('#scene-new-btn');
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('#scene-select option').length > 1,
|
||||
);
|
||||
const targetSceneId = await page.$eval(
|
||||
'#scene-select',
|
||||
(node) =>
|
||||
[...node.options].find(
|
||||
(option) => option.textContent === 'QA selection fixture',
|
||||
).value,
|
||||
);
|
||||
const baselineSceneId = await page.$eval(
|
||||
'#scene-select',
|
||||
(node) =>
|
||||
[...node.options].find(
|
||||
(option) => option.textContent === 'Baseline selection fixture',
|
||||
).value,
|
||||
);
|
||||
await page.select('#scene-select', baselineSceneId);
|
||||
await page.waitForFunction(
|
||||
(id) => window.__godsEyeView.sceneDirector._getSelectedScene().id !== id,
|
||||
{},
|
||||
targetSceneId,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__qaFormChanges = [];
|
||||
});
|
||||
await page.focus('#scene-select');
|
||||
// Typeahead is native even when headless Chrome does not drive OS popups.
|
||||
await page.keyboard.press('q');
|
||||
const sceneSelection = await page.evaluate(() => ({
|
||||
id: document.getElementById('scene-select').value,
|
||||
selected: window.__godsEyeView.sceneDirector._getSelectedScene().id,
|
||||
changes: window.__qaFormChanges,
|
||||
focus: document.activeElement.id,
|
||||
outline: getComputedStyle(document.activeElement).outlineStyle,
|
||||
}));
|
||||
check(
|
||||
'native typeahead reaches the Scene owner and keeps focus',
|
||||
sceneSelection.id === targetSceneId &&
|
||||
sceneSelection.selected === targetSceneId &&
|
||||
sceneSelection.changes.at(-1) === targetSceneId &&
|
||||
sceneSelection.focus === 'scene-select',
|
||||
sceneSelection,
|
||||
);
|
||||
check('keyboard focus remains visible', sceneSelection.outline === 'solid');
|
||||
const localized = new URL(page.url());
|
||||
localized.searchParams.set('lang', 'ru');
|
||||
await page.goto(localized.href, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
if (
|
||||
await page.$eval(
|
||||
'#draw-toggle',
|
||||
(node) => node.getAttribute('aria-pressed') !== 'true',
|
||||
)
|
||||
)
|
||||
await page.click('#draw-toggle');
|
||||
const translated = await page.evaluate(() => {
|
||||
const nodes = [
|
||||
'scene-select',
|
||||
'cctv-camera-select',
|
||||
'hud-layout-select',
|
||||
'draw-color-select',
|
||||
].map((id) => document.getElementById(id));
|
||||
return (
|
||||
document.documentElement.lang === 'ru' &&
|
||||
nodes.every(
|
||||
(node) =>
|
||||
node.clientWidth > 0 &&
|
||||
node.getBoundingClientRect().width <=
|
||||
node.closest('.panel-collapsible').getBoundingClientRect().width,
|
||||
)
|
||||
);
|
||||
});
|
||||
check('real Russian labels and fields fit at 390px', translated);
|
||||
await page.screenshot({ path: 'qa-shots/form-fields/ru-390.png' });
|
||||
check('no uncaught browser errors', errors.length === 0, errors);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -1884,7 +1884,7 @@ async function runBrowserGroup(record) {
|
||||
|
||||
await step('C15', async () => {
|
||||
// CRT/NVG/FLIR are DISPLAY LABELS; the real style ids are retro /
|
||||
// surveillance / thermal (src/ui.js STYLE_STATUS_LABELS). Passing the
|
||||
// surveillance / thermal (src/ui/visualPresets.js STYLE_STATUS_LABELS). Passing the
|
||||
// labels made every call a silent no-op that still "passed" — so assert
|
||||
// the returned visual state actually changed to the requested style.
|
||||
const wanted = [['retro', 'CRT'], ['surveillance', 'NVG'], ['thermal', 'FLIR'], ['normal', 'Normal']];
|
||||
|
||||
@@ -86,14 +86,6 @@ function percentile(values, pct) {
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * pct) - 1)];
|
||||
}
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function analyzeMembership(samples) {
|
||||
let replacements = 0;
|
||||
let exposure = 0;
|
||||
|
||||
@@ -156,14 +156,50 @@ const pressTabs = async (count, backwards = false) => {
|
||||
// The row scrolls smoothly when native Tab brings an edge button into view.
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
};
|
||||
// Phones (≤720px) present the dock's Presets inline in the More tab's sheet.
|
||||
// It opens from the keyboard, as a keyboard user would, so the focus it moves
|
||||
// into the sheet shows a ring.
|
||||
const openPhoneMoreSheet = async () => {
|
||||
const tab = '#mobile-tab-bar [data-mobile-sheet="more"]';
|
||||
if (await page.evaluate(() => document.body.dataset.mobileSheet === 'more')) {
|
||||
await page.focus(tab);
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
await page.focus(tab);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.evaluate(() => new Promise((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
||||
}));
|
||||
};
|
||||
|
||||
// Media-query changes reach the page on a rendering step; wait until the
|
||||
// phone shell agrees with the viewport before measuring.
|
||||
const waitForShellSettled = () => page.waitForFunction(() => (
|
||||
document.body.classList.contains('mobile-shell')
|
||||
=== matchMedia('(max-width: 720px)').matches
|
||||
), { timeout: 10_000, polling: 100 });
|
||||
|
||||
// The dock slides back into place when the layout leaves the phone shell.
|
||||
const waitForDockSettled = () => page.waitForFunction(() => (
|
||||
!document.getElementById('command-dock').getAnimations({ subtree: true })
|
||||
.some((animation) => animation.playState === 'running')
|
||||
), { timeout: 5_000 });
|
||||
|
||||
const checkResponsiveStyleFocus = async (width) => {
|
||||
// The disclosure is only the starting boundary. Every style target below is
|
||||
// reached by actual Tab events, never by focusing the style under test.
|
||||
const selectedMapBefore = await page.evaluate(() => (
|
||||
window.__godsEyeView.styleManager.mapStackController.getActiveId()
|
||||
));
|
||||
await page.focus('#control-panel-toggle');
|
||||
await pressTabs(2); // disclosure -> pin -> Normal
|
||||
if (width <= 720) {
|
||||
// Phones show Presets in the More tab's sheet, which focuses its first
|
||||
// visible control (Normal) when it opens; the tray toggle and pin are
|
||||
// desktop chrome there.
|
||||
await openPhoneMoreSheet();
|
||||
} else {
|
||||
await page.focus('#control-panel-toggle');
|
||||
await pressTabs(2); // disclosure -> pin -> Normal
|
||||
}
|
||||
for (const [index, style] of ['normal', 'thermal', 'snow'].entries()) {
|
||||
if (index) await pressTabs(3);
|
||||
const state = await styleFocusMetrics();
|
||||
@@ -215,7 +251,16 @@ const layerFocusState = async (id) => ({
|
||||
...await page.evaluate((layerId) => {
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
const button = document.querySelector(`[data-layer-id="${layerId}"] .data-toggle-btn`);
|
||||
const list = document.getElementById('data-toggles');
|
||||
// The list scrolls on its own on desktop; in a phone sheet the sheet
|
||||
// scrolls instead. Measure whichever container actually scrolls.
|
||||
let list = document.getElementById('data-toggles');
|
||||
for (let node = list; node; node = node.parentElement) {
|
||||
const overflow = getComputedStyle(node).overflowY;
|
||||
if ((overflow === 'auto' || overflow === 'scroll') && node.scrollHeight > node.clientHeight) {
|
||||
list = node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
lifecycle: manager.getLayerLifecycleState(layerId),
|
||||
feedState: button?.dataset.feedState, label: button?.textContent.trim(),
|
||||
@@ -1146,6 +1191,8 @@ try {
|
||||
|
||||
await page.setViewport({ width: 620, height: 900, deviceScaleFactor: 1 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 180));
|
||||
await waitForShellSettled();
|
||||
await openPhoneMoreSheet();
|
||||
const at620 = await trayMetrics();
|
||||
check(
|
||||
'620 px tray uses two rows without clipping',
|
||||
@@ -1179,7 +1226,13 @@ try {
|
||||
await page.screenshot({ path: path.join(shotsDir, '480-open.png') });
|
||||
await checkResponsiveStyleFocus(480);
|
||||
|
||||
// Pinning and the compact wing are the narrow desktop dock (721–900 px);
|
||||
// phones use the tab bar instead.
|
||||
await page.setViewport({ width: 800, height: 900, deviceScaleFactor: 1 });
|
||||
await waitForShellSettled();
|
||||
await waitForDockSettled();
|
||||
await page.click('.dock-pin-btn[data-pin-target="control-panel"]');
|
||||
await waitForDockSettled();
|
||||
await page.click('#control-panel-toggle');
|
||||
const cdp = await page.createCDPSession();
|
||||
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
|
||||
@@ -1374,6 +1427,27 @@ try {
|
||||
|
||||
for (const width of [1000, 620, 480]) {
|
||||
await page.setViewport({ width, height: 900, deviceScaleFactor: 1 });
|
||||
await waitForShellSettled();
|
||||
if (width <= 720) {
|
||||
// Phones present Location as the Search tab's sheet: the keyboard opens
|
||||
// it onto the field, and Escape returns focus to the tab.
|
||||
const searchTab = '#mobile-tab-bar [data-mobile-sheet="search"]';
|
||||
await page.focus(searchTab);
|
||||
await page.keyboard.press('Enter');
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
const field = await focusMetrics('#location-search');
|
||||
check(`Search: ${width} px keyboard opens the sheet on the location field`,
|
||||
field.focusedId === 'location-search' && field.visible && field.insideViewport,
|
||||
JSON.stringify(field));
|
||||
await page.screenshot({ path: path.join(shotsDir, `${width}-search-open.png`) });
|
||||
await page.keyboard.press('Escape');
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
const tab = await focusMetrics(searchTab);
|
||||
const closedSheet = await page.evaluate(() => !document.body.dataset.mobileSheet);
|
||||
check(`Search: ${width} px Escape closes the sheet with a visible ring on its tab`,
|
||||
closedSheet && hasVisibleControlFocus(tab), JSON.stringify({ closedSheet, tab }));
|
||||
continue;
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.setPanelCollapsed('control-panel', true, { persist: false, syncShare: false });
|
||||
@@ -1489,6 +1563,12 @@ try {
|
||||
if (dataSetup.ready) {
|
||||
for (const width of [1000, 620, 480]) {
|
||||
await page.setViewport({ width, height: 900, deviceScaleFactor: 1 });
|
||||
await waitForShellSettled();
|
||||
if (width <= 720 && !(await page.evaluate(() => document.body.dataset.mobileSheet === 'layers'))) {
|
||||
// Phones present Data Layers in the Layers tab's sheet.
|
||||
await page.focus('#mobile-tab-bar [data-mobile-sheet="layers"]');
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false }));
|
||||
// Resizing schedules rail placement on animation frames. Wait for that
|
||||
// pass and its CSS transitions before comparing a focus rectangle with
|
||||
@@ -1549,6 +1629,7 @@ try {
|
||||
)));
|
||||
|
||||
await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 });
|
||||
await waitForShellSettled();
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false }));
|
||||
await page.focus('#data-panel .panel-collapse-btn');
|
||||
const transitionTab = await tabTo(`[data-layer-id="${dataSetup.transitionId}"] .data-toggle-btn`);
|
||||
|
||||
@@ -306,7 +306,16 @@ try {
|
||||
await observeNetwork(page);
|
||||
page.on('pageerror', (error) => result.errors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error' && result.consoleErrors.length < 30)
|
||||
if (message.type() !== 'error') return;
|
||||
// A keyless server does not offer optional Google tokens. Keep every other
|
||||
// HTTP or JavaScript error visible, including failures of this route other
|
||||
// than its expected capability-miss 404.
|
||||
const optionalTokenMiss =
|
||||
message.location().url === new URL('/api/google/tiles-token', url).href &&
|
||||
/^Failed to load resource: the server responded with a status of 404\b/.test(
|
||||
message.text(),
|
||||
);
|
||||
if (!optionalTokenMiss && result.consoleErrors.length < 30)
|
||||
result.consoleErrors.push(message.text());
|
||||
});
|
||||
page.on('requestfailed', (request) => {
|
||||
|
||||
@@ -1291,8 +1291,6 @@ async function main() {
|
||||
const clusterBadge = await page.evaluate(async () => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
|
||||
const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js');
|
||||
const { radioStationIdFromPick } = await import('/src/data/radio.js');
|
||||
const baselineSolveRevision = getWorldOverlayDiagnostics().solveRevision;
|
||||
const source = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index))
|
||||
.find((item) => item.name === 'Radio stations');
|
||||
|
||||
@@ -66,7 +66,8 @@ try {
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.click('#scene-capture-btn');
|
||||
await page.focus('#scene-capture-btn');
|
||||
await page.keyboard.press('Space');
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('.scene-shot-row').length === 1,
|
||||
);
|
||||
@@ -328,6 +329,9 @@ try {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.screenshot({ path: path.join(shots, 'desktop.png') });
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
// Phones present Scenes in the Layers tab's sheet.
|
||||
await page.click('#mobile-tab-bar [data-mobile-sheet="layers"]');
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
await page.screenshot({ path: path.join(shots, 'narrow.png') });
|
||||
check(
|
||||
'Scene controls stay inside the narrow viewport',
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Lit airplane regression: StarrySky must match a black environment baseline.
|
||||
* A white environment is a positive control: it must visibly brighten the
|
||||
* same model. Each case uses a fresh scene and dynamic environment map.
|
||||
* Usage: node scripts/qa-sky-model-lighting.mjs [--url http://localhost:4333/]
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const i = process.argv.indexOf('--url');
|
||||
const base = i > 0 ? process.argv[i + 1] : 'http://localhost:4333/';
|
||||
const url = new URL('/__qa_sky_model__', base);
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--ignore-gpu-blocklist'],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
page.on('pageerror', (error) => console.log('PAGEERR', error.message));
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
if (request.url() === url.href)
|
||||
request.respond({
|
||||
contentType: 'text/html',
|
||||
body: '<!doctype html><html><body></body></html>',
|
||||
});
|
||||
else if (new URL(request.url()).origin !== url.origin) request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
await page.goto(url.href);
|
||||
const results = await page.evaluate(async () => {
|
||||
window.CESIUM_BASE_URL = '/cesium/';
|
||||
// Use StarrySky's exact Vite dependency URL, including its version query.
|
||||
const source = await fetch('/src/sky/starrySky.js').then((r) => r.text());
|
||||
const cesiumUrl = source.match(
|
||||
/from ["']([^"']*\/cesium\.js[^"']*)["']/,
|
||||
)?.[1];
|
||||
if (!cesiumUrl) throw new Error('run against the Vite dev server');
|
||||
const C = await import(cesiumUrl);
|
||||
const { StarrySky } = await import('/src/sky/starrySky.js');
|
||||
const time = C.JulianDate.fromIso8601('2026-10-19T00:00:00Z');
|
||||
const results = {};
|
||||
for (const kind of ['black', 'starry', 'white']) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 400;
|
||||
canvas.height = 300;
|
||||
document.body.append(canvas);
|
||||
const scene = new C.Scene({ canvas });
|
||||
scene.rethrowRenderErrors = true;
|
||||
let cubeMap;
|
||||
try {
|
||||
scene.backgroundColor = C.Color.BLACK;
|
||||
scene.light = new C.DirectionalLight({
|
||||
direction: new C.Cartesian3(1, 0, 0),
|
||||
intensity: 0.2,
|
||||
});
|
||||
if (kind === 'starry') {
|
||||
scene.skyBox = new StarrySky({ requestRender() {} });
|
||||
scene.skyBox._loading = true; // visible sky is outside this probe
|
||||
} else {
|
||||
const value = kind === 'white' ? 255 : 0;
|
||||
const face = {
|
||||
width: 1,
|
||||
height: 1,
|
||||
arrayBufferView: new Uint8Array([value, value, value, 255]),
|
||||
};
|
||||
cubeMap = new C.CubeMap({
|
||||
context: scene.context,
|
||||
source: Object.fromEntries(
|
||||
[...C.CubeMap.faceNames()].map((name) => [name, face]),
|
||||
),
|
||||
});
|
||||
scene.skyBox = { _cubeMap: cubeMap, update() {}, destroy() {} };
|
||||
}
|
||||
const position = C.Cartesian3.fromDegrees(0, 0, 10000);
|
||||
const model = await C.Model.fromGltfAsync({
|
||||
url: '/models/airplane.glb',
|
||||
modelMatrix: C.Transforms.eastNorthUpToFixedFrame(position),
|
||||
});
|
||||
scene.primitives.add(model);
|
||||
scene.camera.lookAt(position, new C.HeadingPitchRange(0.4, -0.5, 45));
|
||||
let framed = false;
|
||||
for (let frame = 0; frame < 240; frame++) {
|
||||
scene.initializeFrame();
|
||||
scene.render(time);
|
||||
if (model.ready && !framed) {
|
||||
scene.camera.viewBoundingSphere(
|
||||
model.boundingSphere,
|
||||
new C.HeadingPitchRange(
|
||||
0.4,
|
||||
-0.5,
|
||||
model.boundingSphere.radius * 3,
|
||||
),
|
||||
);
|
||||
framed = true;
|
||||
}
|
||||
await new Promise(requestAnimationFrame);
|
||||
}
|
||||
if (!model.ready || !model.environmentMapManager.radianceCubeMap)
|
||||
throw new Error(`${kind}: model or dynamic lighting did not load`);
|
||||
scene.initializeFrame();
|
||||
scene.render(time);
|
||||
const gl = scene.context._gl;
|
||||
const px = new Uint8Array(canvas.width * canvas.height * 4);
|
||||
gl.readPixels(
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
px,
|
||||
);
|
||||
let total = 0;
|
||||
let visible = 0;
|
||||
for (let p = 0; p < px.length; p += 4) {
|
||||
const luma = 0.299 * px[p] + 0.587 * px[p + 1] + 0.114 * px[p + 2];
|
||||
total += luma;
|
||||
if (luma > 1) visible++;
|
||||
}
|
||||
results[kind] = { total, visible };
|
||||
} finally {
|
||||
scene.destroy();
|
||||
cubeMap?.destroy();
|
||||
canvas.remove();
|
||||
}
|
||||
}
|
||||
return results;
|
||||
});
|
||||
console.log(JSON.stringify(results));
|
||||
assert.ok(results.black.visible > 100, 'the lit model must be visible');
|
||||
assert.ok(
|
||||
Math.abs(results.starry.total - results.black.total) <=
|
||||
results.black.total * 0.01,
|
||||
'StarrySky must match black environment lighting within 1%',
|
||||
);
|
||||
assert.ok(
|
||||
results.white.total > results.black.total * 1.03,
|
||||
'white environment control must brighten the model by at least 3%',
|
||||
);
|
||||
console.log(
|
||||
'PASS: lit airplane matches black sky; white control is brighter',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env node
|
||||
/** Badge acceptance on dev/preview. Camera state is a disposable controller fixture. */
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
let checks = 0;
|
||||
let failures = 0;
|
||||
const check = (name, passed, detail) => {
|
||||
checks++;
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!passed) {
|
||||
failures++;
|
||||
console.log(JSON.stringify(detail));
|
||||
}
|
||||
};
|
||||
const out = 'qa-shots/status-badges';
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
try {
|
||||
for (const lang of ['en', 'ru']) {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://127.0.0.1:4189'}/?welcome=0&lang=${lang}`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const { dataManager, styleManager: manager } = window.__godsEyeView;
|
||||
await dataManager.setEnabled('wind', true);
|
||||
manager.setPanelCollapsed('weather-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
manager.setPanelCollapsed('cctv-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
const owner = manager._cctvControls;
|
||||
owner._cctvUnsubscribe?.();
|
||||
owner._cctvUnsubscribe = null;
|
||||
owner.actions = { ...owner.actions, isEnabled: () => true };
|
||||
owner._renderCctvState({
|
||||
enabled: true,
|
||||
activeCameraId: 'badge-fixture',
|
||||
cameras: [{ id: 'badge-fixture', city: 'QA', name: 'Badge fixture' }],
|
||||
activeCamera: {
|
||||
id: 'badge-fixture',
|
||||
city: 'QA',
|
||||
headingDeg: 30,
|
||||
pitchDeg: -10,
|
||||
fovDeg: 45,
|
||||
rangeM: 120,
|
||||
mountHeightM: 8,
|
||||
sourceKind: 'snapshot',
|
||||
sourceStatus: 'degraded',
|
||||
calBadge: 'curated',
|
||||
calDirty: false,
|
||||
},
|
||||
});
|
||||
window.__qaBadgeText =
|
||||
document.getElementById('cctv-source-badge').textContent;
|
||||
});
|
||||
await page.waitForSelector('.weather-coverage.gev-status-badge');
|
||||
const inventory = await page.evaluate(() => {
|
||||
const source = document.getElementById('cctv-source-badge');
|
||||
const quality = document.getElementById('cctv-quality-chip');
|
||||
const coverage = [...document.querySelectorAll('.weather-coverage')];
|
||||
return {
|
||||
shared: [source, quality, ...coverage].every((node) =>
|
||||
node.classList.contains('gev-status-badge'),
|
||||
),
|
||||
live:
|
||||
source.getAttribute('role') === 'status' &&
|
||||
source.getAttribute('aria-live') === 'polite',
|
||||
silent: [quality, ...coverage].every(
|
||||
(node) =>
|
||||
!node.hasAttribute('aria-live') &&
|
||||
!node.hasAttribute('role') &&
|
||||
!node.hasAttribute('tabindex'),
|
||||
),
|
||||
coverage:
|
||||
coverage.length > 0 &&
|
||||
coverage.every(
|
||||
(node) =>
|
||||
node.textContent &&
|
||||
!node.hidden &&
|
||||
node.classList.contains('gev-status-badge--plain'),
|
||||
),
|
||||
source: source.textContent,
|
||||
};
|
||||
});
|
||||
check(
|
||||
`${lang}: CCTV and populated Weather badges share the view`,
|
||||
inventory.shared && inventory.coverage,
|
||||
inventory,
|
||||
);
|
||||
check(
|
||||
`${lang}: existing announcement and read-only semantics remain intact`,
|
||||
inventory.live && inventory.silent,
|
||||
inventory,
|
||||
);
|
||||
check(
|
||||
`${lang}: source wording uses the active locale`,
|
||||
lang === 'en'
|
||||
? inventory.source === 'SNAPSHOT · DEGRADED'
|
||||
: inventory.source !== 'SNAPSHOT · DEGRADED',
|
||||
inventory,
|
||||
);
|
||||
|
||||
for (const state of ['loading', 'error', 'retained', 'ready', 'idle']) {
|
||||
const result = await page.evaluate((state) => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
owner._cctvFrame.dataset.loading = state === 'loading' ? 'true' : '';
|
||||
owner._cctvFrame.dataset.error = ['error', 'retained'].includes(state)
|
||||
? 'true'
|
||||
: '';
|
||||
owner._cctvFrameWrap.classList.toggle(
|
||||
'has-frame',
|
||||
state === 'retained',
|
||||
);
|
||||
owner._syncCctvSourceBadge(
|
||||
owner._cctvState.activeCamera,
|
||||
state !== 'idle',
|
||||
);
|
||||
const badge = document.getElementById('cctv-source-badge');
|
||||
return {
|
||||
state: badge.dataset.frameState,
|
||||
text: badge.textContent,
|
||||
color: getComputedStyle(badge).color,
|
||||
};
|
||||
}, state);
|
||||
const expected = {
|
||||
loading: ['loading', 'rgb(255, 229, 155)'],
|
||||
error: ['error', 'rgb(255, 176, 176)'],
|
||||
retained: ['ready', 'rgb(157, 239, 255)'],
|
||||
ready: ['ready', 'rgb(157, 239, 255)'],
|
||||
idle: ['idle', 'rgb(157, 239, 255)'],
|
||||
}[state];
|
||||
check(
|
||||
`${lang}: ${state} keeps its frame state and palette`,
|
||||
result.state === expected[0] &&
|
||||
result.color === expected[1] &&
|
||||
(!['ready', 'retained'].includes(state) ||
|
||||
result.text === inventory.source),
|
||||
result,
|
||||
);
|
||||
}
|
||||
for (const [calBadge, dirty, color] of [
|
||||
['raw-prior', false, 'rgb(143, 231, 255)'],
|
||||
['curated', false, 'rgb(255, 230, 162)'],
|
||||
['calibrated', false, 'rgb(173, 255, 191)'],
|
||||
['curated', true, 'rgb(255, 208, 138)'],
|
||||
]) {
|
||||
const result = await page.evaluate(
|
||||
({ calBadge, dirty }) => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
document.getElementById('cctv-next-btn').focus();
|
||||
owner._renderCctvState({
|
||||
...owner._cctvState,
|
||||
activeCamera: {
|
||||
...owner._cctvState.activeCamera,
|
||||
calBadge,
|
||||
calDirty: dirty,
|
||||
},
|
||||
});
|
||||
const badge = document.getElementById('cctv-quality-chip');
|
||||
return {
|
||||
state: badge.dataset.calBadge,
|
||||
color: getComputedStyle(badge).color,
|
||||
focus: document.activeElement.id,
|
||||
};
|
||||
},
|
||||
{ calBadge, dirty },
|
||||
);
|
||||
check(
|
||||
`${lang}: ${dirty ? 'edited' : calBadge} keeps provenance color and control focus`,
|
||||
result.state === (dirty ? 'edited' : calBadge) &&
|
||||
result.color === color &&
|
||||
result.focus === 'cctv-next-btn',
|
||||
result,
|
||||
);
|
||||
}
|
||||
for (const theme of ['tactical', 'cyber']) {
|
||||
await page.evaluate(
|
||||
(theme) => window.__godsEyeView.styleManager._setHudVariant(theme),
|
||||
theme,
|
||||
);
|
||||
for (const width of [1440, 620, 390]) {
|
||||
await page.setViewport({ width, height: 900 });
|
||||
for (const panelId of ['cctv-panel', 'weather-panel']) {
|
||||
await page.evaluate(async (panelId) => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
for (const other of ['cctv-panel', 'weather-panel']) {
|
||||
if (other !== panelId)
|
||||
manager.setPanelCollapsed(other, true, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
}
|
||||
manager.setPanelCollapsed(panelId, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
}, panelId);
|
||||
const layout = await page.evaluate((panelId) => {
|
||||
const selector =
|
||||
panelId === 'cctv-panel'
|
||||
? '#cctv-source-badge, #cctv-quality-chip'
|
||||
: '.weather-coverage';
|
||||
return [...document.querySelectorAll(selector)].map((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
const panel = node.closest(`#${panelId}`).getBoundingClientRect();
|
||||
return {
|
||||
id: node.id || 'weather-coverage',
|
||||
text: node.textContent,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
scroll: node.scrollWidth,
|
||||
client: node.clientWidth,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
panelLeft: panel.left,
|
||||
panelRight: panel.right,
|
||||
fits:
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
rect.left >= panel.left - 1 &&
|
||||
rect.right <= panel.right + 1 &&
|
||||
node.scrollWidth <= node.clientWidth + 1,
|
||||
};
|
||||
});
|
||||
}, panelId);
|
||||
check(
|
||||
`${lang}/${theme}: ${panelId} badges fit at ${width}px`,
|
||||
layout.length >= (panelId === 'cctv-panel' ? 2 : 1) &&
|
||||
layout.every((node) => node.fits),
|
||||
layout,
|
||||
);
|
||||
if (width === 390)
|
||||
await page.screenshot({
|
||||
path: `${out}/${lang}-${theme}-${panelId}-${width}.png`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const disposed = await page.evaluate(() => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
const badge = document.getElementById('cctv-quality-chip');
|
||||
const text = badge.textContent;
|
||||
owner.destroy();
|
||||
owner._renderCctvState(null);
|
||||
return badge.textContent === text;
|
||||
});
|
||||
check(`${lang}: disposal prevents late state painting`, disposed);
|
||||
}
|
||||
check('no uncaught browser errors', errors.length === 0, errors);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`${checks} checks; ${failures} failures`);
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
import {
|
||||
answerKartaViewRequest,
|
||||
describeKartaViewCall,
|
||||
KV_LINE,
|
||||
KV_SEQUENCE_ID,
|
||||
kartaViewPhotos,
|
||||
resetKartaViewFixture,
|
||||
|
||||
@@ -80,9 +80,7 @@ try {
|
||||
page.evaluate(() => {
|
||||
const { viewer, dataManager } = window.__godsEyeView;
|
||||
let upper = 0,
|
||||
lower = 0,
|
||||
near = 0;
|
||||
const camera = viewer.camera.positionWC;
|
||||
lower = 0;
|
||||
dataManager.layers
|
||||
.get('traffic')
|
||||
.module.visitMotionDots((id, key, p) => {
|
||||
|
||||
@@ -48,8 +48,6 @@ import {
|
||||
reduceScenario,
|
||||
reduceSensorContrast,
|
||||
reduceScriptedMotion,
|
||||
reduceMotion,
|
||||
reduceFleet,
|
||||
} from '../src/layers/transit/qaMetrics.js';
|
||||
|
||||
const BASE = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
|
||||
@@ -295,7 +295,7 @@ async function main() {
|
||||
);
|
||||
|
||||
// Wait for the photoreal tileset and at least one vessel refresh.
|
||||
const settled = await page
|
||||
await page
|
||||
.waitForFunction(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const t = gev.tileset;
|
||||
|
||||
@@ -8,13 +8,7 @@
|
||||
* node scripts/qa-weather-swap.mjs --url http://localhost:4173 --label google
|
||||
* [--layer weather-satellite] [--product clouds] [--headless]
|
||||
*/
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
|
||||
@@ -61,6 +61,10 @@ const browser = await puppeteer.launch({
|
||||
|
||||
const boot = async () => {
|
||||
const page = await browser.newPage();
|
||||
// This probe measures active rendering; an OS-minimized window must not
|
||||
// silently exercise the application's intentional hidden-page pause.
|
||||
const focus = await page.createCDPSession();
|
||||
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
||||
await page.setViewport({ width: 1400, height: 920, deviceScaleFactor: 1 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) =>
|
||||
@@ -141,7 +145,7 @@ const snapshot = (page) =>
|
||||
),
|
||||
},
|
||||
weather,
|
||||
windCells: wind.gpu?.pathCount ?? wind.paths ?? null,
|
||||
windCells: wind.gpu?.pathCount ?? wind.paths ?? 0,
|
||||
enabled: [...dm.layers].filter(([, e]) => e.enabled).map(([id]) => id),
|
||||
};
|
||||
});
|
||||
@@ -220,7 +224,7 @@ try {
|
||||
const baseline = await snapshot(a.page);
|
||||
console.log('baseline', JSON.stringify(baseline));
|
||||
for (const id of WEATHER) await setLayer(a.page, id, true);
|
||||
await a.page
|
||||
const weatherReady = await a.page
|
||||
.waitForFunction(
|
||||
() =>
|
||||
['weather-radar', 'weather-satellite', 'weather-lightning'].every(
|
||||
@@ -233,7 +237,13 @@ try {
|
||||
),
|
||||
{ timeout: 120_000 },
|
||||
)
|
||||
.catch(() => console.log(' !! weather not ready'));
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
results.weatherReady = weatherReady;
|
||||
if (!weatherReady)
|
||||
console.log(' !! weather not ready — feature coverage incomplete');
|
||||
await sleep(2000);
|
||||
// exercise: history playback, wind reading, storm selection, product switch
|
||||
await a.page.evaluate(() => {
|
||||
@@ -331,11 +341,25 @@ try {
|
||||
'errors (control run):',
|
||||
results.errorsB.length ? results.errorsB : 'none',
|
||||
);
|
||||
// Resource ownership is an acceptance check; provider availability remains
|
||||
// separately reported diagnostic evidence in both runs.
|
||||
if (
|
||||
!weatherReady ||
|
||||
Object.keys(leak).length ||
|
||||
[...a.errors, ...b.errors].some((e) => e.startsWith('pageerror:'))
|
||||
) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (error) {
|
||||
results.failure = { name: error.name, message: error.message };
|
||||
throw error;
|
||||
} finally {
|
||||
// Preserve completed resource evidence even if a later provider or browser
|
||||
// phase aborts. A partial run still exits nonzero.
|
||||
writeFileSync(
|
||||
path.join(outDir, 'results.json'),
|
||||
JSON.stringify(results, null, 2),
|
||||
);
|
||||
console.log(`wrote ${path.join(outDir, 'results.json')}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
export const ALLOCATION_TEST_FILES = Object.freeze([
|
||||
'src/data/focusAllocations.test.mjs',
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
|
||||
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
||||
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
|
||||
|
||||
@@ -2695,16 +2696,17 @@ async function main() {
|
||||
// reappear wearing its stale/creation-north nose for up to 1 s).
|
||||
// Staged deterministically: park the camera nadir over the synthetic
|
||||
// cluster, let rotations settle, TAMPER a billboard's rotation, then
|
||||
// trigger each path with the camera idle. The idle-drift catch-up
|
||||
// (ROTATION_REFRESH_MS = 1 s) can coincidentally mask a regression in a
|
||||
// ~300 ms SwiftShader frame window, so a regression shows as an
|
||||
// intermittent (not guaranteed) failure — never a false failure.
|
||||
// trigger each path with the camera idle. The fleet runs every 80 ms,
|
||||
// independently of rendered FPS. Observe the first completed settle and
|
||||
// reveal frames within 500 ms of forcing moveEnd, before the 1 s idle
|
||||
// rotation refresh can repair an incorrect first reveal.
|
||||
// ============================================================
|
||||
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
|
||||
const arrival = await evalPage(async () => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
|
||||
const { observeArrivalRotation } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
|
||||
// 3D models OFF for this phase: a model-handed-off billboard is hidden
|
||||
// and skips rotation updates entirely — the probes need live billboards
|
||||
// (this is also the app's default state the field report came from).
|
||||
@@ -2766,39 +2768,29 @@ async function main() {
|
||||
const bb = findBB(id);
|
||||
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
|
||||
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
|
||||
// (a) settle pass: tamper, then raise moveEnd with the camera IDLE —
|
||||
// the pose signature is unchanged, so only the moveEnd hook can fix
|
||||
// this before the 1 s catch-up.
|
||||
bb.rotation = r0 + 1.5;
|
||||
v.camera.moveEnd.raiseEvent();
|
||||
await nextFrames(2);
|
||||
const afterMoveEnd = bb.rotation;
|
||||
// A real-GPU fleet tick can land near the edge of the frame budget and
|
||||
// advance the contact before the probe reads it. Compare with the
|
||||
// production projection at the CURRENT position, not the now-stale r0.
|
||||
const expectedMoveEnd = screenProjectedRotation(v.scene, bb.position, course, null);
|
||||
// (b) reveal pass: tamper + hide — the next fleet tick must flip it
|
||||
// visible AND correct the nose in that same tick (camera still idle,
|
||||
// no moveEnd raised). Diagnostic fields (round 5): record WHICH frame
|
||||
// the flip landed on (flipFrame; -1 = never within the extended
|
||||
// window) and whether a 3D model owns this id — distinguishes "tick
|
||||
// slow under load" from "occluder says hidden" from "model handoff".
|
||||
bb.rotation = r0 + 1.5;
|
||||
bb.show = false;
|
||||
let flipFrame = -1;
|
||||
for (let f = 1; f <= 6; f++) {
|
||||
await nextFrames(1);
|
||||
if (bb.show && flipFrame === -1) flipFrame = f;
|
||||
if (f >= 2 && flipFrame !== -1) break;
|
||||
}
|
||||
const expectedReveal = screenProjectedRotation(v.scene, bb.position, course, null);
|
||||
const observed = await observeArrivalRotation({
|
||||
scene: v.scene,
|
||||
startMoveEnd() {
|
||||
// Only moveEnd can repair the tampered angle at the idle pose.
|
||||
bb.rotation = r0 + 1.5;
|
||||
v.camera.moveEnd.raiseEvent();
|
||||
},
|
||||
startReveal() {
|
||||
// Hide immediately after the confirmed settle frame. The next
|
||||
// eligible fleet tick must reveal AND re-aim in that same tick.
|
||||
bb.rotation = r0 + 1.5;
|
||||
bb.show = false;
|
||||
},
|
||||
read() {
|
||||
return {
|
||||
shown: bb.show,
|
||||
rotationError: angDiff(bb.rotation,
|
||||
screenProjectedRotation(v.scene, bb.position, course, null)),
|
||||
};
|
||||
},
|
||||
});
|
||||
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
|
||||
return {
|
||||
r0,
|
||||
dMoveEnd: angDiff(afterMoveEnd, expectedMoveEnd),
|
||||
dReveal: angDiff(bb.rotation, expectedReveal),
|
||||
shown: bb.show, flipFrame, hasModel,
|
||||
};
|
||||
return { r0, ...observed, hasModel };
|
||||
};
|
||||
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
|
||||
const militaryCourse = window.__SYNTH.military.find((m) => m.hex === 'bbb101')?.track ?? 0;
|
||||
@@ -2807,22 +2799,13 @@ async function main() {
|
||||
military: await probe('bbb101', militaryCourse),
|
||||
};
|
||||
});
|
||||
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? a.error
|
||||
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} hasModel=${a.hasModel}`);
|
||||
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; elapsed=${Number(a.elapsedMs).toFixed(1)} ms`
|
||||
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms hasModel=${a.hasModel}`);
|
||||
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
|
||||
// Reveal budget re-pinned 2 → 4 frames (round 5): the diagnostic probe
|
||||
// shows the military flip consistently lands on frame 2 — the exact edge
|
||||
// of the old budget — so any load hiccup flaked it (flights lands frame
|
||||
// 1). The regression this protects against is a reveal that waits for
|
||||
// the 1 s idle catch-up (~30-60 frames) or never re-aims; 4 frames still
|
||||
// catches that with a wide margin, with zero-headroom flakes gone.
|
||||
const arrOk = (a) => !!a && !a.error && a.shown
|
||||
&& (a.flipFrame === -1 ? false : a.flipFrame <= 4)
|
||||
&& a.dMoveEnd * 180 / Math.PI < 10 && a.dReveal * 180 / Math.PI < 10;
|
||||
record(`arrival: moveEnd + reveal both re-aim within 4 frames, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||
arrOk(arrival.flights), fmtArr(arrival.flights));
|
||||
record(`arrival: moveEnd + reveal both re-aim within 4 frames, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||
arrOk(arrival.military), fmtArr(arrival.military));
|
||||
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||
arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights));
|
||||
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||
arrivalRotationPassed(arrival.military), fmtArr(arrival.military));
|
||||
|
||||
// ============================================================
|
||||
// DISPLAY FLOOR (2026-08-19): a grounded contact must never render below
|
||||
|
||||
Reference in new issue
Block a user