feat(sky): replace the blocky default sky box with catalogue stars

Cesium's default Tycho-2 sky box is six 1024² JPEGs stretched 3–6× around
the whole-globe view, so space read as an over-compressed JPEG (HAL-627).

The scene now draws its own sky in the Scene#skyBox slot: the 9,096 stars
of the Yale Bright Star Catalogue as point sprites sized in CSS pixels
(magnitude → width and opacity, B−V → colour), over a soft, star-free
Milky Way cube map built from ESO/S. Brunier's panorama (CC BY 4.0). Both
layers share one ICRF→Earth-fixed rotation at the scene clock, with the
TEME rotation filling in until Cesium's IAU 2006 tables load.

Assets total 482 KB, built by scripts/build-bright-stars.mjs and
scripts/build-milky-way.mjs. Frame time and the idle render governor are
unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-19 11:08:46 +04:00
1 parent 93ea088f45
commit 6549344946
20 files changed
+10644 -2

No files matched your search

+16
View File
@@ -198,6 +198,8 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
| **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) | | **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) |
| **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) | | **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
| **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) | | **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
| **Yale Bright Star Catalogue** (9,096 stars, sky background) | `sky/` | **Public domain** | ✅ (no restrictions) | "Yale Bright Star Catalogue (Hoffleit & Warren, via CDS)" (courtesy credit) |
| **ESO Milky Way panorama** (eso0932a, blurred cube map, sky background) | `sky/` | **CC BY 4.0** | ✅ (attribution; changes stated) | "ESO/S. Brunier" |
| **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — | | **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — |
| **CCTV Camera Database** model-enrichment extract v2.26.0 (17 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) | | **CCTV Camera Database** model-enrichment extract v2.26.0 (17 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) |
@@ -270,6 +272,20 @@ https://www.naturalearthdata.com/about/terms-of-use/). We credit anyway: "Made w
Earth". Registration in the in-app `dataCredits.js` attribution list ships with the resolver Earth". Registration in the in-app `dataCredits.js` attribution list ships with the resolver
wiring (see below). wiring (see below).
### Sky background (`sky/`)
The space behind the globe is drawn from two bundled sources (details, hashes
and build commands in [`sky/README.md`](src/data/local_data/sky/README.md)):
- **Stars:** the Yale Bright Star Catalogue, 5th Revised Edition (Hoffleit &
Warren 1991, CDS catalogue V/50) — public domain; credited as a courtesy.
- **Milky Way glow:** ESO image eso0932a, "The Milky Way panorama" — **CC BY
4.0, credit "ESO/S. Brunier"**. Modified: stars removed, blurred, reprojected
to ICRF cube faces and tone-mapped, as the folder README states.
NASA SVS "Deep Star Maps 2020" was not used for the glow: its Milky Way layer
derives from Gaia DR2, which ESA licenses CC BY-NC 3.0 IGO.
### DataSF Analysis Neighborhoods (`neighborhoods/`) ### DataSF Analysis Neighborhoods (`neighborhoods/`)
`neighborhoods/san-francisco.json` bundles the City & County of San Francisco's official `neighborhoods/san-francisco.json` bundles the City & County of San Francisco's official
+158
View File
@@ -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 DATA_SOURCES.md 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();
+372
View File
@@ -0,0 +1,372 @@
#!/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 and convert sRGB to linear light.
* 2. Paint out the planets the 2008–2010 exposures caught (PLANETS): they
* sit where today's sky has nothing and are far brighter than any star.
* 3. Remove stars with a morphological opening (minimum, then maximum, over
* a (2·OPEN_RADIUS+1)² window): anything smaller than the window — every
* star — drops to the surrounding sky; the renderer draws catalogue stars
* itself, sharp and in the right place.
* 4. Blur with a three-pass box approximation of a Gaussian, σ = BLUR_DEG on
* the sky: rows wrap in longitude and widen by 1/cos(latitude) so the
* blur stays round towards the poles.
* 5. Resample into six FACE_SIZE² cube faces in the standard WebGL face
* orientation (the renderer uploads them without flipY), with the ICRF
* axes as the cube axes — +X to the March equinox, +Z to the north
* celestial pole — rotating each direction into galactic coordinates
* with the IAU J2000 matrix.
* 6. Map the darkest 25 % of the sky to black and the brightest 0.01 % to
* white, encode with gamma 1/2.2 and ±0.5 LSB dither against banding;
* 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;
const WORK_WIDTH = 4096;
const OPEN_RADIUS = 3;
const WEBP_QUALITY = 96;
// The darkest quarter of the sky is the black level: away from the galactic
// plane the panorama carries a mottled glow of unresolved stars that would
// read as grey fog around the globe.
const BLACK_PERCENTILE = 0.25;
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 mean of the ring just outside it, per
* channel. 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 + 0.5) * Math.PI) / 180);
const disc = [];
const ring = [];
const span = Math.ceil(((radiusDeg + 0.5) / 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) {
let sum = 0;
for (const index of ring) sum += plane[index];
const mean = sum / ring.length;
for (const index of disc) plane[index] = mean;
}
}
/** 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;
}
/** 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 planet of PLANETS) paintOut(planes, width, height, planet);
for (const plane of planes) {
extremumPlane(plane, width, height, OPEN_RADIUS, Math.min);
extremumPlane(plane, width, height, OPEN_RADIUS, Math.max);
blurPlane(plane, width, height, sigmaPx);
}
const black = luminancePercentile(planes, BLACK_PERCENTILE);
const white = luminancePercentile(planes, WHITE_PERCENTILE);
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;
for (let c = 0; c < 3; c++) {
const linear = Math.max(
(samplePlane(planes[c], width, height, u, v) - black) /
(white - black),
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();
+6 -1
View File
@@ -1062,5 +1062,10 @@
"src/ui/flyToDataSource.test.mjs", "src/ui/flyToDataSource.test.mjs",
"scripts/assemble-changelog.mjs", "scripts/assemble-changelog.mjs",
"src/tooling/changelogFragments.test.mjs", "src/tooling/changelogFragments.test.mjs",
"src/data/gbfsProxyRateLimit.test.mjs" "src/data/gbfsProxyRateLimit.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"
] ]
+10 -1
View File
@@ -22,7 +22,14 @@
}, },
"viewer": { "viewer": {
"exports": ["./application/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"] "external": ["cesium"]
}, },
"vite-build": { "vite-build": {
@@ -1708,6 +1715,8 @@
"src/app/surfaceServices.js", "src/app/surfaceServices.js",
"src/app/tools.js", "src/app/tools.js",
"src/app/viewer.js", "src/app/viewer.js",
"src/sky/starrySky.js",
"src/sky/stars.js",
"src/app/atmosphereCompat.js", "src/app/atmosphereCompat.js",
"src/bloom.js", "src/bloom.js",
"src/camera.js", "src/camera.js",
+5
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js'; import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
import { StarrySky } from '../sky/starrySky.js';
const PINCH_ZOOM_MULTIPLIER = 8; const PINCH_ZOOM_MULTIPLIER = 8;
const MAX_PINCH_PIXEL_DELTA = 120; const MAX_PINCH_PIXEL_DELTA = 120;
@@ -119,6 +120,9 @@ export function createApplicationViewer({ container, creditContainer }) {
selectionIndicator: false, selectionIndicator: false,
infoBox: false, infoBox: false,
baseLayer: false, baseLayer: false,
// Replaced below: the default Tycho-2 cube map is too coarse for the
// whole-globe view and its JPEG blocks show around the Earth.
skyBox: false,
creditContainer, creditContainer,
msaaSamples: 4, msaaSamples: 4,
contextOptions: { webgl: { preserveDrawingBuffer: true } }, contextOptions: { webgl: { preserveDrawingBuffer: true } },
@@ -130,6 +134,7 @@ export function createApplicationViewer({ container, creditContainer }) {
// render loop. See app/atmosphereCompat.js. // render loop. See app/atmosphereCompat.js.
applyModelAtmosphereWorkaround(viewer.scene); applyModelAtmosphereWorkaround(viewer.scene);
viewer.scene.globe.show = false; viewer.scene.globe.show = false;
viewer.scene.skyBox = new StarrySky();
viewer.scene.skyAtmosphere.show = true; viewer.scene.skyAtmosphere.show = true;
viewer.scene.skyAtmosphere.atmosphereLightIntensity = 18; viewer.scene.skyAtmosphere.atmosphereLightIntensity = 18;
viewer.scene.skyAtmosphere.saturationShift = -0.12; viewer.scene.skyAtmosphere.saturationShift = -0.12;
+7
View File
@@ -386,6 +386,13 @@ export const DATA_CREDITS = [
'ECMWF does not accept any liability whatsoever for any error or omission in the data, their availability, or for any loss or damage arising from their use.', 'ECMWF does not accept any liability whatsoever for any error or omission in the data, their availability, or for any loss or damage arising from their use.',
}, },
// ── Bundled snapshots ─────────────────────────────────────────── // ── Bundled snapshots ───────────────────────────────────────────
{
key: 'sky-background',
html:
'Stars: <a href="https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50" target="_blank" rel="noopener">Yale Bright Star Catalogue</a> ' +
'(Hoffleit &amp; Warren, via CDS). Milky Way: <a href="https://www.eso.org/public/images/eso0932a/" target="_blank" rel="noopener">ESO/S. Brunier</a> ' +
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>; stars removed, blurred and reprojected)',
},
{ {
key: 'dams', key: 'dams',
html: 'Dams: Open Infrastructure Map', html: 'Dams: Open Infrastructure Map',
+65
View File
@@ -0,0 +1,65 @@
# Sky background
The space behind the globe (`src/sky/starrySky.js`): catalogue stars drawn as
sharp point sprites over a soft Milky Way cube map. It replaces Cesium's
default Tycho-2 sky box, whose six 1024² JPEGs stretch 3–6× around the
whole-globe view and show compression blocks.
| File | Content | Size |
| ---- | ------- | ---- |
| `bright_stars.json` | 9,096 stars: J2000 RA/Dec, V magnitude, B−V | 235 KB |
| `milkyway_{px,nx,py,ny,pz,nz}.webp` | Milky Way glow, six 1024² cube faces, no stars | 247 KB |
## Stars — Yale Bright Star Catalogue
**Source:** Yale Bright Star Catalogue, 5th Revised Edition (Hoffleit &
Warren 1991), CDS VizieR catalogue
[V/50](https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50), file
`https://cdsarc.cds.unistra.fr/ftp/V/50/catalog.gz` (sha256
`3dc44b1e90be8fbe5bcc7656032560f51275f985c7e3f783c9028e1838ec7bed`, fetched
2026-10-19).
**License:** public domain (Yale University Observatory / NASA Astronomical
Data Center). CDS asks for an acknowledgement; the in-app "Data attribution"
popover carries it.
**Build:** `node scripts/build-bright-stars.mjs [catalog.gz]` — every record
with a J2000 position (14 novae and non-stellar entries dropped), RA/Dec
rounded to 0.001°, magnitudes to 0.01. The renderer maps magnitude to sprite
width and opacity and B−V to a desaturated black-body colour
(`src/sky/stars.js`).
## Milky Way — ESO/S. Brunier
**Source:** "The Milky Way panorama", ESO image
[eso0932a](https://www.eso.org/public/images/eso0932a/), full-size original
`https://cdn.eso.org/images/original/eso0932a.tif` (6000×3000 sRGB TIFF, sha256
`10f209ab83e1fd89e7fa1ed70277ffc6ed19c43549f04ec637c6806d98aff035`, fetched
2026-10-19). Plate carrée in galactic coordinates, centred on the Galactic
Centre.
**License:** [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
(<https://www.eso.org/public/outreach/copyright/>). **Credit: ESO/S. Brunier.**
**Changes made** (CC BY 4.0 asks for them to be stated): resampled to
4096×2048, two planets (Jupiter, by position) painted over with the surrounding sky,
stars removed with a 7×7 morphological opening, blurred (σ = 0.4°),
reprojected from galactic coordinates to ICRF cube faces, black level set at
the darkest 25 % of the sky, gamma-encoded and saved as WebP. Build:
`node scripts/build-milky-way.mjs <eso0932a.tif>`.
The panorama is a photographic mosaic, not an astrometric map: bright stars
sit up to ~2° from their catalogue positions. That is invisible in a glow
blurred this much, and every sharp star on screen comes from the catalogue.
The NASA SVS "Deep Star Maps 2020" Milky Way layer was considered and not
used: it is derived from Gaia DR2, which ESA distributes under CC BY-NC 3.0
IGO with prior authorization required for commercial use.
## Frame
Both layers are in ICRF/J2000. The cube faces use the standard WebGL face
layout with +X towards the March equinox and +Z towards the north celestial
pole, uploaded without `flipY`. At runtime one rotation —
`Transforms.computeIcrfToFixedMatrix` at the scene clock — turns both into
Earth-fixed coordinates.
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+430
View File
@@ -0,0 +1,430 @@
import * as Cesium from 'cesium';
import { loadBundledJson } from '../data/bundledJson.js';
import { governorRequestRender } from '../renderGovernor.js';
import { buildStarVertices, STAR_VERTEX_FLOATS } from './stars.js';
/**
* Space background: catalogue stars drawn as point sprites over a soft Milky
* Way cube map, replacing Cesium's default Tycho-2 sky box (six 1024² JPEGs
* stretched 3–6× on screen, which read as compression blocks around the
* globe).
*
* Narrow, version-tested engine adapter. It takes the `Scene#skyBox` slot:
* Cesium calls `update(frameState, useHdr)` once per frame and executes the
* returned command first in the environment pass, before the atmosphere, sun,
* moon and every globe or tile command — so the sky stays behind everything
* without depth testing. Only `execute()` and `derivedCommands` of that
* command are read. An engine upgrade must revalidate this adapter.
*
* Both layers share one ICRF→Earth-fixed rotation (IAU 2006 precession,
* nutation and Earth rotation at the scene clock), so stars and the Milky
* Way agree with each other and with the real sky over the globe. Until
* Cesium's ICRF tables load, the TEME approximation the default sky box uses
* fills in; the arrival renders one frame through the idle governor.
*/
export const STARRY_SKY_CESIUM_VERSION = '1.138.0';
/** Opacity of the Milky Way glow over black; the stars carry the sky. */
export const MILKY_WAY_INTENSITY = 0.4;
/** Overall star brightness multiplier. */
export const STAR_INTENSITY = 1;
/** Clock change, in seconds, below which the sky rotation is reused. */
export const SKY_ROTATION_EPSILON_SECONDS = 1;
const STAR_PACK_URL = new URL(
'../data/local_data/sky/bright_stars.json',
import.meta.url,
);
const MILKY_WAY_FACE_URLS = Object.freeze({
positiveX: new URL(
'../data/local_data/sky/milkyway_px.webp',
import.meta.url,
),
negativeX: new URL(
'../data/local_data/sky/milkyway_nx.webp',
import.meta.url,
),
positiveY: new URL(
'../data/local_data/sky/milkyway_py.webp',
import.meta.url,
),
negativeY: new URL(
'../data/local_data/sky/milkyway_ny.webp',
import.meta.url,
),
positiveZ: new URL(
'../data/local_data/sky/milkyway_pz.webp',
import.meta.url,
),
negativeZ: new URL(
'../data/local_data/sky/milkyway_nz.webp',
import.meta.url,
),
});
// Directions, not points: w = 0 drops the camera translation, and z = w puts
// every fragment on the far plane, inside the clip volume at any frustum.
const SKY_DIRECTION_GLSL = `
uniform mat3 u_icrfToFixed;
vec4 skyClipPosition(vec3 icrfDirection)
{
vec3 eye = czm_viewRotation * (u_icrfToFixed * icrfDirection);
vec4 clip = czm_projection * vec4(eye, 0.0);
clip.z = clip.w * 0.99999;
return clip;
}
`;
const MILKY_WAY_VS = `${SKY_DIRECTION_GLSL}
in vec3 position;
out vec3 v_direction;
void main()
{
gl_Position = skyClipPosition(position);
v_direction = position;
}
`;
const MILKY_WAY_FS = `
uniform samplerCube u_milkyWay;
uniform float u_intensity;
in vec3 v_direction;
void main()
{
vec3 color = czm_textureCube(u_milkyWay, normalize(v_direction)).rgb * u_intensity;
// Interleaved-gradient dither: the dim glow spans only a few 8-bit steps.
float noise = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));
color = max(color + (noise - 0.5) / 255.0, 0.0);
out_FragColor = vec4(czm_gammaCorrect(color), 1.0);
}
`;
const STAR_VS = `${SKY_DIRECTION_GLSL}
in vec3 position;
in float sigma;
in vec4 color;
out vec4 v_color;
out float v_sigma;
out float v_radius;
void main()
{
gl_Position = skyClipPosition(position);
// σ is in CSS pixels; the sprite holds ±3σ plus a pixel of margin.
v_sigma = sigma * czm_pixelRatio;
v_radius = ceil(3.0 * v_sigma) + 1.0;
gl_PointSize = 2.0 * v_radius;
v_color = color;
}
`;
const STAR_FS = `
uniform float u_intensity;
in vec4 v_color;
in float v_sigma;
in float v_radius;
void main()
{
vec2 offset = (gl_PointCoord - 0.5) * (2.0 * v_radius);
float alpha = v_color.a * u_intensity *
exp(-0.5 * dot(offset, offset) / (v_sigma * v_sigma));
if (alpha < 1.0 / 512.0) discard;
out_FragColor = vec4(czm_gammaCorrect(v_color.rgb) * alpha, alpha);
}
`;
/** Pick the ICRF→fixed rotation for `time`, or TEME while tables load. */
export function computeSkyRotation(
time,
result,
transforms = Cesium.Transforms,
) {
const icrf = transforms.computeIcrfToFixedMatrix(time, result);
if (icrf) return { matrix: icrf, precise: true };
return {
matrix: transforms.computeTemeToPseudoFixedMatrix(time, result),
precise: false,
};
}
/** Whether a cached rotation at `cachedTime` still serves `time`. */
export function isSkyRotationCurrent(cachedTime, time) {
return (
cachedTime !== undefined &&
Math.abs(Cesium.JulianDate.secondsDifference(time, cachedTime)) <
SKY_ROTATION_EPSILON_SECONDS
);
}
export class StarrySky {
/**
* @param {object} [options]
* @param {() => void} [options.requestRender] renders one frame when an
* asset or the precise rotation arrives; the idle governor by default.
*/
constructor({
requestRender = () => governorRequestRender('sky-assets'),
} = {}) {
/** Matches `SkyBox#show`. */
this.show = true;
this.milkyWayIntensity = MILKY_WAY_INTENSITY;
this.starIntensity = STAR_INTENSITY;
this._requestRender = requestRender;
this._rotation = Cesium.Matrix3.clone(Cesium.Matrix3.IDENTITY);
this._rotationTime = undefined;
this._rotationPrecise = false;
this._icrfRequest = undefined;
this._icrfUnavailable = false;
this._loading = false;
this._starData = undefined;
this._faceImages = undefined;
this._cubeMap = undefined;
this._milkyWayCommand = undefined;
this._milkyWayAttributes = undefined;
this._starCommand = undefined;
this._useHdr = undefined;
this._destroyed = false;
const uniforms = {
u_icrfToFixed: () => this._rotation,
};
this._milkyWayUniforms = {
...uniforms,
u_milkyWay: () => this._cubeMap,
u_intensity: () => this.milkyWayIntensity,
};
this._starUniforms = {
...uniforms,
u_intensity: () => this.starIntensity,
};
const commands = [];
this._commands = commands;
// The environment pass reads `derivedCommands` (log depth, HDR, picking)
// before calling `execute`; none apply to a far-plane background.
this._compositeCommand = {
derivedCommands: {},
execute(context, passState) {
for (const command of commands) command.execute(context, passState);
},
};
}
/** Start loading assets; failures leave a plain black sky. */
_load() {
if (this._loading) return;
this._loading = true;
loadBundledJson(STAR_PACK_URL)
.then((pack) => {
if (this._destroyed) return;
this._starData = buildStarVertices(pack);
this._requestRender();
})
.catch((error) =>
console.warn('[sky] star catalogue unavailable', error),
);
const faces = Object.entries(MILKY_WAY_FACE_URLS);
Promise.all(
faces.map(([, url]) => Cesium.Resource.fetchImage({ url: url.href })),
)
.then((images) => {
if (this._destroyed) return;
this._faceImages = Object.fromEntries(
faces.map(([face], index) => [face, images[index]]),
);
this._requestRender();
})
.catch((error) => console.warn('[sky] Milky Way unavailable', error));
}
_updateRotation(time) {
// An approximate rotation is final only while nothing better can come.
const settled =
this._rotationPrecise || this._icrfRequest || this._icrfUnavailable;
if (settled && isSkyRotationCurrent(this._rotationTime, time)) return;
const { precise } = computeSkyRotation(time, this._rotation);
this._rotationPrecise = precise;
this._rotationTime = Cesium.JulianDate.clone(time, this._rotationTime);
if (!precise) this._preloadIcrf(time);
}
/** Fetch Cesium's IAU 2006 tables around `time` once; retry on new spans. */
_preloadIcrf(time) {
if (this._icrfRequest || this._icrfUnavailable) return;
const interval = new Cesium.TimeInterval({
start: Cesium.JulianDate.addDays(time, -1, new Cesium.JulianDate()),
stop: Cesium.JulianDate.addDays(time, 1, new Cesium.JulianDate()),
});
this._icrfRequest = Promise.resolve(
Cesium.Transforms.preloadIcrfFixed(interval),
)
.then(
() => {
this._rotationTime = undefined;
},
(error) => {
// TEME is within a fraction of a degree; do not retry every frame.
this._icrfUnavailable = true;
console.warn('[sky] ICRF tables unavailable, using TEME', error);
},
)
.finally(() => {
this._icrfRequest = undefined;
if (!this._destroyed) this._requestRender();
});
}
_createMilkyWayCommand(context) {
const cubeMap = new Cesium.CubeMap({
context,
source: this._faceImages,
flipY: false,
});
cubeMap.generateMipmap();
cubeMap.sampler = new Cesium.Sampler({
minificationFilter: Cesium.TextureMinificationFilter.LINEAR_MIPMAP_LINEAR,
});
this._cubeMap = cubeMap;
this._faceImages = undefined;
const geometry = Cesium.BoxGeometry.createGeometry(
Cesium.BoxGeometry.fromDimensions({
dimensions: new Cesium.Cartesian3(2, 2, 2),
vertexFormat: Cesium.VertexFormat.POSITION_ONLY,
}),
);
const attributeLocations =
Cesium.GeometryPipeline.createAttributeLocations(geometry);
this._milkyWayCommand = new Cesium.DrawCommand({
owner: this,
vertexArray: Cesium.VertexArray.fromGeometry({
context,
geometry,
attributeLocations,
bufferUsage: Cesium.BufferUsage.STATIC_DRAW,
}),
renderState: Cesium.RenderState.fromCache({}),
uniformMap: this._milkyWayUniforms,
});
this._milkyWayAttributes = attributeLocations;
}
_createStarCommand(context) {
const { count, vertices } = this._starData;
this._starData = undefined;
if (count === 0) return;
const vertexBuffer = Cesium.Buffer.createVertexBuffer({
context,
typedArray: vertices,
usage: Cesium.BufferUsage.STATIC_DRAW,
});
const stride = STAR_VERTEX_FLOATS * Float32Array.BYTES_PER_ELEMENT;
const attribute = (index, components, offsetFloats) => ({
index,
vertexBuffer,
componentsPerAttribute: components,
componentDatatype: Cesium.ComponentDatatype.FLOAT,
offsetInBytes: offsetFloats * Float32Array.BYTES_PER_ELEMENT,
strideInBytes: stride,
});
this._starCommand = new Cesium.DrawCommand({
owner: this,
primitiveType: Cesium.PrimitiveType.POINTS,
count,
vertexArray: new Cesium.VertexArray({
context,
attributes: [
attribute(0, 3, 0),
attribute(1, 1, 3),
attribute(2, 4, 4),
],
}),
// Premultiplied additive: starlight adds to the Milky Way glow and
// leaves the framebuffer alpha alone.
renderState: Cesium.RenderState.fromCache({
blending: {
enabled: true,
equationRgb: Cesium.BlendEquation.ADD,
equationAlpha: Cesium.BlendEquation.ADD,
functionSourceRgb: Cesium.BlendFunction.ONE,
functionDestinationRgb: Cesium.BlendFunction.ONE,
functionSourceAlpha: Cesium.BlendFunction.ZERO,
functionDestinationAlpha: Cesium.BlendFunction.ONE,
},
}),
uniformMap: this._starUniforms,
});
}
_updateShaders(context, useHdr) {
if (this._useHdr === useHdr) return;
this._useHdr = useHdr;
const defines = useHdr ? ['HDR'] : [];
for (const [command, vs, fs, attributeLocations] of [
[
this._milkyWayCommand,
MILKY_WAY_VS,
MILKY_WAY_FS,
this._milkyWayAttributes,
],
[
this._starCommand,
STAR_VS,
STAR_FS,
{ position: 0, sigma: 1, color: 2 },
],
]) {
if (!command) continue;
command.shaderProgram?.destroy();
command.shaderProgram = Cesium.ShaderProgram.fromCache({
context,
vertexShaderSource: vs,
fragmentShaderSource: new Cesium.ShaderSource({
defines,
sources: [fs],
}),
attributeLocations,
});
}
}
/** `Scene#skyBox` contract: the command to draw this frame, if any. */
update(frameState, useHdr) {
if (!this.show) return undefined;
const { mode, passes, context } = frameState;
if (mode !== Cesium.SceneMode.SCENE3D) return undefined;
if (!passes.render) return undefined;
this._load();
let rebuilt = false;
if (this._faceImages && !this._milkyWayCommand) {
this._createMilkyWayCommand(context);
rebuilt = true;
}
if (this._starData && !this._starCommand) {
this._createStarCommand(context);
rebuilt = true;
}
if (rebuilt) this._useHdr = undefined;
this._updateShaders(context, Boolean(useHdr));
this._commands.length = 0;
if (this._milkyWayCommand) this._commands.push(this._milkyWayCommand);
if (this._starCommand) this._commands.push(this._starCommand);
if (this._commands.length === 0) return undefined;
this._updateRotation(frameState.time);
return this._compositeCommand;
}
isDestroyed() {
return this._destroyed;
}
destroy() {
for (const command of [this._milkyWayCommand, this._starCommand]) {
if (!command) continue;
command.vertexArray?.destroy();
command.shaderProgram?.destroy();
}
this._cubeMap = this._cubeMap?.destroy();
this._milkyWayCommand = undefined;
this._starCommand = undefined;
this._commands.length = 0;
this._destroyed = true;
return undefined;
}
}
+89
View File
@@ -0,0 +1,89 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
STARRY_SKY_CESIUM_VERSION,
SKY_ROTATION_EPSILON_SECONDS,
StarrySky,
computeSkyRotation,
isSkyRotationCurrent,
} from './starrySky.js';
test('the sky-box adapter was validated against this engine version', () => {
assert.equal(
Cesium.VERSION,
STARRY_SKY_CESIUM_VERSION,
'engine upgrades require revalidating the Scene#skyBox contract',
);
});
test('the ICRF rotation is preferred and TEME fills in while tables load', () => {
const time = Cesium.JulianDate.fromIso8601('2026-10-19T00:00:00Z');
const icrf = Cesium.Matrix3.fromRotationZ(0.3);
const teme = Cesium.Matrix3.fromRotationZ(0.2);
const precise = computeSkyRotation(time, new Cesium.Matrix3(), {
computeIcrfToFixedMatrix: (_, result) => Cesium.Matrix3.clone(icrf, result),
computeTemeToPseudoFixedMatrix: () => assert.fail('TEME not needed'),
});
assert.equal(precise.precise, true);
assert.ok(Cesium.Matrix3.equals(precise.matrix, icrf));
const approximate = computeSkyRotation(time, new Cesium.Matrix3(), {
computeIcrfToFixedMatrix: () => undefined,
computeTemeToPseudoFixedMatrix: (_, result) =>
Cesium.Matrix3.clone(teme, result),
});
assert.equal(approximate.precise, false);
assert.ok(Cesium.Matrix3.equals(approximate.matrix, teme));
});
test('the TEME fallback keeps the celestial pole on the Earth axis', () => {
// TEME only spins about Z; ICRF differs from it by precession (~0.36° by
// 2026), so the fallback sky is right up to a fraction of a degree.
const time = Cesium.JulianDate.fromIso8601('2026-10-19T00:00:00Z');
const teme = Cesium.Transforms.computeTemeToPseudoFixedMatrix(time);
const pole = Cesium.Matrix3.multiplyByVector(
teme,
Cesium.Cartesian3.UNIT_Z,
new Cesium.Cartesian3(),
);
assert.ok(Math.abs(pole.z - 1) < 1e-9);
});
test('the rotation is reused only within the clock epsilon', () => {
const time = Cesium.JulianDate.fromIso8601('2026-10-19T00:00:00Z');
const later = (seconds) =>
Cesium.JulianDate.addSeconds(time, seconds, new Cesium.JulianDate());
assert.equal(isSkyRotationCurrent(undefined, time), false);
assert.equal(isSkyRotationCurrent(time, later(0.5)), true);
assert.equal(isSkyRotationCurrent(time, later(-0.5)), true);
assert.equal(
isSkyRotationCurrent(time, later(SKY_ROTATION_EPSILON_SECONDS)),
false,
);
});
test('the sky draws nothing when hidden, outside 3D, or in a pick pass', () => {
const sky = new StarrySky({ requestRender: () => {} });
const frameState = (overrides) => ({
mode: Cesium.SceneMode.SCENE3D,
passes: { render: true },
...overrides,
});
sky._loading = true; // keep the test off the network
sky.show = false;
assert.equal(sky.update(frameState()), undefined);
sky.show = true;
assert.equal(
sky.update(frameState({ mode: Cesium.SceneMode.SCENE2D })),
undefined,
);
assert.equal(
sky.update(frameState({ passes: { render: false, pick: true } })),
undefined,
);
// Nothing has loaded yet, so even a render pass has no command.
assert.equal(sky.update(frameState()), undefined);
assert.equal(sky.isDestroyed(), false);
assert.equal(sky.destroy(), undefined);
assert.equal(sky.isDestroyed(), true);
});
+146
View File
@@ -0,0 +1,146 @@
/**
* Bright-star appearance: catalogue pack → GPU vertex data.
*
* Pure functions, no Cesium. The pack is `local_data/sky/bright_stars.json`
* (Yale Bright Star Catalogue, built by scripts/build-bright-stars.mjs): a
* flat `[raDeg, decDeg, vmag, bv, …]` array in ICRF/J2000.
*
* Every star is drawn as a point sprite holding a small Gaussian. Its width
* is set in CSS pixels and scaled by the device pixel ratio in the shader, so
* a star covers the same screen area at any DPR and stays a crisp dot rather
* than a texel stretched across several pixels.
*
* @module sky/stars
*/
/** Faintest catalogue magnitude; also the reference for brightness. */
export const STAR_LIMIT_MAGNITUDE = 6.5;
/** Gaussian σ of the faintest star, CSS pixels. */
export const STAR_MIN_SIGMA_PX = 0.55;
/** Gaussian σ cap for the brightest star, CSS pixels. */
export const STAR_MAX_SIGMA_PX = 1.35;
/** Peak opacity of the faintest star. */
export const STAR_MIN_ALPHA = 0.22;
/** B−V for stars the catalogue gives no colour (a solar-type white). */
export const STAR_DEFAULT_BV = 0.6;
/** Floats per star in the vertex buffer: xyz, σ, rgb, alpha. */
export const STAR_VERTEX_FLOATS = 8;
const DEG = Math.PI / 180;
// Fraction of the colour kept: the eye sees all but the most extreme stars
// as nearly white, and saturated dots read as UI markers on the globe.
const COLOR_SATURATION = 0.55;
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
/** ICRF unit vector for J2000 right ascension and declination in degrees. */
export function raDecToUnitVector(raDeg, decDeg) {
const ra = raDeg * DEG;
const dec = decDeg * DEG;
const cosDec = Math.cos(dec);
return [cosDec * Math.cos(ra), cosDec * Math.sin(ra), Math.sin(dec)];
}
/**
* Perceived brightness relative to the limit magnitude. Flux grows 2.512×
* per magnitude; the square root compresses Sirius-to-6.5m (≈ 1600×) into a
* range a display can show without the bright stars turning into discs.
*/
export function starBrightness(vmag) {
const magnitude = Number.isFinite(vmag) ? vmag : STAR_LIMIT_MAGNITUDE;
return (
10 **
(-0.2 * (Math.min(magnitude, STAR_LIMIT_MAGNITUDE) - STAR_LIMIT_MAGNITUDE))
);
}
/**
* Sprite parameters for a visual magnitude: `sigmaPx` is the Gaussian σ in
* CSS pixels and `alpha` its peak opacity. Faint stars keep the minimum width
* and fade; bright stars saturate and grow slowly.
*/
export function starAppearance(vmag) {
const brightness = starBrightness(vmag);
return {
sigmaPx: clamp(
STAR_MIN_SIGMA_PX * brightness ** 0.3,
STAR_MIN_SIGMA_PX,
STAR_MAX_SIGMA_PX,
),
alpha: clamp(STAR_MIN_ALPHA * brightness, STAR_MIN_ALPHA, 1),
};
}
/** Effective temperature from B−V (Ballesteros 2012). */
export function bvToTemperature(bv) {
const index = clamp(Number.isFinite(bv) ? bv : STAR_DEFAULT_BV, -0.4, 2.0);
return 4600 * (1 / (0.92 * index + 1.7) + 1 / (0.92 * index + 0.62));
}
/** Black-body colour of a temperature, sRGB 0..1 (Helland's fit). */
export function temperatureToRgb(kelvin) {
const t = clamp(kelvin, 1000, 40000) / 100;
const red = t <= 66 ? 255 : 329.698727446 * (t - 60) ** -0.1332047592;
const green =
t <= 66
? 99.4708025861 * Math.log(t) - 161.1195681661
: 288.1221695283 * (t - 60) ** -0.0755148492;
const blue =
t >= 66
? 255
: t <= 19
? 0
: 138.5177312231 * Math.log(t - 10) - 305.0447927307;
return [red, green, blue].map((channel) => clamp(channel, 0, 255) / 255);
}
/**
* Display colour for a B−V index: the black-body hue, desaturated towards
* white and normalized so the brightest channel is 1 (brightness belongs to
* the magnitude alone).
*/
export function bvToRgb(bv) {
const rgb = temperatureToRgb(bvToTemperature(bv)).map(
(channel) => 1 - COLOR_SATURATION + COLOR_SATURATION * channel,
);
const peak = Math.max(...rgb);
return rgb.map((channel) => channel / peak);
}
/**
* Decode a catalogue pack into interleaved vertex data, `STAR_VERTEX_FLOATS`
* per star. Malformed rows are skipped, never thrown on: a damaged pack must
* not take the globe down with it.
*/
export function buildStarVertices(pack) {
const stars = Array.isArray(pack?.stars) ? pack.stars : [];
const count = Math.floor(stars.length / 4);
const vertices = new Float32Array(count * STAR_VERTEX_FLOATS);
let written = 0;
for (let i = 0; i < count; i++) {
const [raDeg, decDeg, vmag, bv] = stars.slice(i * 4, i * 4 + 4);
if (
!Number.isFinite(raDeg) ||
!Number.isFinite(decDeg) ||
!Number.isFinite(vmag)
)
continue;
const [x, y, z] = raDecToUnitVector(raDeg, decDeg);
const { sigmaPx, alpha } = starAppearance(vmag);
const [r, g, b] = bvToRgb(bv);
vertices.set(
[x, y, z, sigmaPx, r, g, b, alpha],
written * STAR_VERTEX_FLOATS,
);
written += 1;
}
return {
count: written,
vertices:
written === count
? vertices
: vertices.slice(0, written * STAR_VERTEX_FLOATS),
};
}
+134
View File
@@ -0,0 +1,134 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, statSync } from 'node:fs';
import {
STAR_LIMIT_MAGNITUDE,
STAR_MAX_SIGMA_PX,
STAR_MIN_ALPHA,
STAR_MIN_SIGMA_PX,
STAR_VERTEX_FLOATS,
buildStarVertices,
bvToRgb,
bvToTemperature,
raDecToUnitVector,
starAppearance,
starBrightness,
} from './stars.js';
const close = (actual, expected, epsilon = 1e-9) =>
assert.ok(
Math.abs(actual - expected) <= epsilon,
`${actual} is not within ${epsilon} of ${expected}`,
);
const skyFile = (name) =>
new URL(`../data/local_data/sky/${name}`, import.meta.url);
test('right ascension and declination map onto the ICRF axes', () => {
const equinox = raDecToUnitVector(0, 0);
close(equinox[0], 1);
close(equinox[1], 0);
close(equinox[2], 0);
const sixHours = raDecToUnitVector(90, 0);
close(sixHours[0], 0);
close(sixHours[1], 1);
const pole = raDecToUnitVector(123, 90);
close(pole[2], 1);
const south = raDecToUnitVector(200, -30);
close(Math.hypot(...south), 1);
close(south[2], -0.5);
});
test('brightness grows with each magnitude and stops at the catalogue limit', () => {
close(starBrightness(STAR_LIMIT_MAGNITUDE), 1);
close(starBrightness(STAR_LIMIT_MAGNITUDE + 1.5), 1);
close(starBrightness(STAR_LIMIT_MAGNITUDE - 5), 10);
close(starBrightness(Number.NaN), 1);
});
test('brighter stars are never smaller or fainter, and both ends clamp', () => {
let previous = starAppearance(8);
assert.deepEqual(previous, {
sigmaPx: STAR_MIN_SIGMA_PX,
alpha: STAR_MIN_ALPHA,
});
for (let vmag = 7.5; vmag >= -1.5; vmag -= 0.25) {
const next = starAppearance(vmag);
assert.ok(next.sigmaPx >= previous.sigmaPx, `σ shrank at ${vmag}`);
assert.ok(next.alpha >= previous.alpha, `alpha dropped at ${vmag}`);
previous = next;
}
const sirius = starAppearance(-1.46);
assert.equal(sirius.alpha, 1);
assert.ok(sirius.sigmaPx <= STAR_MAX_SIGMA_PX);
assert.ok(sirius.sigmaPx > starAppearance(2).sigmaPx);
// Sprites stay point-like: ±3σ of the brightest star is a few pixels.
assert.ok(6 * STAR_MAX_SIGMA_PX < 10);
});
test('B−V colours run from blue-white to orange with the peak channel at 1', () => {
close(bvToTemperature(0.65), 5_800, 120);
close(bvToTemperature(0), 10_100, 200);
const hot = bvToRgb(-0.3);
const sunLike = bvToRgb(0.65);
const cool = bvToRgb(1.6);
for (const rgb of [hot, sunLike, cool]) {
close(Math.max(...rgb), 1);
assert.ok(Math.min(...rgb) > 0.5, 'colours stay desaturated');
}
assert.ok(hot[2] > hot[0], 'hot stars are blue');
assert.ok(cool[0] > cool[2], 'cool stars are orange');
assert.deepEqual(bvToRgb(null), bvToRgb(0.6));
assert.deepEqual(bvToRgb(9), bvToRgb(2));
});
test('vertex data is interleaved per star and skips malformed rows', () => {
const { count, vertices } = buildStarVertices({
stars: [0, 0, 1, 0.5, 'bad', 10, 2, 0, 90, 45, 3, null, 10, 20],
});
assert.equal(count, 2);
assert.equal(vertices.length, 2 * STAR_VERTEX_FLOATS);
assert.deepEqual(Array.from(vertices.subarray(0, 3)), [1, 0, 0]);
close(vertices[STAR_VERTEX_FLOATS + 1], Math.SQRT1_2, 1e-6);
close(vertices[STAR_VERTEX_FLOATS + 2], Math.SQRT1_2, 1e-6);
close(vertices[3], starAppearance(1).sigmaPx, 1e-6);
close(vertices[7], starAppearance(1).alpha, 1e-6);
assert.deepEqual(buildStarVertices(null), {
count: 0,
vertices: new Float32Array(0),
});
});
test('the committed catalogue holds the Yale Bright Star Catalogue', () => {
const pack = JSON.parse(readFileSync(skyFile('bright_stars.json'), 'utf8'));
assert.deepEqual(pack.meta.columns, ['raDeg', 'decDeg', 'vmag', 'bv']);
assert.equal(pack.stars.length, pack.meta.count * 4);
assert.equal(pack.meta.count, 9096);
let brightest = 0;
for (let i = 0; i < pack.stars.length; i += 4) {
const [ra, dec, vmag] = pack.stars.slice(i, i + 4);
assert.ok(ra >= 0 && ra < 360 && dec >= -90 && dec <= 90);
assert.ok(Number.isFinite(vmag));
if (vmag < pack.stars[brightest + 2]) brightest = i;
}
// Sirius, α CMa: 06h45m08.9s −16°42′58″, V = −1.46.
assert.deepEqual(
pack.stars.slice(brightest, brightest + 4),
[101.287, -16.716, -1.46, 0],
);
assert.equal(buildStarVertices(pack).count, pack.meta.count);
});
test('sky assets stay inside the 5 MB budget', () => {
const files = [
'bright_stars.json',
...['px', 'nx', 'py', 'ny', 'pz', 'nz'].map(
(face) => `milkyway_${face}.webp`,
),
];
const total = files.reduce(
(sum, name) => sum + statSync(skyFile(name)).size,
0,
);
assert.ok(total < 5 * 1024 * 1024, `${total} bytes`);
});
+107
View File
@@ -0,0 +1,107 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildStarPack,
parseBscRecord,
} from '../../scripts/build-bright-stars.mjs';
import {
CUBE_FACES,
cubeFaceDirection,
directionToPlateCarree,
icrfToGalactic,
} from '../../scripts/build-milky-way.mjs';
import { raDecToUnitVector } from '../sky/stars.js';
// Verbatim CDS V/50 records: HR 1, Sirius (HR 2491) and a nova with no J2000
// position. Columns matter, so the padding is part of the fixture.
const HR_1 =
' 1 BD+44 4550 3 36042 46 000001.1+444022000509.9+451345114.44-16.88 6.70 +0.07 +0.08 A1Vn -0.012-0.018 -018 195 4.2 21.6AC 3';
const SIRIUS =
'2491 9Alp CMaBD-16 1591 48915151881 257I 5423 064044.6-163444064508.9-164258227.22-08.88-1.46 0.00 -0.05 -0.03 A1Vm -0.553-1.205 +.375-008SBO 13 10.3 11.2AB 4*';
const NOVA =
' 92 NOVA 1572 B Cas *';
const close = (actual, expected, epsilon = 1e-9) =>
assert.ok(
Math.abs(actual - expected) <= epsilon,
`${actual} is not within ${epsilon} of ${expected}`,
);
test('BSC5 records parse J2000 position, V magnitude and B−V', () => {
const sirius = parseBscRecord(SIRIUS);
assert.equal(sirius.hr, 2491);
close(sirius.raDeg, (6 + 45 / 60 + 8.9 / 3600) * 15);
close(sirius.decDeg, -(16 + 42 / 60 + 58 / 3600));
assert.equal(sirius.vmag, -1.46);
assert.equal(sirius.bv, 0);
const first = parseBscRecord(HR_1);
close(first.raDeg, (5 / 60 + 9.9 / 3600) * 15);
close(first.decDeg, 45 + 13 / 60 + 45 / 3600);
assert.equal(first.vmag, 6.7);
assert.equal(first.bv, 0.07);
assert.equal(parseBscRecord(NOVA), null);
});
test('the star pack is a flat, rounded column array that counts its drops', () => {
const pack = buildStarPack([HR_1, NOVA, SIRIUS, ''].join('\n'), {
fetched: '2026-10-19',
});
assert.deepEqual(
pack.stars,
[1.291, 45.229, 6.7, 0.07, 101.287, -16.716, -1.46, 0],
);
assert.equal(pack.meta.count, 2);
assert.equal(pack.meta.dropped, 1);
assert.equal(pack.meta.fetched, '2026-10-19');
});
test('cube faces use the WebGL major-axis layout', () => {
// Each face centre looks down its own axis …
const axes = {
positiveX: [1, 0, 0],
negativeX: [-1, 0, 0],
positiveY: [0, 1, 0],
negativeY: [0, -1, 0],
positiveZ: [0, 0, 1],
negativeZ: [0, 0, -1],
};
for (const face of CUBE_FACES)
assert.deepEqual(
cubeFaceDirection(face, 0.5, 0.5).map((v) => v + 0),
axes[face],
);
// … and the first texel row and column follow OpenGL ES 3.0 §3.8.10:
// on +X, s grows towards −Z and t towards −Y.
assert.deepEqual(cubeFaceDirection('positiveX', 0, 0), [1, 1, 1]);
assert.deepEqual(cubeFaceDirection('positiveZ', 1, 1), [1, -1, 1]);
assert.deepEqual(cubeFaceDirection('negativeY', 0, 1), [-1, -1, -1]);
assert.throws(() => cubeFaceDirection('up', 0, 0), RangeError);
});
test('plate carrée is centred on longitude 0, growing to the left, north up', () => {
assert.deepEqual(directionToPlateCarree(1, 0, 0), [0.5, 0.5]);
const sixHours = directionToPlateCarree(0, 1, 0);
close(sixHours[0], 0.25);
const eighteenHours = directionToPlateCarree(0, -1, 0);
close(eighteenHours[0], 0.75);
close(directionToPlateCarree(0, 0, 1)[1], 0);
close(directionToPlateCarree(0, 0, -1)[1], 1);
const twelveHours = directionToPlateCarree(-1, 0, 0);
assert.ok(twelveHours[0] === 0 || twelveHours[0] === 1);
});
test('the galactic rotation puts the Galactic Centre and pole where the IAU does', () => {
// Sgr A*-adjacent origin l = b = 0 at RA 266.405°, Dec −28.936° (J2000).
const centre = icrfToGalactic(raDecToUnitVector(266.40499, -28.93617));
close(centre[0], 1, 1e-6);
// North galactic pole at RA 192.85948°, Dec +27.12825°.
const pole = icrfToGalactic(raDecToUnitVector(192.85948, 27.12825));
close(pole[2], 1, 1e-6);
// ESO's panorama: the Large Magellanic Cloud (l ≈ 280.5°, b ≈ −32.9°) sits
// right of and below the centre.
const [u, v] = directionToPlateCarree(
...icrfToGalactic(raDecToUnitVector(80.894, -69.756)),
);
close(u, 0.5 + (360 - 280.47) / 360, 2e-3);
close(v, 0.5 + 32.89 / 180, 2e-3);
});