caelus 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/accuracy.json +2 -2
- package/data/constellations.json +1 -0
- package/data/fixed_stars_deep.json +1 -0
- package/dist/data/constellations.json +1 -0
- package/dist/src/core.d.ts +5 -0
- package/dist/src/data-embedded.js +2 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/interpretation.js +2 -0
- package/dist/src/node-loader.js +6 -0
- package/dist/src/skyview.d.ts +344 -0
- package/dist/src/skyview.js +979 -0
- package/dist/src/stars.d.ts +15 -0
- package/package.json +6 -4
|
@@ -0,0 +1,979 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* caelus skyview -- apparent-sky framing for image prompts.
|
|
3
|
+
*
|
|
4
|
+
* Given an observer, an aim direction, a lens, and an output image size,
|
|
5
|
+
* `skyView` projects the visible bodies into the frame: pixel position,
|
|
6
|
+
* apparent size, brightness, Moon phase orientation, and a sky-state summary.
|
|
7
|
+
* It also serializes a prompt. Caelus computes the geometry and photometry; the
|
|
8
|
+
* image model owns color and atmosphere, guided by the directives.
|
|
9
|
+
*
|
|
10
|
+
* See docs/skyview.md for the model, the projection math, and the schema.
|
|
11
|
+
*/
|
|
12
|
+
import { DEG, mod, J2000, jdTT, precessEcliptic } from "./core.js";
|
|
13
|
+
import { SIGNS } from "./chart.js";
|
|
14
|
+
import { starApparent } from "./stars.js";
|
|
15
|
+
import { azAlt, pheno, refractTrueToApparent, DIAMETER_KM, } from "./pheno.js";
|
|
16
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
17
|
+
const cross = (a, b) => [
|
|
18
|
+
a[1] * b[2] - a[2] * b[1],
|
|
19
|
+
a[2] * b[0] - a[0] * b[2],
|
|
20
|
+
a[0] * b[1] - a[1] * b[0],
|
|
21
|
+
];
|
|
22
|
+
const norm = (a) => Math.sqrt(dot(a, a));
|
|
23
|
+
const unit = (a) => {
|
|
24
|
+
const n = norm(a);
|
|
25
|
+
return [a[0] / n, a[1] / n, a[2] / n];
|
|
26
|
+
};
|
|
27
|
+
const clamp1 = (x) => Math.max(-1, Math.min(1, x));
|
|
28
|
+
/** Direction (local horizontal frame: x east, y north, z up) for an azimuth
|
|
29
|
+
* (deg from true north, east positive) and altitude (deg). */
|
|
30
|
+
function dirFromAzAlt(azDeg, altDeg) {
|
|
31
|
+
const a = azDeg * DEG;
|
|
32
|
+
const h = altDeg * DEG;
|
|
33
|
+
return [Math.cos(h) * Math.sin(a), Math.cos(h) * Math.cos(a), Math.sin(h)];
|
|
34
|
+
}
|
|
35
|
+
const COMPASS16 = [
|
|
36
|
+
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
|
|
37
|
+
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW",
|
|
38
|
+
];
|
|
39
|
+
function compassOf(azDeg) {
|
|
40
|
+
return COMPASS16[Math.round(mod(azDeg, 360) / 22.5) % 16];
|
|
41
|
+
}
|
|
42
|
+
/** Accept an azimuth as degrees or a compass point (`"W"`, `"WNW"`). */
|
|
43
|
+
function parseAzimuth(az) {
|
|
44
|
+
if (typeof az === "number")
|
|
45
|
+
return mod(az, 360);
|
|
46
|
+
const key = az.trim().toUpperCase();
|
|
47
|
+
const i = COMPASS16.indexOf(key);
|
|
48
|
+
if (i >= 0)
|
|
49
|
+
return i * 22.5;
|
|
50
|
+
const n = Number(key);
|
|
51
|
+
if (Number.isFinite(n))
|
|
52
|
+
return mod(n, 360);
|
|
53
|
+
throw new Error(`unknown azimuth '${az}' (degrees or a 16-point compass name)`);
|
|
54
|
+
}
|
|
55
|
+
/** Lens presets. Focal length is 35 mm-equivalent (full-frame, 36 mm wide).
|
|
56
|
+
* The preset carries the projection, as real optics do. */
|
|
57
|
+
const LENS_PRESETS = {
|
|
58
|
+
ultrawide: { focalLengthMm: 14, projection: "fisheye" },
|
|
59
|
+
wide: { focalLengthMm: 24, projection: "rectilinear" },
|
|
60
|
+
standard: { focalLengthMm: 35, projection: "rectilinear" },
|
|
61
|
+
normal: { focalLengthMm: 50, projection: "rectilinear" },
|
|
62
|
+
portrait: { focalLengthMm: 85, projection: "rectilinear" },
|
|
63
|
+
telephoto: { focalLengthMm: 135, projection: "rectilinear" },
|
|
64
|
+
supertele: { focalLengthMm: 200, projection: "rectilinear" },
|
|
65
|
+
};
|
|
66
|
+
export const LENS_NAMES = Object.keys(LENS_PRESETS);
|
|
67
|
+
const SENSOR_MM = 36;
|
|
68
|
+
const hfovFromFocal = (focal, sensor) => (2 * Math.atan(sensor / (2 * focal))) / DEG;
|
|
69
|
+
const focalFromHfov = (hfovDeg, sensor) => sensor / (2 * Math.tan((hfovDeg * DEG) / 2));
|
|
70
|
+
function resolveLens(spec, width, height) {
|
|
71
|
+
let name = "custom";
|
|
72
|
+
let projection = "rectilinear";
|
|
73
|
+
let focal;
|
|
74
|
+
let sensor = SENSOR_MM;
|
|
75
|
+
let hfovDeg;
|
|
76
|
+
if (typeof spec === "string") {
|
|
77
|
+
const p = LENS_PRESETS[spec];
|
|
78
|
+
if (!p)
|
|
79
|
+
throw new Error(`unknown lens '${spec}' (presets: ${LENS_NAMES.join(", ")})`);
|
|
80
|
+
name = spec;
|
|
81
|
+
focal = p.focalLengthMm;
|
|
82
|
+
projection = p.projection;
|
|
83
|
+
hfovDeg = hfovFromFocal(focal, sensor);
|
|
84
|
+
}
|
|
85
|
+
else if ("hfovDeg" in spec) {
|
|
86
|
+
hfovDeg = spec.hfovDeg;
|
|
87
|
+
projection = spec.projection ?? "rectilinear";
|
|
88
|
+
focal = focalFromHfov(hfovDeg, sensor);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
sensor = spec.sensorWidthMm ?? SENSOR_MM;
|
|
92
|
+
focal = spec.focalLengthMm;
|
|
93
|
+
projection = spec.projection ?? "rectilinear";
|
|
94
|
+
hfovDeg = hfovFromFocal(focal, sensor);
|
|
95
|
+
}
|
|
96
|
+
const hfovR = hfovDeg * DEG;
|
|
97
|
+
const vfovDeg = projection === "fisheye"
|
|
98
|
+
? hfovDeg * (height / width)
|
|
99
|
+
: (2 * Math.atan(Math.tan(hfovR / 2) * (height / width))) / DEG;
|
|
100
|
+
return {
|
|
101
|
+
name,
|
|
102
|
+
focalLengthMm: Math.round(focal * 10) / 10,
|
|
103
|
+
sensorWidthMm: sensor,
|
|
104
|
+
projection,
|
|
105
|
+
hfovDeg: Math.round(hfovDeg * 100) / 100,
|
|
106
|
+
vfovDeg: Math.round(vfovDeg * 100) / 100,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function twilightStage(sunAltDeg) {
|
|
110
|
+
if (sunAltDeg > 0)
|
|
111
|
+
return "day";
|
|
112
|
+
if (sunAltDeg > -6)
|
|
113
|
+
return "civil";
|
|
114
|
+
if (sunAltDeg > -12)
|
|
115
|
+
return "nautical";
|
|
116
|
+
if (sunAltDeg > -18)
|
|
117
|
+
return "astronomical";
|
|
118
|
+
return "night";
|
|
119
|
+
}
|
|
120
|
+
/** Sky-brightness ceiling on the naked-eye limit by twilight stage. Night sets
|
|
121
|
+
* no ceiling of its own; there the site darkness ({@link BORTLE_LIMIT}) and the
|
|
122
|
+
* Moon decide the limit. */
|
|
123
|
+
const STAGE_CEILING = {
|
|
124
|
+
day: -4.0, civil: 1.5, nautical: 4.0, astronomical: 5.5, night: Infinity,
|
|
125
|
+
};
|
|
126
|
+
/** Effective naked-eye limiting magnitude: the more restrictive of the
|
|
127
|
+
* twilight-brightness ceiling and the site's dark-sky limit, then reduced when
|
|
128
|
+
* a bright Moon is up. With no Bortle class, the dark-site limit defaults to
|
|
129
|
+
* 6.0 (suburban), matching the original behavior. */
|
|
130
|
+
function limitingMag(stage, moonAltDeg, moonIllum, bortle) {
|
|
131
|
+
const bortleLimit = bortle !== undefined ? (BORTLE_LIMIT[Math.round(bortle)] ?? 6.0) : 6.0;
|
|
132
|
+
let lim = Math.min(STAGE_CEILING[stage], bortleLimit);
|
|
133
|
+
if (lim > 0 && moonAltDeg !== null && moonAltDeg > 0 && (moonIllum ?? 0) > 0.3) {
|
|
134
|
+
lim -= 1.5 * (moonIllum ?? 0);
|
|
135
|
+
}
|
|
136
|
+
return Math.round(lim * 10) / 10;
|
|
137
|
+
}
|
|
138
|
+
const PHASE_NAMES = [
|
|
139
|
+
"new moon", "waxing crescent", "first quarter", "waxing gibbous",
|
|
140
|
+
"full moon", "waning gibbous", "last quarter", "waning crescent",
|
|
141
|
+
];
|
|
142
|
+
function moonPhaseName(illum, waxing) {
|
|
143
|
+
if (illum < 0.03)
|
|
144
|
+
return "new moon";
|
|
145
|
+
if (illum > 0.97)
|
|
146
|
+
return "full moon";
|
|
147
|
+
if (Math.abs(illum - 0.5) < 0.06)
|
|
148
|
+
return waxing ? "first quarter" : "last quarter";
|
|
149
|
+
if (illum < 0.5)
|
|
150
|
+
return waxing ? "waxing crescent" : "waning crescent";
|
|
151
|
+
return waxing ? "waxing gibbous" : "waning gibbous";
|
|
152
|
+
}
|
|
153
|
+
/** Image-plane angle (deg, 0 right, 90 up) to a clock position (12 up). */
|
|
154
|
+
function clockOf(angleDeg) {
|
|
155
|
+
let h = Math.round((90 - angleDeg) / 30) % 12;
|
|
156
|
+
if (h <= 0)
|
|
157
|
+
h += 12;
|
|
158
|
+
return `${h} o'clock`;
|
|
159
|
+
}
|
|
160
|
+
/** UT Julian Day to an ISO-8601 UTC string (Meeus ch. 7, inverse). */
|
|
161
|
+
function jdToUtcIso(jd) {
|
|
162
|
+
const z = Math.floor(jd + 0.5);
|
|
163
|
+
const f = jd + 0.5 - z;
|
|
164
|
+
let a = z;
|
|
165
|
+
if (z >= 2299161) {
|
|
166
|
+
const alpha = Math.floor((z - 1867216.25) / 36524.25);
|
|
167
|
+
a = z + 1 + alpha - Math.floor(alpha / 4);
|
|
168
|
+
}
|
|
169
|
+
const b = a + 1524;
|
|
170
|
+
const c = Math.floor((b - 122.1) / 365.25);
|
|
171
|
+
const d = Math.floor(365.25 * c);
|
|
172
|
+
const e = Math.floor((b - d) / 30.6001);
|
|
173
|
+
const dayF = b - d - Math.floor(30.6001 * e) + f;
|
|
174
|
+
const day = Math.floor(dayF);
|
|
175
|
+
const month = e < 14 ? e - 1 : e - 13;
|
|
176
|
+
const year = month > 2 ? c - 4716 : c - 4715;
|
|
177
|
+
let secs = Math.min(86399, Math.round((dayF - day) * 86400));
|
|
178
|
+
const hh = Math.floor(secs / 3600);
|
|
179
|
+
secs -= hh * 3600;
|
|
180
|
+
const mm = Math.floor(secs / 60);
|
|
181
|
+
const ss = secs - mm * 60;
|
|
182
|
+
const p2 = (n) => String(n).padStart(2, "0");
|
|
183
|
+
const yr = year < 0 ? `-${String(-year).padStart(4, "0")}` : String(year).padStart(4, "0");
|
|
184
|
+
return `${yr}-${p2(month)}-${p2(day)}T${p2(hh)}:${p2(mm)}:${p2(ss)}Z`;
|
|
185
|
+
}
|
|
186
|
+
function displayName(id) {
|
|
187
|
+
if (id.startsWith("star:"))
|
|
188
|
+
return id.slice(5);
|
|
189
|
+
return id.charAt(0).toUpperCase() + id.slice(1);
|
|
190
|
+
}
|
|
191
|
+
/** Map apparent magnitude to a rendering cue: how prominent the point should
|
|
192
|
+
* look in the image. This is deliberately decoupled from the body's true
|
|
193
|
+
* angular size, which is sub-pixel for a planet: in a photo a bright point's
|
|
194
|
+
* on-screen presence comes from brightness bloom, not its disk. Drives the
|
|
195
|
+
* prompt text, not the geometry. */
|
|
196
|
+
function brightnessDescriptor(mag, nakedEye) {
|
|
197
|
+
if (mag === null)
|
|
198
|
+
return undefined;
|
|
199
|
+
if (!nakedEye)
|
|
200
|
+
return "faint, just at the visibility limit";
|
|
201
|
+
if (mag <= -3)
|
|
202
|
+
return "brilliant, the dominant point in the sky; render with a soft glow and slight bloom";
|
|
203
|
+
if (mag <= -1)
|
|
204
|
+
return "very bright, with a slight glow";
|
|
205
|
+
if (mag <= 1)
|
|
206
|
+
return "bright point of light";
|
|
207
|
+
return "modest point of light";
|
|
208
|
+
}
|
|
209
|
+
// ICRS (J2000 equatorial) -> galactic rotation; rows are the galactic axes in
|
|
210
|
+
// equatorial coordinates. The inverse (galactic -> equatorial) is the transpose.
|
|
211
|
+
const ICRS_TO_GAL = [
|
|
212
|
+
[-0.0548755604162154, -0.8734370902348850, -0.4838350155487132],
|
|
213
|
+
[0.4941094278755837, -0.4448296299600112, 0.7469822444972189],
|
|
214
|
+
[-0.8676661490190047, -0.1980763734312015, 0.4559837761750669],
|
|
215
|
+
];
|
|
216
|
+
const ECL_OBLIQUITY_J2000 = 23.4392911 * DEG;
|
|
217
|
+
/** A point on the galactic equator (galactic longitude `lDeg`, latitude 0) as
|
|
218
|
+
* a J2000 ecliptic (lon, lat) pair in radians, ready for {@link precessEcliptic}. */
|
|
219
|
+
function galacticEquatorToEclJ2000(lDeg) {
|
|
220
|
+
const l = lDeg * DEG;
|
|
221
|
+
const g = [Math.cos(l), Math.sin(l), 0]; // galactic Cartesian, b = 0
|
|
222
|
+
// equatorial J2000 = ICRS_TO_GAL^T . g
|
|
223
|
+
const xq = ICRS_TO_GAL[0][0] * g[0] + ICRS_TO_GAL[1][0] * g[1] + ICRS_TO_GAL[2][0] * g[2];
|
|
224
|
+
const yq = ICRS_TO_GAL[0][1] * g[0] + ICRS_TO_GAL[1][1] * g[1] + ICRS_TO_GAL[2][1] * g[2];
|
|
225
|
+
const zq = ICRS_TO_GAL[0][2] * g[0] + ICRS_TO_GAL[1][2] * g[1] + ICRS_TO_GAL[2][2] * g[2];
|
|
226
|
+
// equatorial -> ecliptic: rotate about the x-axis by the J2000 obliquity.
|
|
227
|
+
const e = ECL_OBLIQUITY_J2000;
|
|
228
|
+
const lon = Math.atan2(yq * Math.cos(e) + zq * Math.sin(e), xq);
|
|
229
|
+
const lat = Math.asin(clamp1(-yq * Math.sin(e) + zq * Math.cos(e)));
|
|
230
|
+
return [lon, lat];
|
|
231
|
+
}
|
|
232
|
+
/** Naked-eye limiting magnitude by Bortle dark-sky class (1 pristine, 9 inner
|
|
233
|
+
* city). Omitting the class keeps the legacy suburban default of 6.0. */
|
|
234
|
+
const BORTLE_LIMIT = {
|
|
235
|
+
1: 7.6, 2: 7.4, 3: 7.0, 4: 6.5, 5: 6.0, 6: 5.5, 7: 5.0, 8: 4.5, 9: 4.0,
|
|
236
|
+
};
|
|
237
|
+
/** The background star-field instruction, adapting to sky darkness. In a dark
|
|
238
|
+
* sky it tells the model to fill a dense field; in twilight or city sky it
|
|
239
|
+
* keeps the field sparse. The listed stars are always pinned regardless. */
|
|
240
|
+
function starfieldClause(limit, dark, moonBright, field) {
|
|
241
|
+
if (field.complete) {
|
|
242
|
+
return `The structured \`bodies\` data holds a complete naked-eye field of ${field.count} `
|
|
243
|
+
+ `stars to magnitude ${limit}; render each at its exact pixel as a fine point of light. `
|
|
244
|
+
+ "Do not add, move, or omit stars.";
|
|
245
|
+
}
|
|
246
|
+
if (!dark) {
|
|
247
|
+
return "Beyond those you may add a few of the very brightest stars, but keep it sparse: "
|
|
248
|
+
+ "do not fill the sky with stars.";
|
|
249
|
+
}
|
|
250
|
+
if (moonBright) {
|
|
251
|
+
return "Moonlight suppresses the faint stars: beyond those, keep the background field sparse.";
|
|
252
|
+
}
|
|
253
|
+
if (limit >= 6.5) {
|
|
254
|
+
return `Beyond those, fill the background with a dense, deep field of faint stars down to about `
|
|
255
|
+
+ `magnitude ${limit}, with natural brightness variation; the listed stars stay exactly placed.`;
|
|
256
|
+
}
|
|
257
|
+
if (limit >= 5.5) {
|
|
258
|
+
return `Beyond those, add a rich field of many hundreds of fainter stars, down to about magnitude ${limit}.`;
|
|
259
|
+
}
|
|
260
|
+
if (limit >= 4.5) {
|
|
261
|
+
return `Beyond those, add a moderate scatter of stars, down to about magnitude ${limit}.`;
|
|
262
|
+
}
|
|
263
|
+
return "Beyond those, only a sparse scatter of stars is visible.";
|
|
264
|
+
}
|
|
265
|
+
// ------------------------------------------------------------------- main entry
|
|
266
|
+
/**
|
|
267
|
+
* Project the visible sky into an image frame for a place, instant, aim, and
|
|
268
|
+
* lens. Returns each in-frame body's pixel position, apparent size, brightness,
|
|
269
|
+
* and (for the Moon) phase orientation, a sky-state summary, the bright bodies
|
|
270
|
+
* just outside the frame, and a serialized prompt.
|
|
271
|
+
*
|
|
272
|
+
* Caelus computes geometry and photometry only. It does not render an image;
|
|
273
|
+
* the `prompt` and `directives` hand color and atmosphere to an image model.
|
|
274
|
+
*
|
|
275
|
+
* @param engine The engine used to evaluate positions.
|
|
276
|
+
* @param jdUt The instant, Julian Day (UT). For "at sunset", resolve it first
|
|
277
|
+
* with `riseSet(engine, "sun", jdStart, lat, lonEast, "set")`.
|
|
278
|
+
* @param view Observer, aim (azimuth and altitude), lens, and image size.
|
|
279
|
+
* @param opts Refraction inputs, star selection, and the body set.
|
|
280
|
+
* @returns A {@link SkyViewResult}.
|
|
281
|
+
* @example
|
|
282
|
+
* ```ts
|
|
283
|
+
* const set = riseSet(engine, "sun", julianDay(2026, 6, 21), 47.6, -122.3, "set")!;
|
|
284
|
+
* const view = skyView(engine, set, {
|
|
285
|
+
* observer: { lat: 47.6, lonEast: -122.3, altM: 9 },
|
|
286
|
+
* aim: { azimuth: "W", altitude: 5 },
|
|
287
|
+
* lens: "normal",
|
|
288
|
+
* image: { width: 1024, height: 683 },
|
|
289
|
+
* });
|
|
290
|
+
* view.bodies.find((b) => b.id === "moon")?.brightLimbClock;
|
|
291
|
+
* ```
|
|
292
|
+
*/
|
|
293
|
+
export function skyView(engine, jdUt, view, opts = {}) {
|
|
294
|
+
const { lat, lonEast, altM } = view.observer;
|
|
295
|
+
const { width, height } = view.image;
|
|
296
|
+
if (!(width > 0) || !(height > 0)) {
|
|
297
|
+
throw new Error("image width and height must be positive");
|
|
298
|
+
}
|
|
299
|
+
const aimAz = parseAzimuth(view.aim.azimuth);
|
|
300
|
+
const aimAlt = view.aim.altitude;
|
|
301
|
+
const lens = resolveLens(view.lens, width, height);
|
|
302
|
+
const pressure = opts.pressure ?? 1013.25;
|
|
303
|
+
const tempC = opts.tempC ?? 15.0;
|
|
304
|
+
const refract = opts.refraction ?? true;
|
|
305
|
+
// Camera basis from the aim. `right` is horizontal (no roll); near the zenith
|
|
306
|
+
// the up reference falls back to north.
|
|
307
|
+
const F = dirFromAzAlt(aimAz, aimAlt);
|
|
308
|
+
let rightRaw = cross(F, [0, 0, 1]);
|
|
309
|
+
if (norm(rightRaw) < 1e-6)
|
|
310
|
+
rightRaw = cross(F, [0, 1, 0]);
|
|
311
|
+
const right = unit(rightRaw);
|
|
312
|
+
const up = cross(right, F); // unit: right and F are orthonormal
|
|
313
|
+
const hfovR = lens.hfovDeg * DEG;
|
|
314
|
+
const vfovR = lens.vfovDeg * DEG;
|
|
315
|
+
const tanH = Math.tan(hfovR / 2);
|
|
316
|
+
const tanV = Math.tan(vfovR / 2);
|
|
317
|
+
const place = (azDeg, altTrueDeg, refractThis = refract) => {
|
|
318
|
+
const altApp = refractThis ? refractTrueToApparent(altTrueDeg, pressure, tempC) : altTrueDeg;
|
|
319
|
+
const V = dirFromAzAlt(azDeg, altApp);
|
|
320
|
+
const f = dot(V, F);
|
|
321
|
+
const rr = dot(V, right);
|
|
322
|
+
const uu = dot(V, up);
|
|
323
|
+
let xn;
|
|
324
|
+
let yn;
|
|
325
|
+
let inFrame;
|
|
326
|
+
if (lens.projection === "rectilinear") {
|
|
327
|
+
if (f > 1e-9) {
|
|
328
|
+
xn = (rr / f) / tanH;
|
|
329
|
+
yn = (uu / f) / tanV;
|
|
330
|
+
inFrame = Math.abs(xn) <= 1 && Math.abs(yn) <= 1;
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
xn = rr >= 0 ? Infinity : -Infinity;
|
|
334
|
+
yn = uu >= 0 ? Infinity : -Infinity;
|
|
335
|
+
inFrame = false;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
const theta = Math.acos(clamp1(f));
|
|
340
|
+
const psi = Math.atan2(uu, rr);
|
|
341
|
+
xn = (theta * Math.cos(psi)) / (hfovR / 2);
|
|
342
|
+
yn = (theta * Math.sin(psi)) / (vfovR / 2);
|
|
343
|
+
inFrame = Math.abs(xn) <= 1 && Math.abs(yn) <= 1;
|
|
344
|
+
}
|
|
345
|
+
const deltaDeg = Math.acos(clamp1(f)) / DEG;
|
|
346
|
+
let side = "behind";
|
|
347
|
+
if (f > 0) {
|
|
348
|
+
side = Math.abs(xn) >= Math.abs(yn)
|
|
349
|
+
? (xn > 0 ? "right" : "left")
|
|
350
|
+
: (yn > 0 ? "above" : "below");
|
|
351
|
+
}
|
|
352
|
+
const x = Number.isFinite(xn) ? Math.round(((xn + 1) / 2) * width) : NaN;
|
|
353
|
+
const y = Number.isFinite(yn) ? Math.round(((1 - yn) / 2) * height) : NaN;
|
|
354
|
+
return { altApp, x, y, inFrame, deltaDeg, side };
|
|
355
|
+
};
|
|
356
|
+
// Sun and Moon up front: they drive twilight, limiting magnitude, the sky
|
|
357
|
+
// gradient, and the Moon's bright-limb orientation.
|
|
358
|
+
const sunPos = engine.position("sun", jdUt);
|
|
359
|
+
const [sunAz, sunAltTrue] = azAlt(engine.data, sunPos.lon, sunPos.lat, jdUt, lat, lonEast);
|
|
360
|
+
const stage = twilightStage(sunAltTrue);
|
|
361
|
+
const moonPos = engine.position("moon", jdUt);
|
|
362
|
+
const [moonAz, moonAltTrue] = azAlt(engine.data, moonPos.lon, moonPos.lat, jdUt, lat, lonEast);
|
|
363
|
+
const moonPheno = pheno(engine, "moon", jdUt);
|
|
364
|
+
const moonIllum = moonPheno.phase;
|
|
365
|
+
const moonWaxing = mod(moonPos.lon - sunPos.lon, 360) < 180;
|
|
366
|
+
const moonUp = moonAltTrue > 0;
|
|
367
|
+
const bortle = opts.bortle;
|
|
368
|
+
const limit = limitingMag(stage, moonUp ? moonAltTrue : null, moonIllum, bortle);
|
|
369
|
+
const skyIsDark = stage === "astronomical" || stage === "night";
|
|
370
|
+
const moonBright = moonUp && moonIllum > 0.55;
|
|
371
|
+
let brightestAz = null;
|
|
372
|
+
if (sunAltTrue > -18)
|
|
373
|
+
brightestAz = sunAz;
|
|
374
|
+
else if (moonUp && moonIllum > 0.3)
|
|
375
|
+
brightestAz = moonAz;
|
|
376
|
+
const lowNote = (id, altApp) => {
|
|
377
|
+
if (altApp >= 5)
|
|
378
|
+
return undefined;
|
|
379
|
+
if (id === "sun")
|
|
380
|
+
return "on the horizon: flattened by refraction, deep warm color";
|
|
381
|
+
return "near the horizon: dimmed and reddened by the atmosphere";
|
|
382
|
+
};
|
|
383
|
+
const bodies = [];
|
|
384
|
+
const offFrame = [];
|
|
385
|
+
const bodyIds = opts.bodies ?? [
|
|
386
|
+
"sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn",
|
|
387
|
+
];
|
|
388
|
+
for (const id of bodyIds) {
|
|
389
|
+
const pos = id === "sun" ? sunPos : id === "moon" ? moonPos : engine.position(id, jdUt);
|
|
390
|
+
const [az, altTrue] = id === "sun"
|
|
391
|
+
? [sunAz, sunAltTrue]
|
|
392
|
+
: id === "moon"
|
|
393
|
+
? [moonAz, moonAltTrue]
|
|
394
|
+
: azAlt(engine.data, pos.lon, pos.lat, jdUt, lat, lonEast);
|
|
395
|
+
const p = place(az, altTrue);
|
|
396
|
+
// The Sun stays in view as it touches the horizon (the sunset subject);
|
|
397
|
+
// every other body must be above the horizon to appear.
|
|
398
|
+
const visible = id === "sun" ? p.altApp > -1.0 : p.altApp > 0;
|
|
399
|
+
if (!visible)
|
|
400
|
+
continue;
|
|
401
|
+
let magnitude = null;
|
|
402
|
+
let diamDeg = 0;
|
|
403
|
+
if (DIAMETER_KM[id] !== undefined) {
|
|
404
|
+
const ph = pheno(engine, id, jdUt);
|
|
405
|
+
magnitude = Math.round(ph.magnitude * 100) / 100;
|
|
406
|
+
diamDeg = ph.diameter;
|
|
407
|
+
}
|
|
408
|
+
const sizePx = Math.max(diamDeg > 0 ? 1 : 0, Math.round((diamDeg * width) / lens.hfovDeg));
|
|
409
|
+
const nakedEye = id === "sun" || id === "moon"
|
|
410
|
+
|| (magnitude !== null && magnitude <= limit);
|
|
411
|
+
if (!p.inFrame) {
|
|
412
|
+
if (nakedEye) {
|
|
413
|
+
offFrame.push({
|
|
414
|
+
id, name: displayName(id), side: p.side,
|
|
415
|
+
deltaDeg: Math.round(p.deltaDeg * 10) / 10,
|
|
416
|
+
azimuthDeg: Math.round(az * 10) / 10,
|
|
417
|
+
altitudeDeg: Math.round(p.altApp * 10) / 10,
|
|
418
|
+
magnitude,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
const body = {
|
|
424
|
+
id, name: displayName(id),
|
|
425
|
+
azimuthDeg: Math.round(az * 10) / 10,
|
|
426
|
+
altitudeDeg: Math.round(p.altApp * 10) / 10,
|
|
427
|
+
x: p.x, y: p.y, inFrame: true,
|
|
428
|
+
sizePx, angularDiameterDeg: Math.round(diamDeg * 1e4) / 1e4,
|
|
429
|
+
magnitude, nakedEye,
|
|
430
|
+
brightnessHint: brightnessDescriptor(magnitude, nakedEye),
|
|
431
|
+
note: lowNote(id, p.altApp),
|
|
432
|
+
};
|
|
433
|
+
if (id === "moon") {
|
|
434
|
+
body.illum = Math.round(moonIllum * 1000) / 1000;
|
|
435
|
+
body.phaseName = moonPhaseName(moonIllum, moonWaxing);
|
|
436
|
+
// Bright limb points along the great circle from Moon toward Sun.
|
|
437
|
+
const M = dirFromAzAlt(moonAz, moonAltTrue);
|
|
438
|
+
const S = dirFromAzAlt(sunAz, sunAltTrue);
|
|
439
|
+
const t = unit([
|
|
440
|
+
S[0] - dot(S, M) * M[0], S[1] - dot(S, M) * M[1], S[2] - dot(S, M) * M[2],
|
|
441
|
+
]);
|
|
442
|
+
const angle = mod(Math.atan2(dot(t, up), dot(t, right)) / DEG, 360);
|
|
443
|
+
body.brightLimbAngleDeg = Math.round(angle * 10) / 10;
|
|
444
|
+
body.brightLimbClock = clockOf(angle);
|
|
445
|
+
}
|
|
446
|
+
bodies.push(body);
|
|
447
|
+
}
|
|
448
|
+
// Stars. With the deep pack loaded and a dark sky, pin the complete naked-eye
|
|
449
|
+
// field to the limiting magnitude (animation-grade, exact). Otherwise place
|
|
450
|
+
// the bright named catalog and let the directive ask the model to fill the
|
|
451
|
+
// fainter field. A Bortle class shows named stars to the limit; without one,
|
|
452
|
+
// the legacy bright-only default keeps existing output unchanged.
|
|
453
|
+
const wantStars = opts.includeStars ?? true;
|
|
454
|
+
const deep = engine.data.deepStars;
|
|
455
|
+
const useDeep = wantStars && deep !== undefined
|
|
456
|
+
&& (opts.deepField ?? (skyIsDark && !moonBright));
|
|
457
|
+
const starMagLimit = opts.maxStarMag ?? (bortle !== undefined ? limit : 2.5);
|
|
458
|
+
const starCap = Math.min(starMagLimit, limit);
|
|
459
|
+
let starfield = { source: "none", count: 0, complete: false, limitingMag: limit };
|
|
460
|
+
const toStarBody = (name, mag, az, p) => ({
|
|
461
|
+
id: `star:${name}`, name,
|
|
462
|
+
azimuthDeg: Math.round(az * 10) / 10,
|
|
463
|
+
altitudeDeg: Math.round(p.altApp * 10) / 10,
|
|
464
|
+
x: p.x, y: p.y, inFrame: true,
|
|
465
|
+
sizePx: 0, angularDiameterDeg: 0,
|
|
466
|
+
magnitude: Math.round(mag * 100) / 100, nakedEye: true,
|
|
467
|
+
brightnessHint: brightnessDescriptor(Math.round(mag * 100) / 100, true),
|
|
468
|
+
});
|
|
469
|
+
if (useDeep && deep) {
|
|
470
|
+
const jde = jdTT(jdUt);
|
|
471
|
+
const found = [];
|
|
472
|
+
for (const name in deep.stars) {
|
|
473
|
+
const s = deep.stars[name];
|
|
474
|
+
if (s.mag > limit)
|
|
475
|
+
continue;
|
|
476
|
+
const [lonR, latR] = starApparent(engine.data, s, jde);
|
|
477
|
+
const [az, altTrue] = azAlt(engine.data, lonR / DEG, latR / DEG, jdUt, lat, lonEast);
|
|
478
|
+
const p = place(az, altTrue);
|
|
479
|
+
if (p.altApp <= 0 || !p.inFrame)
|
|
480
|
+
continue;
|
|
481
|
+
found.push(toStarBody(name, s.mag, az, p));
|
|
482
|
+
}
|
|
483
|
+
found.sort((a, b) => (a.magnitude ?? 99) - (b.magnitude ?? 99));
|
|
484
|
+
const capped = found.slice(0, opts.maxStars ?? 4000);
|
|
485
|
+
bodies.push(...capped);
|
|
486
|
+
starfield = { source: "deep", count: capped.length, complete: true, limitingMag: limit };
|
|
487
|
+
}
|
|
488
|
+
else if (wantStars && engine.starNames().length > 0 && starCap > -10) {
|
|
489
|
+
const found = [];
|
|
490
|
+
for (const name of engine.starNames()) {
|
|
491
|
+
const s = engine.fixedStar(name, jdUt);
|
|
492
|
+
if (s.mag > starCap)
|
|
493
|
+
continue;
|
|
494
|
+
const [az, altTrue] = azAlt(engine.data, s.lon, s.lat, jdUt, lat, lonEast);
|
|
495
|
+
const p = place(az, altTrue);
|
|
496
|
+
if (p.altApp <= 0 || !p.inFrame)
|
|
497
|
+
continue;
|
|
498
|
+
found.push(toStarBody(name, s.mag, az, p));
|
|
499
|
+
}
|
|
500
|
+
found.sort((a, b) => (a.magnitude ?? 99) - (b.magnitude ?? 99));
|
|
501
|
+
const capped = found.slice(0, opts.maxStars ?? (bortle !== undefined ? 250 : 40));
|
|
502
|
+
bodies.push(...capped);
|
|
503
|
+
starfield = { source: "named", count: capped.length, complete: false, limitingMag: limit };
|
|
504
|
+
}
|
|
505
|
+
// Horizon row at the center azimuth: the geometric horizon (no refraction, it
|
|
506
|
+
// is a ground reference, not a body). Exact for a no-roll rectilinear camera;
|
|
507
|
+
// approximate (a curve's midpoint) for fisheye.
|
|
508
|
+
const horizon = place(aimAz, 0, false);
|
|
509
|
+
const horizonY = Number.isFinite(horizon.y) ? horizon.y : null;
|
|
510
|
+
// Keep "just out of frame" honest: only bodies in front and within one
|
|
511
|
+
// horizontal field of the center, nearest first.
|
|
512
|
+
const offMax = Math.max(lens.hfovDeg, lens.vfovDeg);
|
|
513
|
+
const offNear = offFrame
|
|
514
|
+
.filter((o) => o.side !== "behind" && o.deltaDeg <= offMax)
|
|
515
|
+
.sort((a, b) => a.deltaDeg - b.deltaDeg);
|
|
516
|
+
offFrame.length = 0;
|
|
517
|
+
offFrame.push(...offNear);
|
|
518
|
+
// Milky Way band: sample the galactic equator, project it, and report where
|
|
519
|
+
// it crosses the frame and where its bright center (Sagittarius) sits. Visible
|
|
520
|
+
// only in a dark sky without a bright Moon or heavy light pollution.
|
|
521
|
+
const mwDark = skyIsDark && !moonBright && (bortle === undefined || bortle <= 6);
|
|
522
|
+
const galSamples = [];
|
|
523
|
+
let gcSamp = null;
|
|
524
|
+
for (let l = 0; l < 360; l += 2) {
|
|
525
|
+
const [lonR, latR] = galacticEquatorToEclJ2000(l);
|
|
526
|
+
const [lonD, latD] = precessEcliptic(lonR, latR, J2000, jdUt);
|
|
527
|
+
const [az, altTrue] = azAlt(engine.data, lonD / DEG, latD / DEG, jdUt, lat, lonEast);
|
|
528
|
+
const pl = place(az, altTrue, false); // diffuse band: no refraction
|
|
529
|
+
const s = { l, x: pl.x, y: pl.y, inFrame: pl.inFrame, altApp: pl.altApp, side: pl.side };
|
|
530
|
+
galSamples.push(s);
|
|
531
|
+
if (l === 0)
|
|
532
|
+
gcSamp = s;
|
|
533
|
+
}
|
|
534
|
+
const galUp = galSamples.filter((s) => s.altApp > 0);
|
|
535
|
+
const galInFrame = galUp.filter((s) => s.inFrame && Number.isFinite(s.x));
|
|
536
|
+
const visible = mwDark && galUp.length > 0;
|
|
537
|
+
let entry = null;
|
|
538
|
+
let exit = null;
|
|
539
|
+
if (galInFrame.length >= 2) {
|
|
540
|
+
const byX = [...galInFrame].sort((a, b) => a.x - b.x);
|
|
541
|
+
entry = { x: byX[0].x, y: byX[0].y };
|
|
542
|
+
exit = { x: byX[byX.length - 1].x, y: byX[byX.length - 1].y };
|
|
543
|
+
}
|
|
544
|
+
const gcUp = gcSamp !== null && gcSamp.altApp > 0 && Number.isFinite(gcSamp.x);
|
|
545
|
+
const galacticCenter = gcUp && gcSamp !== null
|
|
546
|
+
? {
|
|
547
|
+
x: gcSamp.x, y: gcSamp.y, inFrame: gcSamp.inFrame,
|
|
548
|
+
altitudeDeg: Math.round(gcSamp.altApp * 10) / 10, side: gcSamp.side,
|
|
549
|
+
}
|
|
550
|
+
: null;
|
|
551
|
+
let mwNote;
|
|
552
|
+
if (!mwDark) {
|
|
553
|
+
mwNote = moonBright ? "washed out by a bright Moon"
|
|
554
|
+
: (bortle !== undefined && bortle > 6) ? "lost to light pollution"
|
|
555
|
+
: "the sky is too bright (daylight or twilight)";
|
|
556
|
+
}
|
|
557
|
+
else if (galUp.length === 0) {
|
|
558
|
+
mwNote = "the galactic plane is entirely below the horizon";
|
|
559
|
+
}
|
|
560
|
+
else if (galInFrame.length === 0) {
|
|
561
|
+
mwNote = "above the horizon but outside this frame";
|
|
562
|
+
}
|
|
563
|
+
else if (galacticCenter?.inFrame) {
|
|
564
|
+
mwNote = "crosses the frame, with the bright center in view";
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
mwNote = `crosses the frame; the bright center is ${gcUp ? "above the horizon, off-frame" : "below the horizon"}`;
|
|
568
|
+
}
|
|
569
|
+
const milkyWay = {
|
|
570
|
+
visible, inFrame: visible && galInFrame.length > 0, entry, exit, galacticCenter, note: mwNote,
|
|
571
|
+
};
|
|
572
|
+
const fieldClause = starfieldClause(limit, skyIsDark, moonBright, starfield);
|
|
573
|
+
// The visible celestial pole: the sky's rotation center. Azimuth 0 (north) or
|
|
574
|
+
// 180 (south), altitude |lat|.
|
|
575
|
+
const poleAltTrue = Math.abs(lat);
|
|
576
|
+
const polePlace = place(lat >= 0 ? 0 : 180, poleAltTrue, false);
|
|
577
|
+
const pole = {
|
|
578
|
+
which: lat >= 0 ? "north" : "south",
|
|
579
|
+
altitudeDeg: Math.round(poleAltTrue * 10) / 10,
|
|
580
|
+
x: Number.isFinite(polePlace.x) ? polePlace.x : null,
|
|
581
|
+
y: Number.isFinite(polePlace.y) ? polePlace.y : null,
|
|
582
|
+
inFrame: polePlace.inFrame,
|
|
583
|
+
};
|
|
584
|
+
// Reference-frame overlays: the ecliptic, zodiac signs, house cusps, and
|
|
585
|
+
// constellation figures projected into the frame. Annotations, not photoreal.
|
|
586
|
+
let overlays = null;
|
|
587
|
+
if (opts.overlays) {
|
|
588
|
+
const req = opts.overlays;
|
|
589
|
+
overlays = { ecliptic: null, signs: null, houses: null, constellations: null };
|
|
590
|
+
// Project an ecliptic-of-date point (deg) to a frame placement.
|
|
591
|
+
const projEcl = (lonDeg, latDeg) => {
|
|
592
|
+
const [az, altTrue] = azAlt(engine.data, lonDeg, latDeg, jdUt, lat, lonEast);
|
|
593
|
+
return place(az, altTrue, false);
|
|
594
|
+
};
|
|
595
|
+
// In-frame polylines from a path of ecliptic-of-date [lon, lat] points.
|
|
596
|
+
const polylines = (pts) => {
|
|
597
|
+
const segs = [];
|
|
598
|
+
let cur = [];
|
|
599
|
+
for (const [lo, la] of pts) {
|
|
600
|
+
const p = projEcl(lo, la);
|
|
601
|
+
if (p.inFrame && Number.isFinite(p.x))
|
|
602
|
+
cur.push({ x: p.x, y: p.y });
|
|
603
|
+
else {
|
|
604
|
+
if (cur.length > 1)
|
|
605
|
+
segs.push(cur);
|
|
606
|
+
cur = [];
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (cur.length > 1)
|
|
610
|
+
segs.push(cur);
|
|
611
|
+
return segs;
|
|
612
|
+
};
|
|
613
|
+
if (req.ecliptic) {
|
|
614
|
+
const path = [];
|
|
615
|
+
for (let l = 0; l <= 360; l += 1)
|
|
616
|
+
path.push([l, 0]);
|
|
617
|
+
overlays.ecliptic = polylines(path).map((s) => ({ label: "ecliptic", points: s }));
|
|
618
|
+
}
|
|
619
|
+
if (req.signs) {
|
|
620
|
+
const marks = [];
|
|
621
|
+
for (let k = 0; k < 12; k++) {
|
|
622
|
+
const lon = k * 30 + 15; // sign midpoint, for the label
|
|
623
|
+
const p = projEcl(lon, 0);
|
|
624
|
+
if (p.inFrame && Number.isFinite(p.x))
|
|
625
|
+
marks.push({ text: SIGNS[k], x: p.x, y: p.y, lon });
|
|
626
|
+
}
|
|
627
|
+
overlays.signs = marks;
|
|
628
|
+
}
|
|
629
|
+
if (req.houses) {
|
|
630
|
+
const marks = [];
|
|
631
|
+
try {
|
|
632
|
+
const chart = engine.chartAt(jdUt, lat, lonEast, { houseSystem: req.houseSystem ?? "placidus" });
|
|
633
|
+
for (let i = 0; i < 12; i++) {
|
|
634
|
+
const p = projEcl(chart.cusps[i], 0);
|
|
635
|
+
if (p.inFrame && Number.isFinite(p.x))
|
|
636
|
+
marks.push({ text: `H${i + 1}`, x: p.x, y: p.y, lon: chart.cusps[i] });
|
|
637
|
+
}
|
|
638
|
+
const ang = [
|
|
639
|
+
["ASC", chart.angles.asc], ["MC", chart.angles.mc],
|
|
640
|
+
["DSC", mod(chart.angles.asc + 180, 360)], ["IC", mod(chart.angles.mc + 180, 360)],
|
|
641
|
+
];
|
|
642
|
+
for (const [t, lo] of ang) {
|
|
643
|
+
const p = projEcl(lo, 0);
|
|
644
|
+
if (p.inFrame && Number.isFinite(p.x))
|
|
645
|
+
marks.push({ text: t, x: p.x, y: p.y, lon: lo });
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
catch { /* polar house failure: leave what was collected */ }
|
|
649
|
+
overlays.houses = marks;
|
|
650
|
+
}
|
|
651
|
+
if (req.constellations && engine.data.constellations) {
|
|
652
|
+
const jde = jdTT(jdUt);
|
|
653
|
+
const lines = [];
|
|
654
|
+
const toDate = (lo, la) => {
|
|
655
|
+
const [l2, b2] = precessEcliptic(lo * DEG, la * DEG, J2000, jde);
|
|
656
|
+
return [l2 / DEG, b2 / DEG];
|
|
657
|
+
};
|
|
658
|
+
for (const fig of engine.data.constellations.lines) {
|
|
659
|
+
for (const seg of fig.segs) {
|
|
660
|
+
const ofDate = seg.map(([lo, la]) => toDate(lo, la));
|
|
661
|
+
for (const s of polylines(ofDate))
|
|
662
|
+
lines.push({ label: fig.con, points: s });
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
const labels = [];
|
|
666
|
+
for (const lab of engine.data.constellations.labels) {
|
|
667
|
+
const [lo, la] = toDate(lab.lon, lab.lat);
|
|
668
|
+
const p = projEcl(lo, la);
|
|
669
|
+
if (p.inFrame && Number.isFinite(p.x))
|
|
670
|
+
labels.push({ text: lab.name, x: p.x, y: p.y });
|
|
671
|
+
}
|
|
672
|
+
overlays.constellations = { lines, labels };
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
const sky = {
|
|
676
|
+
twilight: stage,
|
|
677
|
+
sunAltitudeDeg: Math.round(sunAltTrue * 10) / 10,
|
|
678
|
+
sunAzimuthDeg: Math.round(sunAz * 10) / 10,
|
|
679
|
+
limitingMag: limit,
|
|
680
|
+
moonAltitudeDeg: Math.round(moonAltTrue * 10) / 10,
|
|
681
|
+
moonIllum: Math.round(moonIllum * 1000) / 1000,
|
|
682
|
+
brightestAzimuthDeg: brightestAz === null ? null : Math.round(brightestAz * 10) / 10,
|
|
683
|
+
horizonY,
|
|
684
|
+
};
|
|
685
|
+
const directives = buildDirectives(lens, sky, milkyWay, fieldClause, overlays, width, height, aimAz, aimAlt);
|
|
686
|
+
const prompt = buildPrompt(bodies, offFrame, directives, starfield);
|
|
687
|
+
const renderPlan = buildRenderPlan(sky, bodies, starfield, milkyWay, overlays, pole, directives, width, height);
|
|
688
|
+
return {
|
|
689
|
+
instant: { jdUt, utc: jdToUtcIso(jdUt) },
|
|
690
|
+
observer: { lat, lonEast, ...(altM !== undefined ? { altM } : {}) },
|
|
691
|
+
aim: { azimuthDeg: Math.round(aimAz * 10) / 10, altitudeDeg: aimAlt, compass: compassOf(aimAz) },
|
|
692
|
+
lens,
|
|
693
|
+
image: { width, height },
|
|
694
|
+
sky,
|
|
695
|
+
bodies,
|
|
696
|
+
offFrame,
|
|
697
|
+
milkyWay,
|
|
698
|
+
pole,
|
|
699
|
+
starfield,
|
|
700
|
+
overlays,
|
|
701
|
+
renderPlan,
|
|
702
|
+
directives,
|
|
703
|
+
prompt,
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
const SIDEREAL_DEG_PER_MIN = 360 / 1436.0682; // 360 deg per sidereal day
|
|
707
|
+
/**
|
|
708
|
+
* A time sequence of {@link skyView} frames for the same place, aim, and lens:
|
|
709
|
+
* the keyframes for an accurate night-sky animation. Each frame is a complete,
|
|
710
|
+
* physically exact spec; across frames the sky rotates about the pole, the Moon
|
|
711
|
+
* drifts and changes phase, twilight evolves, and the Milky Way wheels. The
|
|
712
|
+
* geometry is continuous, so the frames are temporally coherent; supplying them
|
|
713
|
+
* as control images (or reprojecting one rendered plate by the per-frame
|
|
714
|
+
* rotation) is how to keep the rendered output coherent too.
|
|
715
|
+
*
|
|
716
|
+
* @param engine The engine used to evaluate positions.
|
|
717
|
+
* @param view Observer, aim, lens, and image size, shared by every frame.
|
|
718
|
+
* @param seq Frame count and timing (`stepMinutes` or `endJdUt`).
|
|
719
|
+
* @param opts Per-frame {@link SkyViewOptions} (bortle, refraction, bodies).
|
|
720
|
+
* @returns A {@link SkyViewSequence}: the frames plus timing and the sky's
|
|
721
|
+
* per-step rotation about the celestial pole (each frame carries its `pole`).
|
|
722
|
+
* @example
|
|
723
|
+
* ```ts
|
|
724
|
+
* // One frame per 6 minutes for two hours from astronomical dusk
|
|
725
|
+
* const seq = skyViewSequence(engine, view, { startJdUt: dusk, frames: 21, stepMinutes: 6 });
|
|
726
|
+
* seq.rotationDegPerStep; // ~1.5 deg of sky rotation per frame
|
|
727
|
+
* seq.frames[0].pole; // the rotation center in pixels
|
|
728
|
+
* ```
|
|
729
|
+
*/
|
|
730
|
+
export function skyViewSequence(engine, view, seq, opts = {}) {
|
|
731
|
+
if (!Number.isInteger(seq.frames) || seq.frames < 1) {
|
|
732
|
+
throw new Error("frames must be a positive integer");
|
|
733
|
+
}
|
|
734
|
+
let stepDays = 0;
|
|
735
|
+
if (seq.frames > 1) {
|
|
736
|
+
if (seq.endJdUt !== undefined) {
|
|
737
|
+
stepDays = (seq.endJdUt - seq.startJdUt) / (seq.frames - 1);
|
|
738
|
+
}
|
|
739
|
+
else if (seq.stepMinutes !== undefined) {
|
|
740
|
+
stepDays = seq.stepMinutes / 1440;
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
throw new Error("provide stepMinutes or endJdUt for a multi-frame sequence");
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
const frames = [];
|
|
747
|
+
for (let i = 0; i < seq.frames; i++) {
|
|
748
|
+
frames.push(skyView(engine, seq.startJdUt + i * stepDays, view, opts));
|
|
749
|
+
}
|
|
750
|
+
const stepMinutes = stepDays * 1440;
|
|
751
|
+
return {
|
|
752
|
+
frames,
|
|
753
|
+
count: frames.length,
|
|
754
|
+
startJdUt: seq.startJdUt,
|
|
755
|
+
endJdUt: seq.startJdUt + (seq.frames - 1) * stepDays,
|
|
756
|
+
stepMinutes,
|
|
757
|
+
durationMinutes: stepMinutes * (seq.frames - 1),
|
|
758
|
+
rotationDegPerHour: SIDEREAL_DEG_PER_MIN * 60,
|
|
759
|
+
rotationDegPerStep: SIDEREAL_DEG_PER_MIN * stepMinutes,
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
// ------------------------------------------------------------- serialization
|
|
763
|
+
function buildDirectives(lens, sky, milkyWay, fieldClause, overlays, width, height, aimAz, aimAlt) {
|
|
764
|
+
const out = [];
|
|
765
|
+
out.push(`Frame ${width}x${height}px, ${lens.name} lens (${lens.focalLengthMm}mm, `
|
|
766
|
+
+ `${lens.hfovDeg} deg horizontal field), ${lens.projection} projection. `
|
|
767
|
+
+ `Looking ${compassOf(aimAz)} (azimuth ${Math.round(aimAz)} deg) at `
|
|
768
|
+
+ `altitude ${aimAlt} deg.`);
|
|
769
|
+
if (lens.projection === "rectilinear" && lens.hfovDeg > 100) {
|
|
770
|
+
out.push("Field of view exceeds 100 deg on a rectilinear projection; corners stretch heavily. Consider the ultrawide (fisheye) lens.");
|
|
771
|
+
}
|
|
772
|
+
if (sky.horizonY !== null && sky.horizonY >= 0 && sky.horizonY <= height) {
|
|
773
|
+
const pct = Math.round((sky.horizonY / height) * 100);
|
|
774
|
+
out.push(`Keep the horizon level and straight at y=${sky.horizonY} (${pct}% down the frame).`);
|
|
775
|
+
}
|
|
776
|
+
else {
|
|
777
|
+
out.push("The true horizon is outside the frame.");
|
|
778
|
+
}
|
|
779
|
+
if (sky.twilight === "night") {
|
|
780
|
+
// Sun below -18 deg: no solar twilight. A warm horizon glow would be a
|
|
781
|
+
// physical impossibility here; the sky is dark to the horizon, neutral-lit
|
|
782
|
+
// by the Moon if one is up.
|
|
783
|
+
const moonLit = sky.moonAltitudeDeg !== null && sky.moonAltitudeDeg > 0 && (sky.moonIllum ?? 0) > 0.3;
|
|
784
|
+
out.push(`Deep night: the Sun is ${Math.abs(sky.sunAltitudeDeg).toFixed(1)} deg below the horizon, no twilight. `
|
|
785
|
+
+ (moonLit
|
|
786
|
+
? "Moonlight casts a soft, neutral blue-grey wash, brighter near the Moon. "
|
|
787
|
+
: "The sky is dark all the way down to the horizon (at most faint airglow); do not paint a warm twilight glow. ")
|
|
788
|
+
+ "You choose exact colors.");
|
|
789
|
+
}
|
|
790
|
+
else {
|
|
791
|
+
const sunWhere = sky.sunAltitudeDeg >= 0 ? "above" : "below";
|
|
792
|
+
const grad = sky.brightestAzimuthDeg !== null
|
|
793
|
+
? `Render the sky brightest toward ${compassOf(sky.brightestAzimuthDeg)} `
|
|
794
|
+
+ `(azimuth ${Math.round(sky.brightestAzimuthDeg)} deg), fading across the frame. `
|
|
795
|
+
: "";
|
|
796
|
+
out.push(`${sky.twilight} twilight: the Sun is ${Math.abs(sky.sunAltitudeDeg).toFixed(1)} deg ${sunWhere} `
|
|
797
|
+
+ `the horizon. ${grad}You choose exact colors; keep it warm low and cool high.`);
|
|
798
|
+
}
|
|
799
|
+
out.push(`Naked-eye limit about magnitude ${sky.limitingMag}. Render every body listed below. ${fieldClause}`);
|
|
800
|
+
if (milkyWay.visible && milkyWay.inFrame && milkyWay.entry && milkyWay.exit) {
|
|
801
|
+
const gc = milkyWay.galacticCenter;
|
|
802
|
+
const center = gc?.inFrame
|
|
803
|
+
? `Its brightest part, the galactic center in Sagittarius, is at (${gc.x},${gc.y}). `
|
|
804
|
+
: gc
|
|
805
|
+
? `Its bright center (Sagittarius) lies off-frame ${gc.side}. `
|
|
806
|
+
: "Its bright center is below the horizon, so the band here is the fainter outer arm. ";
|
|
807
|
+
out.push(`The Milky Way crosses the frame, entering near (${milkyWay.entry.x},${milkyWay.entry.y}) `
|
|
808
|
+
+ `and exiting near (${milkyWay.exit.x},${milkyWay.exit.y}). ${center}`
|
|
809
|
+
+ "Render it as a soft, mottled band of unresolved starlight, dustier and brighter toward the "
|
|
810
|
+
+ "center; not individual stars.");
|
|
811
|
+
}
|
|
812
|
+
if (overlays) {
|
|
813
|
+
const parts = [];
|
|
814
|
+
if (overlays.ecliptic?.length)
|
|
815
|
+
parts.push("the ecliptic line");
|
|
816
|
+
if (overlays.signs?.length) {
|
|
817
|
+
parts.push(`zodiac signs (${overlays.signs.map((s) => s.text).join(", ")})`);
|
|
818
|
+
}
|
|
819
|
+
if (overlays.houses?.length)
|
|
820
|
+
parts.push("house cusps and the angles (ASC, MC)");
|
|
821
|
+
if (overlays.constellations?.labels.length) {
|
|
822
|
+
const names = overlays.constellations.labels.map((l) => l.text);
|
|
823
|
+
const shown = names.slice(0, 8).join(", ") + (names.length > 8 ? ", ..." : "");
|
|
824
|
+
parts.push(`constellation figures (${shown})`);
|
|
825
|
+
}
|
|
826
|
+
if (parts.length) {
|
|
827
|
+
out.push(`OVERLAY (optional annotation layer, not part of a photoreal sky): the structured \`overlays\` `
|
|
828
|
+
+ `data holds exact pixels for ${parts.join("; ")}. Draw these only for an annotated star chart, `
|
|
829
|
+
+ "as thin lines and small labels over the sky.");
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
out.push("You set color, light, and atmosphere. Do not move, resize, or recolor the placed "
|
|
833
|
+
+ "bodies for composition; their positions and sizes are physically correct.");
|
|
834
|
+
return out;
|
|
835
|
+
}
|
|
836
|
+
const ANCHOR_MAG = 2.5; // stars brighter than this are listed individually
|
|
837
|
+
function buildPrompt(bodies, offFrame, directives, starfield) {
|
|
838
|
+
const lines = [];
|
|
839
|
+
lines.push("PHOTOREALISTIC SKY, exact placement (pixel origin top-left):");
|
|
840
|
+
lines.push("");
|
|
841
|
+
lines.push("SCENE:");
|
|
842
|
+
for (const d of directives)
|
|
843
|
+
lines.push(`- ${d}`);
|
|
844
|
+
lines.push("");
|
|
845
|
+
lines.push("BODIES (render EVERY one at its given pixel; do not relocate, rescale, or omit any):");
|
|
846
|
+
if (bodies.length === 0)
|
|
847
|
+
lines.push("- none in frame");
|
|
848
|
+
// List the bright anchors individually; a large faint field is summarized,
|
|
849
|
+
// with its exact pixels carried in the structured data.
|
|
850
|
+
const isField = (b) => b.id.startsWith("star:") && (b.magnitude ?? 99) > ANCHOR_MAG;
|
|
851
|
+
const fieldStars = bodies.filter(isField);
|
|
852
|
+
for (const b of bodies) {
|
|
853
|
+
if (isField(b))
|
|
854
|
+
continue;
|
|
855
|
+
const parts = [`${b.name} at (${b.x},${b.y})`];
|
|
856
|
+
// A body with a resolvable disk (Sun, Moon) gets a pixel size; a planet or
|
|
857
|
+
// star is a point whose on-screen presence is its brightness, not its
|
|
858
|
+
// sub-pixel disk, so it leads with the brightness cue instead.
|
|
859
|
+
const isDisk = b.sizePx >= 3;
|
|
860
|
+
if (isDisk) {
|
|
861
|
+
parts.push(`~${b.sizePx}px wide disk`);
|
|
862
|
+
if (b.phaseName) {
|
|
863
|
+
parts.push(`${b.phaseName}, ${Math.round((b.illum ?? 0) * 100)}% lit`);
|
|
864
|
+
if (b.brightLimbClock)
|
|
865
|
+
parts.push(`bright side toward ${b.brightLimbClock}`);
|
|
866
|
+
}
|
|
867
|
+
else if (b.brightnessHint) {
|
|
868
|
+
parts.push(b.brightnessHint);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
else {
|
|
872
|
+
parts.push("point of light");
|
|
873
|
+
if (b.brightnessHint)
|
|
874
|
+
parts.push(b.brightnessHint);
|
|
875
|
+
}
|
|
876
|
+
let line = `- ${parts.join(", ")}`;
|
|
877
|
+
if (b.note)
|
|
878
|
+
line += `. ${b.note}`;
|
|
879
|
+
lines.push(line);
|
|
880
|
+
}
|
|
881
|
+
if (fieldStars.length > 0) {
|
|
882
|
+
lines.push(starfield.complete
|
|
883
|
+
? `- Plus ${fieldStars.length} field stars at the exact pixels in the structured \`bodies\` data, `
|
|
884
|
+
+ `a complete naked-eye field to magnitude ${starfield.limitingMag}: render each as a fine point of `
|
|
885
|
+
+ "light; do not add, move, or omit stars."
|
|
886
|
+
: `- Plus ${fieldStars.length} fainter stars at the exact pixels in the structured \`bodies\` data; `
|
|
887
|
+
+ "render them as fine points, and you may add a sparse scatter more.");
|
|
888
|
+
}
|
|
889
|
+
if (offFrame.length > 0) {
|
|
890
|
+
lines.push("");
|
|
891
|
+
lines.push("OUTSIDE THE FRAME (do not draw these inside it):");
|
|
892
|
+
const sideText = {
|
|
893
|
+
left: "off the left edge", right: "off the right edge",
|
|
894
|
+
above: "above the top edge", below: "below the bottom edge", behind: "behind the camera",
|
|
895
|
+
};
|
|
896
|
+
for (const o of offFrame) {
|
|
897
|
+
lines.push(`- ${o.name} is ${sideText[o.side]}, ${o.deltaDeg} deg from center`);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return lines.join("\n");
|
|
901
|
+
}
|
|
902
|
+
/** The body-free background plate prompt: the scene directives (camera, horizon,
|
|
903
|
+
* sky color) with every body, star, Milky Way, and overlay directive removed,
|
|
904
|
+
* and a hard no-bodies instruction added. The plate is pure atmosphere. */
|
|
905
|
+
function buildBackgroundPrompt(directives) {
|
|
906
|
+
const drop = ["Naked-eye limit", "The Milky Way", "OVERLAY", "You set color"];
|
|
907
|
+
const scene = directives.filter((d) => !drop.some((p) => d.startsWith(p)));
|
|
908
|
+
const lines = ["PHOTOREALISTIC SKY PLATE (atmosphere and horizon only, no celestial bodies):", ""];
|
|
909
|
+
for (const d of scene)
|
|
910
|
+
lines.push(`- ${d}`);
|
|
911
|
+
lines.push("- Render ONLY the sky gradient, clouds, atmosphere, and any horizon or foreground. "
|
|
912
|
+
+ "Do NOT draw the Sun, Moon, planets, stars, the Milky Way, or any point of light: those are "
|
|
913
|
+
+ "composited separately. Keep the sky clean and even, with no baked-in glare where bright "
|
|
914
|
+
+ "bodies will sit.");
|
|
915
|
+
return lines.join("\n");
|
|
916
|
+
}
|
|
917
|
+
/** Assemble the {@link RenderPlan}: a body-free plate plus the locally
|
|
918
|
+
* composited computed layers, animation strategy, and grading notes. */
|
|
919
|
+
function buildRenderPlan(sky, bodies, starfield, milkyWay, overlays, pole, directives, width, height) {
|
|
920
|
+
const planetCount = bodies.filter((b) => !b.id.startsWith("star:")).length;
|
|
921
|
+
const starCount = bodies.length - planetCount;
|
|
922
|
+
const overlayCount = overlays
|
|
923
|
+
? (overlays.ecliptic?.length ?? 0) + (overlays.signs?.length ?? 0)
|
|
924
|
+
+ (overlays.houses?.length ?? 0) + (overlays.constellations?.lines.length ?? 0)
|
|
925
|
+
: 0;
|
|
926
|
+
const layers = [
|
|
927
|
+
{
|
|
928
|
+
kind: "bodies", present: planetCount > 0, count: planetCount,
|
|
929
|
+
composite: "Additive sprites at each pixel: refraction-flattened disks for the Sun and Moon "
|
|
930
|
+
+ "(the Moon at its lit fraction, bright limb toward its clock angle), brightness-scaled "
|
|
931
|
+
+ "glints for the planets.",
|
|
932
|
+
},
|
|
933
|
+
{
|
|
934
|
+
kind: "stars", present: starCount > 0, count: starCount,
|
|
935
|
+
composite: starfield.complete
|
|
936
|
+
? "Fine additive points at the exact pixels in `bodies` (a complete field to the limiting "
|
|
937
|
+
+ "magnitude); size and brightness from magnitude. Do not add or move stars."
|
|
938
|
+
: "Fine additive points at the listed pixels, brightness from magnitude; a faint scatter "
|
|
939
|
+
+ "may be added between them.",
|
|
940
|
+
},
|
|
941
|
+
{
|
|
942
|
+
kind: "milkyWay", present: milkyWay.visible && milkyWay.inFrame, count: milkyWay.inFrame ? 1 : 0,
|
|
943
|
+
composite: "A diffuse luminous band along the entry-to-exit path, brightest toward the "
|
|
944
|
+
+ "galactic center; soft and mottled, not resolved into stars.",
|
|
945
|
+
},
|
|
946
|
+
{
|
|
947
|
+
kind: "overlays", present: overlayCount > 0, count: overlayCount,
|
|
948
|
+
composite: "Vector annotations (lines and labels) drawn over the composite. Reference frames, "
|
|
949
|
+
+ "not photoreal; optional.",
|
|
950
|
+
},
|
|
951
|
+
];
|
|
952
|
+
return {
|
|
953
|
+
background: {
|
|
954
|
+
prompt: buildBackgroundPrompt(directives),
|
|
955
|
+
width,
|
|
956
|
+
height,
|
|
957
|
+
constraints: [
|
|
958
|
+
"No celestial bodies: no Sun, Moon, planets, stars, or Milky Way in the plate.",
|
|
959
|
+
sky.horizonY !== null ? `Horizon at y=${sky.horizonY}.` : "Horizon outside the frame.",
|
|
960
|
+
"Even, composite-ready sky; no baked-in glare or lens flare where bright bodies will sit.",
|
|
961
|
+
],
|
|
962
|
+
},
|
|
963
|
+
layers,
|
|
964
|
+
animation: {
|
|
965
|
+
strategy: "static",
|
|
966
|
+
rotationDegPerHour: Math.round(SIDEREAL_DEG_PER_MIN * 60 * 1e4) / 1e4,
|
|
967
|
+
pole,
|
|
968
|
+
notes: "For a sequence (skyViewSequence): generate one background plate (or a few for cloud "
|
|
969
|
+
+ "motion), then per frame rotate the star layer about the pole at the sidereal rate and "
|
|
970
|
+
+ "re-place the bodies, Moon, and Milky Way from each frame's spec. Use a video model only "
|
|
971
|
+
+ "for cloud and atmosphere motion, never for the bodies.",
|
|
972
|
+
},
|
|
973
|
+
postprocess: [
|
|
974
|
+
"Apply atmospheric extinction and reddening to bodies below about 10 deg altitude.",
|
|
975
|
+
"Add subtle bloom to bodies brighter than magnitude -1; keep faint stars crisp points.",
|
|
976
|
+
"Match every composited layer to the plate's color temperature and exposure.",
|
|
977
|
+
],
|
|
978
|
+
};
|
|
979
|
+
}
|