Skip to main content

bevy_sun_move/
lib.rs

1pub mod random_stars;
2
3use bevy::prelude::*;
4use std::f32::consts::PI;
5
6// Helper constants
7pub const DEGREES_TO_RADIANS: f32 = PI / 180.0;
8pub const RADIANS_TO_DEGREES: f32 = 180.0 / PI;
9
10pub struct SunMovePlugin;
11
12impl Plugin for SunMovePlugin {
13    fn build(&self, app: &mut App) {
14        app.add_systems(Update, update_sky_center::<Time>);
15    }
16}
17
18pub trait ISunTime {
19    fn delta_secs(&self) -> f32;
20    fn elapsed_secs(&self) -> f32;
21}
22
23impl<T: Default + Send + Sync + 'static> ISunTime for Time<T> {
24    fn delta_secs(&self) -> f32 {
25        self.delta_secs()
26    }
27
28    fn elapsed_secs(&self) -> f32 {
29        self.elapsed_secs()
30    }
31}
32
33pub struct TypedSunMovePlugin<T: ISunTime + Resource> {
34    _marker: std::marker::PhantomData<T>,
35}
36
37impl<T: ISunTime + Resource> Default for TypedSunMovePlugin<T> {
38    fn default() -> Self {
39        Self {
40            _marker: std::marker::PhantomData,
41        }
42    }
43}
44
45impl<T: ISunTime + Resource> Plugin for TypedSunMovePlugin<T> {
46    fn build(&self, app: &mut App) {
47        app.add_systems(Update, update_sky_center::<T>);
48    }
49}
50
51// Determine latitude and year fraction from day and night fractions of full cycle
52#[derive(Component, Debug, Clone)]
53pub struct TimedSkyConfig {
54    pub planet_tilt_degrees: f32,
55    /// Desired duration of daylight in seconds.
56    pub day_duration_secs: f32,
57    /// Desired duration of nighttime in seconds.
58    pub night_duration_secs: f32,
59    /// Desired maximum sun height (altitude) in degrees during the day.
60    pub max_sun_height_deg: f32,
61    /// The entity representing the sun (usually a DirectionalLight).
62    pub sun_entity: Entity,
63}
64
65impl Default for TimedSkyConfig {
66    fn default() -> Self {
67        Self {
68            planet_tilt_degrees: 23.5, // Earth's tilt
69            sun_entity: Entity::PLACEHOLDER,
70            day_duration_secs: 15.0,   // Example: 15s day
71            night_duration_secs: 15.0, // Example: 15s night (total cycle 30s)
72            max_sun_height_deg: 45.0,
73        }
74    }
75}
76
77/// Calculates required latitude and year fraction to achieve a specific day/night
78/// duration ratio and maximum sun height (noon altitude) for a given planet tilt.
79///
80/// Based on standard astronomical formulas relating day length, noon altitude,
81/// latitude, and declination.
82///
83/// Args:
84/// - planet_tilt_degrees: The axial tilt of the planet in degrees.
85/// - day_duration_secs: The target duration of daylight in seconds.
86/// - night_duration_secs: The target duration of nighttime in seconds.
87/// - max_sun_height_deg: The target maximum altitude of the sun in degrees.
88///
89/// Returns:
90/// An `Option<(latitude_degrees, year_fraction, calculated_declination_degrees)>`.
91/// Returns `None` if the requested parameters are impossible for the given tilt
92/// (e.g., max height too high/low for the day length, or required declination
93/// exceeds the planet tilt).
94#[allow(non_snake_case)]
95pub fn calculate_latitude_yearfraction(
96    planet_tilt_degrees: f32,
97    day_duration_secs: f32,
98    night_duration_secs: f32,
99    max_sun_height_deg: f32,
100) -> Option<(f32, f32, f32)> {
101    let total_duration_secs = day_duration_secs + night_duration_secs;
102    let tilt_rad = planet_tilt_degrees.abs() * DEGREES_TO_RADIANS;
103
104    if total_duration_secs <= f32::EPSILON || day_duration_secs < 0.0 || night_duration_secs < 0.0 {
105        warn!(
106            "Invalid timed durations: day={}s, night={}s. Cannot calculate.",
107            day_duration_secs, night_duration_secs
108        );
109        return None;
110    }
111
112    if max_sun_height_deg < -0.1 || max_sun_height_deg > 90.0 + 0.1 {
113        // Allow slight floating point deviations
114        warn!(
115            "Max sun height {:.2}° is outside valid range [0°, 90°]. Cannot calculate.",
116            max_sun_height_deg
117        );
118        return None;
119    }
120
121    // Handle edge cases: Perpetual Day/Night or 12/12 cycle
122    if day_duration_secs < f32::EPSILON && night_duration_secs > f32::EPSILON {
123        // Perpetual Night (day_fraction = 0)
124        // Requires sun never rises, i.e. max altitude <= 0.
125        if max_sun_height_deg > f32::EPSILON {
126            warn!(
127                "Perpetual night requested but max sun height is {:.2}°. Impossible.",
128                max_sun_height_deg
129            );
130            return None;
131        }
132        // Max height is 0. This happens at latitudes where sun circles the horizon.
133        // This occurs at latitude = 90 - |dec|. For perpetual night at a pole-like lat,
134        // we need dec to be -tilt (NH winter) or +tilt (SH winter).
135        // Latitude is 90 - tilt. Year fraction is 0.75 (NH) or 0.25 (SH).
136        if tilt_rad < f32::EPSILON {
137            warn!("Perpetual night with 0 tilt is impossible unless at equator (12/12 cycle).");
138            return None; // 0 tilt implies 12/12 cycle everywhere.
139        }
140        let calculated_latitude_degrees =
141            (90.0 - planet_tilt_degrees.abs()).copysign(-planet_tilt_degrees); // Choose pole opposite tilt
142        let calculated_declination_degrees = -planet_tilt_degrees.copysign(planet_tilt_degrees); // Winter solstice dec
143        let calculated_year_fraction = if planet_tilt_degrees > 0.0 {
144            0.75
145        } else {
146            0.25
147        }; // NH Winter or SH Winter
148        // info!("Perpetual night calculation: Lat {:.2}°, Dec {:.2}°, YF {:.2}", calculated_latitude_degrees, calculated_declination_degrees, calculated_year_fraction);
149        return Some((
150            calculated_latitude_degrees,
151            calculated_year_fraction,
152            calculated_declination_degrees,
153        ));
154    }
155
156    if night_duration_secs < f32::EPSILON && day_duration_secs > f32::EPSILON {
157        // Perpetual Day (day_fraction = 1)
158        // Requires sun never sets, i.e. min altitude >= 0.
159        // Max height must be > 0 (unless at pole/equinox/tilt=0 which implies 12/12 max height 0).
160        if max_sun_height_deg < f32::EPSILON {
161            warn!(
162                "Perpetual day requested but max sun height is {:.2}°. Impossible (must be > 0 unless 12/12).",
163                max_sun_height_deg
164            );
165            return None; // Perpetual day usually has max height > 0. Max height 0 is the 12/12 case.
166        }
167        // Max height > 0. Perpetual day happens at latitudes polewards of 90 - tilt during summer solstice.
168        // Max height = 90 - |lat - dec|. Min height = 90 - |lat + dec|.
169        // At lat = 90 - tilt, summer solstice (dec=tilt), max height = 90 - (90-tilt - tilt) = 2*tilt. Min height = 90 - (90-tilt + tilt) = 0.
170        // For max height H > 0 and perpetual day, required dec = H/2, required lat = 90 - H/2.
171        if tilt_rad < f32::EPSILON {
172            warn!("Perpetual day with 0 tilt is impossible unless at equator (12/12 cycle).");
173            return None; // 0 tilt implies 12/12 cycle everywhere.
174        }
175        let max_height_rad = max_sun_height_deg * DEGREES_TO_RADIANS;
176        let required_dec_rad = max_height_rad / 2.0;
177        if required_dec_rad.abs() > tilt_rad + f32::EPSILON {
178            warn!(
179                "Required declination {:.2}° for perpetual day with max height {:.2}° exceeds planet tilt {:.2}°. Impossible.",
180                required_dec_rad * RADIANS_TO_DEGREES,
181                max_sun_height_deg,
182                planet_tilt_degrees
183            );
184            return None;
185        }
186        let calculated_latitude_degrees =
187            (90.0 * DEGREES_TO_RADIANS - required_dec_rad) * RADIANS_TO_DEGREES;
188        let calculated_declination_degrees = required_dec_rad * RADIANS_TO_DEGREES;
189        // Summer solstice requires dec > 0 if lat > 0, or dec < 0 if lat < 0.
190        // We aim for positive latitude hemisphere:
191        let final_lat_deg = calculated_latitude_degrees.copysign(planet_tilt_degrees); // Use tilt sign to pick hemisphere
192        let final_dec_deg = calculated_declination_degrees.copysign(planet_tilt_degrees); // Dec must match hemi for summer
193        let sin_yf_angle = final_dec_deg * DEGREES_TO_RADIANS / tilt_rad;
194        let phi = sin_yf_angle.clamp(-1.0, 1.0).asin();
195        let calculated_year_fraction = if final_dec_deg >= 0.0 {
196            phi / (2.0 * PI)
197        } else {
198            0.5 - phi / (2.0 * PI)
199        };
200
201        // info!("Perpetual day calculation: Lat {:.2}°, Dec {:.2}°, YF {:.2}", final_lat_deg, final_dec_deg, calculated_year_fraction);
202        return Some((final_lat_deg, calculated_year_fraction, final_dec_deg));
203    }
204
205    if total_duration_secs <= f32::EPSILON {
206        warn!("Total duration is zero.");
207        return None;
208    }
209
210    let day_fraction = day_duration_secs / total_duration_secs;
211    let max_height_rad = max_sun_height_deg * DEGREES_TO_RADIANS;
212
213    let C = (PI * day_fraction).cos();
214    let S_h = max_height_rad.sin();
215
216    // Derived relations:
217    // cos(lat_rad - dec_rad) = sin(max_height_rad)
218    // cos(lat_rad + dec_rad) = sin(max_height_rad) * (1 + cos(PI * day_fraction)) / (1 - cos(PI * day_fraction))
219
220    let term_for_cos_sum = if (1.0 - C).abs() < f32::EPSILON {
221        // Handle day_fraction near 0 (C near 1)
222        if S_h > f32::EPSILON {
223            // Max height > 0 with day fraction near 0 (perpetual night)
224            warn!(
225                "Impossible combination: Max height {:.2}° requires sun rise, but day fraction {:.2} requests near perpetual night.",
226                max_sun_height_deg, day_fraction
227            );
228            return None;
229        } else {
230            // Max height near 0 with day fraction near 0 (perpetual night on horizon)
231            // This case should be handled by the perpetual night block above.
232            // If we reach here, something is slightly off. Return None or default.
233            warn!("Reached indeterminate case for cos(lat+dec) near day_fraction 0.");
234            return None;
235        }
236    } else {
237        S_h * (1.0 + C) / (1.0 - C)
238    };
239
240    if term_for_cos_sum.abs() > 1.0 + f32::EPSILON {
241        warn!(
242            "Impossible combination: Max height {:.2}° and day fraction {:.2} requires cos(lat+dec) value {:.2} outside [-1, 1].",
243            max_sun_height_deg, day_fraction, term_for_cos_sum
244        );
245        return None;
246    }
247
248    let beta = term_for_cos_sum.clamp(-1.0, 1.0).acos(); // angle for lat + dec
249    let alpha = PI / 2.0 - max_height_rad; // angle for |lat - dec| (zenith distance at noon)
250
251    // Note: cos(lat-dec) = sin(h) implies |lat-dec| = PI/2 - h for h in [0, PI/2]
252    // The sign of (lat-dec) determines if sun culminates South (+ve) or North (-ve) of zenith.
253    // cos(lat+dec) = term_for_cos_sum
254    // The sign of (lat+dec) determines the average position relative to equator/solstices.
255
256    // We need to solve the system:
257    // lat - dec = +/- alpha
258    // lat + dec = +/- beta
259
260    // Let's find candidate lat/dec pairs. There are 4 mathematical pairs, but only 1 or 2
261    // will have |dec| <= |tilt| and |lat| <= PI/2.
262    // Pairs (lat, dec) in radians:
263    let candidates = [
264        ((alpha + beta) / 2.0, (beta - alpha) / 2.0), // lat-dec = +alpha, lat+dec = +beta
265        ((alpha - beta) / 2.0, (-beta - alpha) / 2.0), // lat-dec = +alpha, lat+dec = -beta
266        ((-alpha + beta) / 2.0, (beta + alpha) / 2.0), // lat-dec = -alpha, lat+dec = +beta
267        ((-alpha - beta) / 2.0, (-beta + alpha) / 2.0), // lat-dec = -alpha, lat+dec = -beta
268    ];
269
270    let mut found_lat_rad = None;
271    let mut found_dec_rad = None;
272
273    for (lat_candidate, dec_candidate) in candidates.iter() {
274        let lat_deg = lat_candidate * RADIANS_TO_DEGREES;
275        let dec_deg = dec_candidate * RADIANS_TO_DEGREES;
276
277        // Check if dec is achievable with the planet tilt
278        if dec_deg.abs() <= planet_tilt_degrees.abs() + f32::EPSILON {
279            // Check if latitude is valid
280            if lat_deg.abs() <= 90.0 + f32::EPSILON {
281                // Found a valid pair. Check if it matches our preferred sign combo.
282                let current_lat_sign = lat_deg.signum();
283                let current_dec_sign = dec_deg.signum();
284
285                let signs_match_preference = (day_fraction > 0.5 && current_lat_sign * current_dec_sign >= 0.0) || // Long day: lat and dec same sign
286                    (day_fraction < 0.5 && current_lat_sign * current_dec_sign <= 0.0); // Short day: lat and dec opposite sign
287
288                // If it matches preference, pick it immediately and break.
289                // If not, keep searching in case there's another valid one that does.
290                // If multiple match preference, the first found in the list order is used.
291                if signs_match_preference {
292                    found_lat_rad = Some(*lat_candidate);
293                    found_dec_rad = Some(*dec_candidate);
294                    break; // Found preferred solution
295                }
296
297                // If no preferred solution found yet, store *any* valid solution
298                // (the last one found in the loop order will be kept if no preferred is found)
299                if found_lat_rad.is_none() {
300                    found_lat_rad = Some(*lat_candidate);
301                    found_dec_rad = Some(*dec_candidate);
302                }
303            }
304        }
305    }
306
307    match (found_lat_rad, found_dec_rad) {
308        (Some(lat_rad), Some(dec_rad)) => {
309            let calculated_latitude_degrees = lat_rad * RADIANS_TO_DEGREES;
310            let calculated_declination_degrees = dec_rad * RADIANS_TO_DEGREES;
311
312            // Now find the year fraction corresponding to this declination and tilt
313            if tilt_rad < f32::EPSILON {
314                // Handle 0 tilt separately
315                if dec_rad.abs() > f32::EPSILON {
316                    warn!(
317                        "Calculated non-zero declination {:.2}° but tilt is 0°. Impossible.",
318                        calculated_declination_degrees
319                    );
320                    return None;
321                }
322                // If dec is 0 and tilt is 0, any year fraction works, but let's pick equinox.
323                return Some((
324                    calculated_latitude_degrees,
325                    0.0,
326                    calculated_declination_degrees,
327                ));
328            }
329
330            let sin_yf_angle = (dec_rad / tilt_rad).clamp(-1.0, 1.0); // Should be <= 1 from checks, but clamp for safety
331            let phi = sin_yf_angle.asin(); // phi is in [-PI/2, PI/2]
332
333            // There are two year fractions per declination (unless at solstice)
334            // yf1 maps dec >= 0 to [0, 0.25] and dec < 0 to [0.75, 1)
335            let yf1 = if dec_rad >= 0.0 {
336                phi / (2.0 * PI)
337            } else {
338                1.0 + phi / (2.0 * PI)
339            };
340            // yf2 maps dec >= 0 to [0.25, 0.5] and dec < 0 to (0.5, 0.75]
341            let yf2 = if dec_rad >= 0.0 {
342                0.5 - phi / (2.0 * PI)
343            } else {
344                0.5 - phi / (2.0 * PI)
345            };
346
347            // Let's choose the year fraction that is closer to the 'expected' season for the day length
348            // Long day (df > 0.5) suggests summer-like conditions (yf near 0.25 or 0.75 depending on hemi/tilt sign)
349            // Short day (df < 0.5) suggests winter-like conditions (yf near 0.75 or 0.25 depending on hemi/tilt sign)
350            // Given we aimed for lat/dec signs matching df, dec > 0 implies NH summer/SH winter half year.
351            // dec > 0 is yf in (0, 0.5). yf1 is [0, 0.25], yf2 is [0.25, 0.5]. Pick one closest to 0.25?
352            // dec < 0 is yf in (0.5, 1). yf1 is [0.75, 1), yf2 is (0.5, 0.75]. Pick one closest to 0.75?
353
354            let target_yf = if dec_rad >= 0.0 { 0.25 } else { 0.75 };
355            let calculated_year_fraction = if (target_yf - yf1).abs() < (target_yf - yf2).abs() {
356                yf1
357            } else {
358                yf2
359            };
360            // Ensure year fraction is in [0, 1) range
361            let final_yf = calculated_year_fraction.fract();
362            let final_yf = if final_yf < 0.0 {
363                final_yf + 1.0
364            } else {
365                final_yf
366            };
367
368            //  info!("Calculated parameters: Latitude {:.2}°, Declination {:.2}°, Year Fraction {:.4}",
369            //        calculated_latitude_degrees, calculated_declination_degrees, final_yf);
370
371            Some((
372                calculated_latitude_degrees,
373                final_yf,
374                calculated_declination_degrees,
375            ))
376        }
377        _ => {
378            warn!("No valid latitude/declination found for the given constraints.");
379            None
380        }
381    }
382}
383
384#[derive(Component, Debug, Clone)]
385#[require(Transform, Visibility)]
386pub struct SkyCenter {
387    pub latitude_degrees: f32,
388    pub planet_tilt_degrees: f32,
389
390    /// Fraction of the year (0.0 to 1.0), where 0.0 is Vernal Equinox.
391    pub year_fraction: f32,
392
393    /// Duration of a full day/night cycle in seconds.
394    pub cycle_duration_secs: f32,
395
396    /// The entity representing the sun (usually a DirectionalLight).
397    pub sun: Entity,
398
399    /// Time elapsed within the current cycle (seconds).
400    /// Stored here to allow pausing/setting time easily.
401    pub current_cycle_time: f32,
402}
403
404impl Default for SkyCenter {
405    fn default() -> Self {
406        Self {
407            latitude_degrees: 0.0,
408            planet_tilt_degrees: 23.5,
409            year_fraction: 0.0,
410            cycle_duration_secs: 600.0, // 10 minutes by default
411            sun: Entity::PLACEHOLDER,
412            current_cycle_time: 0.0,
413        }
414    }
415}
416
417impl SkyCenter {
418    pub fn from_timed_config(timed_config: &TimedSkyConfig) -> Option<Self> {
419        let calc = calculate_latitude_yearfraction(
420            timed_config.planet_tilt_degrees,
421            timed_config.day_duration_secs,
422            timed_config.night_duration_secs,
423            timed_config.max_sun_height_deg,
424        );
425
426        if let Some((latitude, year_fraction, _)) = calc {
427            Some(Self {
428                latitude_degrees: latitude,
429                planet_tilt_degrees: timed_config.planet_tilt_degrees,
430                year_fraction,
431                cycle_duration_secs: timed_config.day_duration_secs
432                    + timed_config.night_duration_secs,
433                sun: timed_config.sun_entity,
434                current_cycle_time: 0.0,
435            })
436        } else {
437            warn!("Failed to calculate latitude/year_fraction/declination for timed sky config.");
438            None
439        }
440    }
441
442    #[allow(dead_code)]
443    fn update_from_timed_config(&mut self, timed_config: &TimedSkyConfig) {
444        let calc = calculate_latitude_yearfraction(
445            timed_config.planet_tilt_degrees,
446            timed_config.day_duration_secs,
447            timed_config.night_duration_secs,
448            timed_config.max_sun_height_deg,
449        );
450
451        if let Some((latitude, year_fraction, _)) = calc {
452            self.latitude_degrees = latitude;
453            self.year_fraction = year_fraction;
454            self.cycle_duration_secs =
455                timed_config.day_duration_secs + timed_config.night_duration_secs;
456            self.sun = timed_config.sun_entity;
457        } else {
458            warn!("Failed to calculate latitude/year_fraction/declination for timed sky config.");
459        }
460    }
461}
462
463/// Calculates the sun's direction vector in the observer's local coordinate frame (Y up, X east, Z north).
464/// This vector points *from* the observer *towards* the sun.
465///
466/// Based on standard astronomical formulas converting equatorial coordinates (declination, hour angle)
467/// to horizontal coordinates (altitude, azimuth).
468///
469/// Args:
470/// - hour_fraction: Fraction of the day (0.0 to 1.0), where 0.0 is midnight, 0.5 is noon.
471/// - latitude_rad: Observer's latitude in radians (-PI/2 to PI/2).
472/// - axial_tilt_rad: Planet's axial tilt in radians (e.g., 23.5 degrees for Earth).
473/// - year_fraction: Fraction of the year (0.0 to 1.0), where 0.0 is Vernal Equinox.
474///
475/// Returns:
476/// A `Vec3` representing the sun's direction relative to the observer.
477/// The vector length is arbitrary, usually normalized.
478pub fn calculate_sun_direction(
479    hour_fraction: f32,
480    latitude_rad: f32,
481    axial_tilt_rad: f32,
482    year_fraction: f32,
483) -> Vec3 {
484    // Calculate sun's declination based on axial tilt and time of year.
485    // Assuming year_fraction 0.0 is Vernal Equinox (dec=0), 0.25 is Summer Solstice (dec=tilt), etc.
486    let year_angle_rad = year_fraction * 2.0 * PI;
487    let dec_rad = axial_tilt_rad * year_angle_rad.sin();
488
489    // Calculate Local Hour Angle (LHA). This is angle from local meridian (South/North line).
490    // hour_fraction 0.0 is midnight, 0.5 is noon. LHA is 0 at noon, PI 12 hours later.
491    // hour_angle_rad from midnight = hour_fraction * 2.0 * PI.
492    // Local Hour Angle (HA) is angle west of meridian. HA=0 at noon.
493    let hour_angle_rad_from_midnight = hour_fraction * 2.0 * PI;
494    let local_hour_angle_rad = hour_angle_rad_from_midnight - PI; // Angle from noon meridian, positive West
495
496    // Calculate sun's altitude (elevation above horizon) and components in local frame.
497    // Standard formulas for converting equatorial (Dec, HA) to horizontal (Alt, Azi):
498    // sin(alt) = sin(lat)sin(dec) + cos(lat)cos(dec)cos(HA)
499    // cos(alt)sin(azi) = cos(dec)sin(HA)              (X component in East-Up-North)
500    // cos(alt)cos(azi) = cos(lat)sin(dec) - sin(lat)cos(dec)cos(HA) (Z component in East-Up-North)
501
502    // Y (up) component = sin(altitude)
503    let sin_alt = latitude_rad.sin() * dec_rad.sin()
504        + latitude_rad.cos() * dec_rad.cos() * local_hour_angle_rad.cos();
505
506    // X (east) component = cos(altitude) * sin(azimuth from North towards East)
507    // Z (north) component = cos(altitude) * cos(azimuth from North towards East)
508    // We can get these components directly without calculating azimuth explicitly:
509    let x_east = dec_rad.cos() * local_hour_angle_rad.sin();
510    let z_north = latitude_rad.cos() * dec_rad.sin()
511        - latitude_rad.sin() * dec_rad.cos() * local_hour_angle_rad.cos();
512
513    // Construct the direction vector in the observer's local Bevy frame (X east, Y up, Z north)
514    let sun_direction_local = Vec3::new(
515        x_east,  // X: East
516        sin_alt, // Y: Up (sin_alt is already calculated)
517        z_north, // Z: North
518    );
519
520    // Normalize the vector
521    sun_direction_local.normalize()
522}
523
524fn update_sky_center<T: ISunTime + Resource>(
525    mut q_sky_center: Query<(&mut Transform, &mut SkyCenter)>,
526    mut q_sun: Query<&mut Transform, Without<SkyCenter>>,
527    time: Res<T>,
528) {
529    for (mut sky_transforms, mut sky_center) in q_sky_center.iter_mut() {
530        // Update time
531        sky_center.current_cycle_time = time.elapsed_secs();
532        sky_center.current_cycle_time %= sky_center.cycle_duration_secs; // Cycle time loops
533
534        let hour_fraction = sky_center.current_cycle_time / sky_center.cycle_duration_secs;
535
536        let latitude_rad = sky_center.latitude_degrees * DEGREES_TO_RADIANS;
537        let tilt_rad = sky_center.planet_tilt_degrees * DEGREES_TO_RADIANS;
538        let year_fraction = sky_center.year_fraction;
539
540        sky_transforms.translation = Vec3::ZERO;
541        // Sky sphere rotation axis. Useful for attach stars and celestial bodies to the sky sphere.
542        let celestial_pole_axis_local = Vec3::new(0.0, latitude_rad.sin(), latitude_rad.cos());
543
544        // Sky sphere rotation
545        let rotation_angle_rad = PI - hour_fraction * 2.0 * PI;
546        sky_transforms.rotation =
547            Quat::from_axis_angle(celestial_pole_axis_local, rotation_angle_rad);
548
549        let sun_direction_local =
550            calculate_sun_direction(hour_fraction, latitude_rad, tilt_rad, year_fraction);
551
552        if let Ok(mut sun_transform) = q_sun.get_mut(sky_center.sun) {
553            sun_transform.translation = sun_direction_local;
554            sun_transform.look_at(Vec3::ZERO, Vec3::Y); // Ensure the light points towards the origin
555        }
556    }
557}