entangled-worlds-orbiters-shared 2.10.0 → 2.12.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.
Files changed (51) hide show
  1. package/README.md +10 -0
  2. package/dist/notes/index.d.ts +72 -0
  3. package/dist/notes/index.js +94 -0
  4. package/dist/star/blackbody.d.ts +12 -0
  5. package/dist/star/blackbody.js +38 -0
  6. package/dist/star/bounds.d.ts +39 -0
  7. package/dist/star/bounds.js +47 -0
  8. package/dist/star/framing.d.ts +31 -0
  9. package/dist/star/framing.js +68 -0
  10. package/dist/star/index.d.ts +21 -0
  11. package/dist/star/index.js +15 -0
  12. package/dist/star/layout.d.ts +121 -0
  13. package/dist/star/layout.js +307 -0
  14. package/dist/star/lighting.d.ts +65 -0
  15. package/dist/star/lighting.js +115 -0
  16. package/dist/star/params.d.ts +79 -0
  17. package/dist/star/params.js +122 -0
  18. package/dist/star/quality.d.ts +16 -0
  19. package/dist/star/quality.js +16 -0
  20. package/dist/star/shaders.d.ts +20 -0
  21. package/dist/star/shaders.js +186 -0
  22. package/dist/star/starBody.d.ts +58 -0
  23. package/dist/star/starBody.js +308 -0
  24. package/dist/star/starManager.d.ts +73 -0
  25. package/dist/star/starManager.js +190 -0
  26. package/dist/star/stellarData.d.ts +82 -0
  27. package/dist/star/stellarData.js +347 -0
  28. package/package.json +17 -4
  29. package/src/core/index.js +1 -1
  30. package/src/core/lights.js +17 -1
  31. package/src/loaders/glbLoader.js +5 -2
  32. package/src/moons/index.js +11 -1
  33. package/src/moons/moonsGeneration.js +24 -8
  34. package/src/moons/moonsManager.js +18 -0
  35. package/src/notes/index.ts +146 -0
  36. package/src/star/blackbody.ts +46 -0
  37. package/src/star/bounds.ts +73 -0
  38. package/src/star/framing.ts +96 -0
  39. package/src/star/index.ts +59 -0
  40. package/src/star/layout.ts +400 -0
  41. package/src/star/lighting.ts +142 -0
  42. package/src/star/params.ts +161 -0
  43. package/src/star/quality.ts +33 -0
  44. package/src/star/shaders.ts +195 -0
  45. package/src/star/starBody.ts +385 -0
  46. package/src/star/starManager.ts +224 -0
  47. package/src/star/stellarData.ts +387 -0
  48. package/src/world/cloudShell.js +2 -3
  49. package/src/world/worldGLB.js +3 -2
  50. package/src/world/worldManager.js +1 -0
  51. package/src/world/worldTexture.js +4 -3
