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.
@@ -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
+ }