mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
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:
1 parent
93ea088f45
commit
6549344946
20 files changed
+10644
-2
No files matched your search
@@ -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) |
|
||||
| **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) |
|
||||
| **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 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
|
||||
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/`)
|
||||
|
||||
`neighborhoods/san-francisco.json` bundles the City & County of San Francisco's official
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -1062,5 +1062,10 @@
|
||||
"src/ui/flyToDataSource.test.mjs",
|
||||
"scripts/assemble-changelog.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"
|
||||
]
|
||||
@@ -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": {
|
||||
@@ -1708,6 +1715,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",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
|
||||
import { StarrySky } from '../sky/starrySky.js';
|
||||
|
||||
const PINCH_ZOOM_MULTIPLIER = 8;
|
||||
const MAX_PINCH_PIXEL_DELTA = 120;
|
||||
@@ -119,6 +120,9 @@ export function createApplicationViewer({ container, creditContainer }) {
|
||||
selectionIndicator: false,
|
||||
infoBox: 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,
|
||||
msaaSamples: 4,
|
||||
contextOptions: { webgl: { preserveDrawingBuffer: true } },
|
||||
@@ -130,6 +134,7 @@ export function createApplicationViewer({ container, creditContainer }) {
|
||||
// render loop. See app/atmosphereCompat.js.
|
||||
applyModelAtmosphereWorkaround(viewer.scene);
|
||||
viewer.scene.globe.show = false;
|
||||
viewer.scene.skyBox = new StarrySky();
|
||||
viewer.scene.skyAtmosphere.show = true;
|
||||
viewer.scene.skyAtmosphere.atmosphereLightIntensity = 18;
|
||||
viewer.scene.skyAtmosphere.saturationShift = -0.12;
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
// ── 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 & 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',
|
||||
html: 'Dams: Open Infrastructure Map',
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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`);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user