@@ -0,0 +1,307 @@
1
+ // Pure star-system layout: per-star colour / radius / glow and the orbits, derived deterministically
2
+ // from the params + seed (same string hash + LCG the moons use, so a seed means the same thing in
3
+ // both). No three — the renderer consumes these numbers.
4
+ import { blackbodyColor } from './blackbody.js';
5
+ import { normalizeStarParams } from './params.js';
6
+ /** How much of the glow (in star radii) the focus sphere keeps around each disc. */
7
+ export const FOCUS_GLOW_RADII = 1.5;
8
+ /** Focus radius of a layout (pure): max over stars of (distance from barycentre + glow radius). */
9
+ export function starFocusRadius(layout) {
10
+ let r = 0;
11
+ for (const s of layout.stars) {
12
+ const reach = s.orbit.radius + (s.role !== 'wide' && layout.pairOrbit ? layout.pairOrbit.radius : 0);
13
+ r = Math.max(r, reach + s.radius * FOCUS_GLOW_RADII);
14
+ }
15
+ return r;
16
+ }
17
+ /** 32-bit string hash (the same one `generateMoons` uses). */
18
+ export function hashSeed(seed) {
19
+ if (typeof seed === 'number')
20
+ return Math.abs(Math.trunc(seed)) >>> 0;
21
+ let h = 0;
22
+ for (let i = 0; i < seed.length; i++) {
23
+ h = ((h << 5) - h) + seed.charCodeAt(i);
24
+ h = h & h;
25
+ }
26
+ return Math.abs(h) >>> 0;
27
+ }
28
+ /** Linear congruential generator in [0, 1) (Numerical Recipes constants, as in the moons). */
29
+ export function createLcg(seed) {
30
+ let s = hashSeed(seed);
31
+ return () => {
32
+ s = (s * 1664525 + 1013904223) % 4294967296;
33
+ return s / 4294967296;
34
+ };
35
+ }
36
+ const TAU = Math.PI * 2;
37
+ /** Orbital period of a pair at separation 2 (in summed radii), seconds. Slow enough to feel celestial. */
38
+ const BASE_PAIR_PERIOD_SEC = 90;
39
+ function lerp(a, b, t) {
40
+ return a + (b - a) * t;
41
+ }
42
+ /** Linear limb-darkening coefficient vs temperature: ~0.85 for M dwarfs, ~0.6 Sun, ~0.3 O stars. */
43
+ export function limbDarkeningFor(temperatureK) {
44
+ const x = Math.min(1, Math.max(0, (Math.log(temperatureK) - Math.log(2400)) / (Math.log(40000) - Math.log(2400))));
45
+ return lerp(0.85, 0.3, x);
46
+ }
47
+ /** Corona half-size in radii for a luminosity 0..1. */
48
+ export function coronaExtentFor(luminosity) {
49
+ return 2.2 + 3.8 * Math.min(1, Math.max(0, luminosity));
50
+ }
51
+ function makeStar(role, temperatureK, radius, luminosity, activity, noiseSeed, orbit) {
52
+ return {
53
+ role,
54
+ temperatureK,
55
+ color: blackbodyColor(temperatureK),
56
+ radius,
57
+ luminosity,
58
+ activity,
59
+ coronaExtent: coronaExtentFor(luminosity),
60
+ limbDarkening: limbDarkeningFor(temperatureK),
61
+ noiseSeed,
62
+ gain: 1,
63
+ orbit,
64
+ };
65
+ }
66
+ function wrapDeg(deg) {
67
+ const w = ((((deg + 180) % 360) + 360) % 360) - 180;
68
+ return w === -180 ? 180 : w;
69
+ }
70
+ /**
71
+ * Debris-disc orientation (pure, from the seed): the ring's plane is near-horizontal, tilted
72
+ * `tilt` radians towards a viewer on +Z (so it reads as a thin ellipse, ~0.2–0.45 axis ratio) and
73
+ * rolled `roll` radians about the view axis. Independent of the orbital plane so a face-on-ish
74
+ * binary orbit never turns the disc into a flat halo.
75
+ */
76
+ export function discOrientation(seed) {
77
+ const r = createLcg(`${seed}|disc`);
78
+ return { tilt: lerp(0.2, 0.46, r()), roll: lerp(-0.45, 0.45, r()) };
79
+ }
80
+ /** Distant companion (independent seed stream so it never shifts the system's own layout). */
81
+ const DEG = Math.PI / 180;
82
+ /** Unit vector for an azimuth/elevation in degrees (same convention as `starSystemPosition`). */
83
+ function dirOf(azDeg, elDeg) {
84
+ const az = azDeg * DEG;
85
+ const el = elDeg * DEG;
86
+ return [Math.sin(az) * Math.cos(el), Math.sin(el), -Math.cos(az) * Math.cos(el)];
87
+ }
88
+ /**
89
+ * Keep the distant companion's direction at least `minAngle` (radians) away from the system's,
90
+ * rotating it away along the great circle (or straight up when they coincide). Pure.
91
+ */
92
+ export function separateDirection(sys, far, minAngle) {
93
+ const u = dirOf(sys.azimuth, sys.elevation);
94
+ const v = dirOf(far.azimuth, far.elevation);
95
+ const dot = Math.max(-1, Math.min(1, u[0] * v[0] + u[1] * v[1] + u[2] * v[2]));
96
+ if (Math.acos(dot) >= minAngle)
97
+ return far;
98
+ let w = [v[0] - u[0] * dot, v[1] - u[1] * dot, v[2] - u[2] * dot];
99
+ let wl = Math.hypot(w[0], w[1], w[2]);
100
+ if (wl < 1e-6) {
101
+ // Same direction: move it up (or down near the zenith).
102
+ const up = u[1] < 0.9 ? [0, 1, 0] : [0, -1, 0];
103
+ const k = up[0] * u[0] + up[1] * u[1] + up[2] * u[2];
104
+ w = [up[0] - u[0] * k, up[1] - u[1] * k, up[2] - u[2] * k];
105
+ wl = Math.hypot(w[0], w[1], w[2]);
106
+ }
107
+ const c = Math.cos(minAngle);
108
+ const s = Math.sin(minAngle);
109
+ const x = u[0] * c + (w[0] / wl) * s;
110
+ const y = u[1] * c + (w[1] / wl) * s;
111
+ const z = u[2] * c + (w[2] / wl) * s;
112
+ return { azimuth: Math.atan2(x, -z) / DEG, elevation: Math.asin(Math.max(-1, Math.min(1, y))) / DEG };
113
+ }
114
+ /** Farthest any system star's disc reaches from the barycentre (orbits + radius; no glow). */
115
+ export function starsReach(layout) {
116
+ let r = 0;
117
+ for (const s of layout.stars) {
118
+ const reach = s.orbit.radius + (s.role !== 'wide' && layout.pairOrbit ? layout.pairOrbit.radius : 0);
119
+ r = Math.max(r, reach + s.radius);
120
+ }
121
+ return r;
122
+ }
123
+ function distantFor(p, reach) {
124
+ if (!p.distantCompanion)
125
+ return null;
126
+ const r = createLcg(`${p.seed}|distant`);
127
+ // Default: up and back towards the frame's centre from the host — above the planet rather than
128
+ // behind it, and inside a typical frame when the host itself is placed beside the planet.
129
+ const inward = p.azimuth >= 0 ? -1 : 1;
130
+ let azimuth = p.distantAzimuth ?? wrapDeg(p.azimuth + inward * lerp(12, 22, r()));
131
+ let elevation = p.distantElevation ?? Math.min(60, p.elevation + lerp(6, 11, r()));
132
+ // Never inside the system: both sit at `distance`, so the chord between them must clear the
133
+ // system's reach + the companion's radius (with room to spare). Hosts may push the system
134
+ // farther (scene bounds), which only widens the gap.
135
+ const chord = 1.3 * (reach + p.distantRadius);
136
+ const minAngle = 2 * Math.asin(Math.min(1, chord / (2 * p.distance)));
137
+ ({ azimuth, elevation } = separateDirection({ azimuth: p.azimuth, elevation: p.elevation }, { azimuth, elevation }, minAngle));
138
+ azimuth = wrapDeg(azimuth);
139
+ const star = makeStar('distant', p.distantTemperatureK, p.distantRadius, p.distantBrightness * 0.8, p.activity * 0.8, r(), {
140
+ radius: 0,
141
+ phase: 0,
142
+ angularSpeed: 0,
143
+ });
144
+ star.gain = 0.7 + 0.3 * p.distantBrightness; // dimmer, but never grey
145
+ return { star, azimuth, elevation };
146
+ }
147
+ /**
148
+ * Lay out the system for (possibly raw) params. Companions are always cooler and smaller than the
149
+ * primary and derived from it + the seed; the trinary's third is a small, cool, wide companion
150
+ * (an Alpha Centauri-like hierarchy). Deterministic: same params → same layout.
151
+ */
152
+ export function starLayout(rawParams) {
153
+ const p = normalizeStarParams(rawParams);
154
+ const rand = createLcg(p.seed);
155
+ const inclination = lerp(1.0, 1.35, rand()); // tilted towards the viewer: reads as an ellipse, never edge-on
156
+ const node = rand() * TAU;
157
+ const primarySeed = rand();
158
+ const r1 = p.radius;
159
+ const t1 = p.temperatureK;
160
+ if (p.multiplicity === 1) {
161
+ const star = makeStar('primary', t1, r1, p.luminosity, p.activity, primarySeed, { radius: 0, phase: 0, angularSpeed: 0 });
162
+ return {
163
+ stars: [star],
164
+ inclination,
165
+ node,
166
+ pairOrbit: null,
167
+ distant: distantFor(p, r1),
168
+ boundingRadius: r1 * star.coronaExtent,
169
+ focusRadius: r1 * FOCUS_GLOW_RADII,
170
+ };
171
+ }
172
+ // Close companion: cooler and smaller than the primary — unless real values were given (e.g. from
173
+ // the companion's mass). The random draws always happen so the rest of the layout stays put.
174
+ const t2Seeded = Math.max(2000, t1 * lerp(0.55, 0.9, rand()));
175
+ const r2Seeded = r1 * lerp(0.45, 0.85, rand());
176
+ const t2 = p.companionTemperatureK > 0 ? p.companionTemperatureK : t2Seeded;
177
+ const r2 = p.companionRadius > 0 ? p.companionRadius : r2Seeded;
178
+ const lum2 = p.luminosity * Math.min(1, Math.max(0.25, Math.pow(t2 / t1, 2) * (r2 / r1)));
179
+ const act2 = Math.min(1, p.activity * lerp(0.9, 1.4, rand()));
180
+ const pairPhase = rand() * TAU;
181
+ const d = p.separation * (r1 + r2); // centre-to-centre
182
+ // Mass ratio ~ radius^1.5 (main-sequence-ish) sets how far each orbits from the pair barycentre.
183
+ const q = Math.pow(r2 / r1, 1.5); // m2 / m1
184
+ const pairOmega = TAU / (BASE_PAIR_PERIOD_SEC * Math.pow(p.separation / 2, 1.5));
185
+ const primary = makeStar('primary', t1, r1, p.luminosity, p.activity, primarySeed, {
186
+ radius: (d * q) / (1 + q),
187
+ phase: pairPhase,
188
+ angularSpeed: pairOmega,
189
+ });
190
+ const companion = makeStar('companion', t2, r2, lum2, act2, rand(), {
191
+ radius: d / (1 + q),
192
+ phase: pairPhase + Math.PI,
193
+ angularSpeed: pairOmega,
194
+ });
195
+ const stars = [primary, companion];
196
+ let pairOrbit = null;
197
+ let boundingRadius = Math.max(primary.orbit.radius + r1 * primary.coronaExtent, companion.orbit.radius + r2 * companion.coronaExtent);
198
+ if (p.multiplicity === 3) {
199
+ const t3 = Math.max(2000, t1 * lerp(0.4, 0.65, rand()));
200
+ const r3 = r1 * lerp(0.25, 0.45, rand());
201
+ const lum3 = p.luminosity * Math.min(1, Math.max(0.2, Math.pow(t3 / t1, 2) * (r3 / r1)));
202
+ const act3 = Math.min(1, Math.max(p.activity, lerp(0.5, 0.9, rand()))); // small cool stars flare
203
+ // Pair barycentre ↔ third. Never closer than the pair's own reach plus a clear gap, so the third
204
+ // can't graze either pair star whatever the radii (e.g. a tiny forced companion radius).
205
+ const a1 = (d * q) / (1 + q);
206
+ const a2 = d / (1 + q);
207
+ const minD = Math.max(a1 + 1.5 * (r1 + r3), a2 + 1.5 * (r2 + r3));
208
+ const D = Math.max(d * lerp(2.0, 2.8, rand()), minD);
209
+ const wideRatio = D / d;
210
+ const q3 = Math.pow(r3, 1.5) / (Math.pow(r1, 1.5) + Math.pow(r2, 1.5)); // m3 / (m1 + m2)
211
+ const wideOmega = pairOmega / Math.pow(wideRatio, 1.5);
212
+ const widePhase = rand() * TAU;
213
+ pairOrbit = { radius: (D * q3) / (1 + q3), phase: widePhase + Math.PI, angularSpeed: wideOmega };
214
+ const third = makeStar('wide', t3, r3, lum3, act3, rand(), { radius: D / (1 + q3), phase: widePhase, angularSpeed: wideOmega });
215
+ stars.push(third);
216
+ boundingRadius = Math.max(boundingRadius + pairOrbit.radius, third.orbit.radius + r3 * third.coronaExtent);
217
+ }
218
+ return { stars, inclination, node, pairOrbit, distant: distantFor(p, starsReach({ stars, pairOrbit })), boundingRadius, focusRadius: starFocusRadius({ stars, pairOrbit }) };
219
+ }
220
+ /**
221
+ * Local positions (relative to the system barycentre) of every star at `timeSec`, written as
222
+ * xyz triplets into `out` (allocated when omitted). Orbits are circular, in a plane tilted by
223
+ * `inclination` about X and rotated by `node` about Y. Allocation-free when `out` is passed.
224
+ */
225
+ export function starPositions(layout, timeSec, out) {
226
+ const n = layout.stars.length;
227
+ const res = out ?? new Float32Array(n * 3);
228
+ const ci = Math.cos(layout.inclination);
229
+ const si = Math.sin(layout.inclination);
230
+ const cn = Math.cos(layout.node);
231
+ const sn = Math.sin(layout.node);
232
+ const t = Number.isFinite(timeSec) ? timeSec : 0;
233
+ // Pair barycentre offset (trinary only), in the orbital plane.
234
+ let bx = 0;
235
+ let bz = 0;
236
+ if (layout.pairOrbit) {
237
+ const a = layout.pairOrbit.phase + layout.pairOrbit.angularSpeed * t;
238
+ bx = Math.cos(a) * layout.pairOrbit.radius;
239
+ bz = Math.sin(a) * layout.pairOrbit.radius;
240
+ }
241
+ for (let i = 0; i < n; i++) {
242
+ const s = layout.stars[i];
243
+ const a = s.orbit.phase + s.orbit.angularSpeed * t;
244
+ let x = Math.cos(a) * s.orbit.radius;
245
+ let z = Math.sin(a) * s.orbit.radius;
246
+ if (s.role !== 'wide') {
247
+ x += bx;
248
+ z += bz;
249
+ }
250
+ // Tilt about X (plane gains height), then rotate about Y.
251
+ const y = z * si;
252
+ const zt = z * ci;
253
+ res[i * 3] = x * cn + zt * sn;
254
+ res[i * 3 + 1] = y;
255
+ res[i * 3 + 2] = -x * sn + zt * cn;
256
+ }
257
+ return res;
258
+ }
259
+ /** World-space position of the system barycentre for a distance/azimuth/elevation placement. */
260
+ export function starSystemPosition(p) {
261
+ const az = (p.azimuth * Math.PI) / 180;
262
+ const el = (p.elevation * Math.PI) / 180;
263
+ const h = Math.cos(el) * p.distance;
264
+ return [Math.sin(az) * h, Math.sin(el) * p.distance, -Math.cos(az) * h];
265
+ }
266
+ /** Fractional part of a hash — a stateless [0,1) random for (seed, slot) pairs. */
267
+ function hash01(a, b) {
268
+ let h = (Math.imul(a | 0, 0x27d4eb2d) ^ Math.imul(b | 0, 0x165667b1)) >>> 0;
269
+ h = Math.imul(h ^ (h >>> 15), 0x85ebca6b) >>> 0;
270
+ h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35) >>> 0;
271
+ return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
272
+ }
273
+ /** Length of one flare slot; at most one flare starts per slot. */
274
+ export const FLARE_SLOT_SEC = 6;
275
+ const FLARE_RISE_SEC = 0.25;
276
+ const FLARE_DECAY_SEC = 1.6;
277
+ /**
278
+ * Deterministic flare envelope for a star at `timeSec`: fast rise, exponential decay. The chance a
279
+ * slot holds a flare grows with `activity` (0 → never). Same (seed, time) → same answer on every
280
+ * machine, so all views of one world flare together.
281
+ */
282
+ export function flareAt(noiseSeed, activity, timeSec, out) {
283
+ const res = out ?? { intensity: 0, angle: 0 };
284
+ res.intensity = 0;
285
+ res.angle = 0;
286
+ const act = Math.min(1, Math.max(0, activity));
287
+ if (act <= 0 || !Number.isFinite(timeSec))
288
+ return res;
289
+ const seedInt = Math.floor(noiseSeed * 1e6);
290
+ const slot = Math.floor(timeSec / FLARE_SLOT_SEC);
291
+ const chance = 0.08 + 0.5 * act * act;
292
+ for (let k = slot - 1; k <= slot; k++) {
293
+ if (hash01(seedInt, k) >= chance)
294
+ continue;
295
+ const start = (k + hash01(seedInt + 7, k) * 0.7) * FLARE_SLOT_SEC;
296
+ const dt = timeSec - start;
297
+ if (dt < 0)
298
+ continue;
299
+ const env = dt < FLARE_RISE_SEC ? dt / FLARE_RISE_SEC : Math.exp(-(dt - FLARE_RISE_SEC) / FLARE_DECAY_SEC);
300
+ const amp = (0.4 + 0.6 * hash01(seedInt + 13, k)) * act * env;
301
+ if (amp > res.intensity) {
302
+ res.intensity = amp;
303
+ res.angle = hash01(seedInt + 29, k) * TAU;
304
+ }
305
+ }
306
+ return res;
307
+ }
@@ -0,0 +1,65 @@
1
+ import type { RGB } from './blackbody.js';
2
+ import type { StarLayout } from './layout.js';
3
+ /** Author lighting controls. `tint` multiplies the key colour; null (or white) = none. */
4
+ export interface StarLighting {
5
+ /** Key light multiplier (1 = the star's natural brightness). */
6
+ intensity: number;
7
+ /** Camera-side fill light, as a multiplier (0 = none). */
8
+ fill: number;
9
+ /** Colour multiplied into the key light: '#rrggbb', 0xrrggbb, {r,g,b} 0..1, or null. */
10
+ tint: string | number | RGB | null;
11
+ }
12
+ export declare const DEFAULT_STAR_LIGHTING: Readonly<StarLighting>;
13
+ /**
14
+ * Ambient intensity to pair with the star's key + fill (instead of the old white hemisphere +
15
+ * ambient at 1). Output is sRGB, which lifts shadows a lot (4% linear ≈ 22% on screen), so night
16
+ * sides need very little: with the default fill this keeps a hint of the night side (~5% linear)
17
+ * while the terminator reads clearly.
18
+ */
19
+ export declare const DEFAULT_AMBIENT = 0.06;
20
+ /**
21
+ * Key light colour: the stars' blackbody colours weighted by their emitted power (R² · T⁴), so a
22
+ * dim companion barely shifts the Sun but a close pair of equals blends. The distant companion is
23
+ * not part of `layout.stars` and never contributes. Brightest channel = 1. Writes into `out`.
24
+ */
25
+ export declare function systemLightColor(layout: Pick<StarLayout, 'stars'>, out?: RGB): RGB;
26
+ /**
27
+ * Key light intensity for a luminosity 0..1 (three r155+ physical units, no tone mapping): the Sun
28
+ * at 0.5 gives ≈ π, i.e. a white MeshStandard surface facing the star renders at its albedo.
29
+ */
30
+ export declare function keyLightIntensity(luminosity: number, multiplier?: number): number;
31
+ /** Fill (camera-side hemisphere) intensity for the author `fill` multiplier. */
32
+ export declare function fillLightIntensity(fill: number): number;
33
+ /** Parse a tint to RGB 0..1, or null for none / white / unparseable. */
34
+ export declare function parseTint(tint: StarLighting['tint'] | undefined): RGB | null;
35
+ /** Validate a (partial) lighting patch against a base. Never throws. */
36
+ export declare function normalizeStarLighting(raw: unknown, base?: Readonly<StarLighting>): StarLighting;
37
+ /** Old per-world lighting as saved by hosts before the star lit the scene. */
38
+ export interface LegacyLighting {
39
+ ambientIntensity?: number | null;
40
+ diffuseIntensity?: number | null;
41
+ lightColor?: string | number | null;
42
+ /** Ignored: the light now comes from the star. */
43
+ lightDirection?: unknown;
44
+ }
45
+ /** The ambient the old flat-lit look was tuned around (the host's former default). */
46
+ export declare const LEGACY_AMBIENT_REFERENCE = 0.6;
47
+ /** Upper bound for a mapped legacy ambient (keeps night sides from washing out). */
48
+ export declare const LEGACY_AMBIENT_MAX: number;
49
+ export interface LegacyLightingResult extends Partial<StarLighting> {
50
+ /** Ambient intensity to use with the star lights (always set). */
51
+ ambient: number;
52
+ }
53
+ /**
54
+ * Map OLD saved lighting onto the star's — the single place this rule lives, so every app reads a
55
+ * legacy world the same way. Only the legacy keys go through here (`ambientIntensity`,
56
+ * `diffuseIntensity`, `lightColor`, `lightDirection`); saves made with star lighting use the new
57
+ * keys (`ambient`, `intensity`, `fill`, `tint`) and are read raw.
58
+ *
59
+ * - `diffuseIntensity` → `intensity` (key multiplier).
60
+ * - `lightColor` → `tint` (white → none).
61
+ * - `ambientIntensity` → `ambient = DEFAULT_AMBIENT × saved / 0.6` (0.6 was the old default), clamped
62
+ * to [0, 3 × DEFAULT_AMBIENT]; missing → `DEFAULT_AMBIENT`.
63
+ * - `lightDirection` is dropped: the star's position decides.
64
+ */
65
+ export declare function lightingFromLegacy(lighting: LegacyLighting | null | undefined): LegacyLightingResult;
@@ -0,0 +1,115 @@
1
+ // Star-as-scene-light: pure helpers (colour, intensities, legacy mapping). The lights themselves are
2
+ // owned by StarManager; these stay pure so hosts and tests can use them without WebGL.
3
+ export const DEFAULT_STAR_LIGHTING = Object.freeze({ intensity: 1, fill: 1, tint: null });
4
+ /**
5
+ * Ambient intensity to pair with the star's key + fill (instead of the old white hemisphere +
6
+ * ambient at 1). Output is sRGB, which lifts shadows a lot (4% linear ≈ 22% on screen), so night
7
+ * sides need very little: with the default fill this keeps a hint of the night side (~5% linear)
8
+ * while the terminator reads clearly.
9
+ */
10
+ export const DEFAULT_AMBIENT = 0.06;
11
+ const SUN_TEFF_K = 5778;
12
+ /**
13
+ * Key light colour: the stars' blackbody colours weighted by their emitted power (R² · T⁴), so a
14
+ * dim companion barely shifts the Sun but a close pair of equals blends. The distant companion is
15
+ * not part of `layout.stars` and never contributes. Brightest channel = 1. Writes into `out`.
16
+ */
17
+ export function systemLightColor(layout, out = { r: 1, g: 1, b: 1 }) {
18
+ let r = 0;
19
+ let g = 0;
20
+ let b = 0;
21
+ for (const s of layout.stars) {
22
+ const w = s.radius * s.radius * Math.pow(s.temperatureK / SUN_TEFF_K, 4);
23
+ r += s.color.r * w;
24
+ g += s.color.g * w;
25
+ b += s.color.b * w;
26
+ }
27
+ const m = Math.max(r, g, b);
28
+ if (!(m > 0)) {
29
+ out.r = out.g = out.b = 1;
30
+ return out;
31
+ }
32
+ out.r = r / m;
33
+ out.g = g / m;
34
+ out.b = b / m;
35
+ return out;
36
+ }
37
+ /**
38
+ * Key light intensity for a luminosity 0..1 (three r155+ physical units, no tone mapping): the Sun
39
+ * at 0.5 gives ≈ π, i.e. a white MeshStandard surface facing the star renders at its albedo.
40
+ */
41
+ export function keyLightIntensity(luminosity, multiplier = 1) {
42
+ const l = Number.isFinite(luminosity) ? Math.min(1, Math.max(0, luminosity)) : 0.5;
43
+ const k = Number.isFinite(multiplier) ? Math.max(0, multiplier) : 1;
44
+ return k * Math.PI * (0.35 + 1.3 * l);
45
+ }
46
+ /** Fill (camera-side hemisphere) intensity for the author `fill` multiplier. */
47
+ export function fillLightIntensity(fill) {
48
+ return (Number.isFinite(fill) ? Math.max(0, fill) : DEFAULT_STAR_LIGHTING.fill) * 0.3;
49
+ }
50
+ /** Parse a tint to RGB 0..1, or null for none / white / unparseable. */
51
+ export function parseTint(tint) {
52
+ if (tint == null)
53
+ return null;
54
+ let rgb = null;
55
+ if (typeof tint === 'number' && Number.isFinite(tint)) {
56
+ rgb = { r: ((tint >> 16) & 255) / 255, g: ((tint >> 8) & 255) / 255, b: (tint & 255) / 255 };
57
+ }
58
+ else if (typeof tint === 'string') {
59
+ const m = /^#?([0-9a-f]{6}|[0-9a-f]{3})$/i.exec(tint.trim());
60
+ if (m) {
61
+ const hex = m[1].length === 3 ? m[1].replace(/./g, (c) => c + c) : m[1];
62
+ const n = parseInt(hex, 16);
63
+ rgb = { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255 };
64
+ }
65
+ }
66
+ else if (typeof tint === 'object' && [tint.r, tint.g, tint.b].every((v) => typeof v === 'number' && Number.isFinite(v))) {
67
+ rgb = { r: Math.min(1, Math.max(0, tint.r)), g: Math.min(1, Math.max(0, tint.g)), b: Math.min(1, Math.max(0, tint.b)) };
68
+ }
69
+ if (!rgb)
70
+ return null;
71
+ return rgb.r >= 0.999 && rgb.g >= 0.999 && rgb.b >= 0.999 ? null : rgb;
72
+ }
73
+ /** Validate a (partial) lighting patch against a base. Never throws. */
74
+ export function normalizeStarLighting(raw, base = DEFAULT_STAR_LIGHTING) {
75
+ const src = (raw && typeof raw === 'object' ? raw : {});
76
+ const out = { ...base };
77
+ if (typeof src.intensity === 'number' && Number.isFinite(src.intensity))
78
+ out.intensity = Math.min(10, Math.max(0, src.intensity));
79
+ if (typeof src.fill === 'number' && Number.isFinite(src.fill))
80
+ out.fill = Math.min(5, Math.max(0, src.fill));
81
+ if ('tint' in src)
82
+ out.tint = parseTint(src.tint);
83
+ return out;
84
+ }
85
+ /** The ambient the old flat-lit look was tuned around (the host's former default). */
86
+ export const LEGACY_AMBIENT_REFERENCE = 0.6;
87
+ /** Upper bound for a mapped legacy ambient (keeps night sides from washing out). */
88
+ export const LEGACY_AMBIENT_MAX = 3 * DEFAULT_AMBIENT;
89
+ /**
90
+ * Map OLD saved lighting onto the star's — the single place this rule lives, so every app reads a
91
+ * legacy world the same way. Only the legacy keys go through here (`ambientIntensity`,
92
+ * `diffuseIntensity`, `lightColor`, `lightDirection`); saves made with star lighting use the new
93
+ * keys (`ambient`, `intensity`, `fill`, `tint`) and are read raw.
94
+ *
95
+ * - `diffuseIntensity` → `intensity` (key multiplier).
96
+ * - `lightColor` → `tint` (white → none).
97
+ * - `ambientIntensity` → `ambient = DEFAULT_AMBIENT × saved / 0.6` (0.6 was the old default), clamped
98
+ * to [0, 3 × DEFAULT_AMBIENT]; missing → `DEFAULT_AMBIENT`.
99
+ * - `lightDirection` is dropped: the star's position decides.
100
+ */
101
+ export function lightingFromLegacy(lighting) {
102
+ const out = { ambient: DEFAULT_AMBIENT };
103
+ if (!lighting || typeof lighting !== 'object')
104
+ return out;
105
+ const a = lighting.ambientIntensity;
106
+ if (typeof a === 'number' && Number.isFinite(a)) {
107
+ out.ambient = Math.min(LEGACY_AMBIENT_MAX, Math.max(0, (DEFAULT_AMBIENT * a) / LEGACY_AMBIENT_REFERENCE));
108
+ }
109
+ const d = lighting.diffuseIntensity;
110
+ if (typeof d === 'number' && Number.isFinite(d))
111
+ out.intensity = Math.min(10, Math.max(0, d));
112
+ if (lighting.lightColor != null)
113
+ out.tint = parseTint(lighting.lightColor);
114
+ return out;
115
+ }
@@ -0,0 +1,79 @@
1
+ /** How many stars the system shows. The audio Star is always one; companions are appearance only. */
2
+ export type StarMultiplicity = 1 | 2 | 3;
3
+ export interface StarParams {
4
+ /** Effective (surface) temperature in kelvin, ≈2400 (cool M dwarf) … 40000 (O star). Drives colour. */
5
+ temperatureK: number;
6
+ /** Radius of the primary in scene units (the same units as the host's planet and camera). */
7
+ radius: number;
8
+ /** 0..1 — corona intensity and extent. */
9
+ luminosity: number;
10
+ /** 0..1 — surface granulation, flicker and how often flares happen. */
11
+ activity: number;
12
+ /** 1 single, 2 binary (the planet is circumbinary), 3 trinary (a close pair plus a wide third). */
13
+ multiplicity: StarMultiplicity;
14
+ /** Centre distance of the close pair, in units of the two radii summed (≥1.2 so discs never merge). */
15
+ separation: number;
16
+ /** Deterministic seed for companion properties and orbital phases. */
17
+ seed: number | string;
18
+ /** Distance of the star system from the scene origin (the planet), scene units. */
19
+ distance: number;
20
+ /** Degrees around the vertical axis; 0 = straight behind the planet (−Z), positive = to the right (+X). */
21
+ azimuth: number;
22
+ /** Degrees above the horizon plane. */
23
+ elevation: number;
24
+ /** Close companion (binary/trinary pair) temperature, K. 0 = derived from the primary + seed. */
25
+ companionTemperatureK: number;
26
+ /** Close companion radius, scene units. 0 = derived from the primary + seed. */
27
+ companionRadius: number;
28
+ /** Show a distant companion: a small, dimmer star elsewhere in the sky (the planet orbits one star of a wide pair). */
29
+ distantCompanion: boolean;
30
+ /** Distant companion temperature, K. */
31
+ distantTemperatureK: number;
32
+ /** Distant companion radius, scene units (it sits at `distance`, like the host). */
33
+ distantRadius: number;
34
+ /** 0..1 — distant companion brightness (disc gain + corona). */
35
+ distantBrightness: number;
36
+ /** Distant companion direction, degrees (same convention as `azimuth`). null = from the seed. */
37
+ distantAzimuth: number | null;
38
+ /** Distant companion elevation, degrees. null = from the seed. */
39
+ distantElevation: number | null;
40
+ /** 0..1 — faint debris disc around the system (0 = none; no draw call). */
41
+ disc: number;
42
+ }
43
+ /** A Sun-like G star sitting behind and to the upper right of the planet. */
44
+ export declare const DEFAULT_STAR_PARAMS: Readonly<StarParams>;
45
+ /**
46
+ * Accepted ranges; values outside are clamped (azimuth wraps). Sized so the star reads as a
47
+ * background host star: at the minimum (and default) distance the largest radius spans ~10° from the
48
+ * planet, 220 stays inside a 500-unit skybox, and no distance puts it among the planet's moons (hosts pass their scene size to StarManager for the
49
+ * exact, non-colliding minimum — see `minStarDistance`).
50
+ */
51
+ export declare const STAR_PARAM_RANGES: Readonly<{
52
+ readonly temperatureK: readonly [2000, 50000];
53
+ readonly radius: readonly [0.05, 3.5];
54
+ readonly luminosity: readonly [0, 1];
55
+ readonly activity: readonly [0, 1];
56
+ readonly separation: readonly [1.2, 6];
57
+ readonly distance: readonly [20, 220];
58
+ readonly elevation: readonly [-89, 89];
59
+ /** 0 = auto; any other value is at least 2000. */
60
+ readonly companionTemperatureK: readonly [0, 50000];
61
+ /** 0 = auto; any other value is at least 0.05. */
62
+ readonly companionRadius: readonly [0, 3.5];
63
+ readonly distantTemperatureK: readonly [2000, 50000];
64
+ readonly distantRadius: readonly [0.02, 1.4];
65
+ readonly distantBrightness: readonly [0, 1];
66
+ readonly distantElevation: readonly [-89, 89];
67
+ readonly disc: readonly [0, 1];
68
+ }>;
69
+ /** Wrap degrees into (−180, 180]. */
70
+ export declare function wrapDegrees(deg: number): number;
71
+ /**
72
+ * Validate + complete raw params (e.g. from a stored world description). Missing, non-finite or
73
+ * wrongly-typed fields fall back to `base` (default: `DEFAULT_STAR_PARAMS`); numbers are clamped
74
+ * to `STAR_PARAM_RANGES`; unknown fields are ignored. Never throws.
75
+ */
76
+ export declare function normalizeStarParams(raw: unknown, base?: Readonly<StarParams>): StarParams;
77
+ export type SpectralClass = 'O' | 'B' | 'A' | 'F' | 'G' | 'K' | 'M';
78
+ /** Harvard spectral class for an effective temperature (main-sequence boundaries). */
79
+ export declare function spectralClass(temperatureK: number): SpectralClass;