import { describe, expect, it } from "vitest"; import { createDefaultProjectTimeSettings } from "../../src/document/project-time-settings"; import { createDefaultWorldSettings } from "../../src/document/world-settings"; import { advanceRuntimeClockState, createRuntimeClockState, hasTimeWindowJustEnded, hasTimeWindowJustStarted, isWithinTimeWindow, resolveRuntimeDayPhase, resolveRuntimeDayNightWorldState, resolveRuntimeTimeState } from "../../src/runtime-three/runtime-project-time"; describe("runtime project time", () => { it("advances and wraps the global clock using the authored day duration", () => { const settings = createDefaultProjectTimeSettings(); settings.startDayNumber = 3; settings.startTimeOfDayHours = 23.5; settings.dayLengthMinutes = 24; const clock = createRuntimeClockState(settings); const advancedClock = advanceRuntimeClockState(clock, 60); expect(advancedClock.timeOfDayHours).toBeCloseTo(0.5); expect(advancedClock.dayCount).toBe(3); expect(advancedClock.dayLengthMinutes).toBe(24); }); it("resolves reusable runtime day semantics from the authored sun windows", () => { const settings = createDefaultProjectTimeSettings(); settings.startDayNumber = 4; settings.dayLengthMinutes = 30; settings.sunriseTimeOfDayHours = 7; settings.sunsetTimeOfDayHours = 20; settings.dawnDurationHours = 2; settings.duskDurationHours = 2; const resolvedNight = resolveRuntimeTimeState(settings, { timeOfDayHours: 2, dayCount: 4, dayLengthMinutes: 30 }); const resolvedDawn = resolveRuntimeTimeState(settings, { timeOfDayHours: 6.5, dayCount: 4, dayLengthMinutes: 30 }); const resolvedDay = resolveRuntimeTimeState(settings, { timeOfDayHours: 12, dayCount: 4, dayLengthMinutes: 30 }); const resolvedDusk = resolveRuntimeTimeState(settings, { timeOfDayHours: 20.5, dayCount: 4, dayLengthMinutes: 30 }); expect(resolveRuntimeDayPhase(settings, 2)).toBe("night"); expect(resolveRuntimeDayPhase(settings, 6.5)).toBe("dawn"); expect(resolveRuntimeDayPhase(settings, 12)).toBe("day"); expect(resolveRuntimeDayPhase(settings, 20.5)).toBe("dusk"); expect(resolvedNight).toMatchObject({ timeOfDayHours: 2, dayCount: 4, dayLengthMinutes: 30, dayPhase: "night", isNight: true }); expect(resolvedDawn.dayPhase).toBe("dawn"); expect(resolvedDawn.isNight).toBe(false); expect(resolvedDay.dayPhase).toBe("day"); expect(resolvedDay.isNight).toBe(false); expect(resolvedDusk.dayPhase).toBe("dusk"); }); it("handles forward time windows across midnight", () => { expect(isWithinTimeWindow(22, 2, 23.5)).toBe(true); expect(isWithinTimeWindow(22, 2, 1.5)).toBe(true); expect(isWithinTimeWindow(22, 2, 12)).toBe(false); expect(hasTimeWindowJustStarted(21.5, 23.5, 22, 2)).toBe(true); expect(hasTimeWindowJustStarted(23.5, 1, 22, 2)).toBe(false); expect(hasTimeWindowJustEnded(23.5, 2.5, 22, 2)).toBe(true); expect(hasTimeWindowJustStarted(21, 3, 22, 2)).toBe(true); expect(hasTimeWindowJustEnded(21, 3, 22, 2)).toBe(true); }); it("derives authored dawn, day, and night lighting from the global time profile", () => { const world = createDefaultWorldSettings(); const time = createDefaultProjectTimeSettings(); world.background = { mode: "verticalGradient", topColorHex: "#88ccff", bottomColorHex: "#f2b774" }; world.timeOfDay.night.background = { mode: "verticalGradient", topColorHex: "#07101f", bottomColorHex: "#18253b" }; time.sunriseTimeOfDayHours = 7; time.sunsetTimeOfDayHours = 20; time.dawnDurationHours = 2; time.duskDurationHours = 2; const noon = resolveRuntimeDayNightWorldState(world, time, { timeOfDayHours: 12, dayCount: 0, dayLengthMinutes: 24 }); const preSunrise = resolveRuntimeDayNightWorldState(world, time, { timeOfDayHours: 6.5, dayCount: 0, dayLengthMinutes: 24 }); const dawn = resolveRuntimeDayNightWorldState(world, time, { timeOfDayHours: 7.5, dayCount: 0, dayLengthMinutes: 24 }); const lateDawn = resolveRuntimeDayNightWorldState(world, time, { timeOfDayHours: 7.9, dayCount: 0, dayLengthMinutes: 24 }); const postSunset = resolveRuntimeDayNightWorldState(world, time, { timeOfDayHours: 20.5, dayCount: 0, dayLengthMinutes: 24 }); const midnight = resolveRuntimeDayNightWorldState(world, time, { timeOfDayHours: 0, dayCount: 0, dayLengthMinutes: 24 }); expect(midnight.sunLight.intensity).toBeLessThan(noon.sunLight.intensity); expect(midnight.ambientLight.intensity).toBeLessThan( noon.ambientLight.intensity ); expect(preSunrise.ambientLight.intensity).toBeGreaterThan( midnight.ambientLight.intensity ); expect(preSunrise.ambientLight.intensity).toBeLessThan( noon.ambientLight.intensity ); expect(dawn.sunLight.colorHex).not.toBe(noon.sunLight.colorHex); expect(dawn.ambientLight.intensity).toBeGreaterThan( preSunrise.ambientLight.intensity ); expect(postSunset.ambientLight.intensity).toBeLessThan( noon.ambientLight.intensity ); expect(postSunset.ambientLight.intensity).toBeGreaterThan( midnight.ambientLight.intensity ); expect(midnight.moonLight?.intensity ?? 0).toBeGreaterThan(0); expect(preSunrise.moonLight?.intensity ?? 0).toBeGreaterThan(0); expect(dawn.moonLight?.intensity ?? 0).toBeGreaterThan(0); expect(lateDawn.moonLight?.intensity ?? 0).toBeLessThan( dawn.moonLight?.intensity ?? 0 ); expect(postSunset.moonLight?.intensity ?? 0).toBeGreaterThan(0); expect(noon.moonLight).toBeNull(); expect(noon.sunLight.direction.y).toBeGreaterThan(0); expect(midnight.sunLight.direction.y).toBeLessThan(0); if ( preSunrise.background.mode !== "verticalGradient" || dawn.background.mode !== "verticalGradient" || noon.background.mode !== "verticalGradient" || midnight.background.mode !== "verticalGradient" ) { throw new Error("Expected a gradient background for the day/night test."); } expect(preSunrise.background.topColorHex).not.toBe( midnight.background.topColorHex ); expect(dawn.background.topColorHex).not.toBe(noon.background.topColorHex); expect(midnight.background.topColorHex).not.toBe( noon.background.topColorHex ); expect(midnight.background.bottomColorHex).not.toBe( noon.background.bottomColorHex ); }); it("starts the visible sun and moon journeys at the end of dawn and dusk", () => { const world = createDefaultWorldSettings(); const time = createDefaultProjectTimeSettings(); time.sunriseTimeOfDayHours = 7; time.sunsetTimeOfDayHours = 20; time.dawnDurationHours = 2; time.duskDurationHours = 2; const midDawn = resolveRuntimeDayNightWorldState(world, time, { timeOfDayHours: 7.5, dayCount: 0, dayLengthMinutes: 24 }); const dawnEnd = resolveRuntimeDayNightWorldState(world, time, { timeOfDayHours: 8, dayCount: 0, dayLengthMinutes: 24 }); const preDawnEnd = resolveRuntimeDayNightWorldState(world, time, { timeOfDayHours: 7.99, dayCount: 0, dayLengthMinutes: 24 }); const duskEnd = resolveRuntimeDayNightWorldState(world, time, { timeOfDayHours: 21, dayCount: 0, dayLengthMinutes: 24 }); const lateDusk = resolveRuntimeDayNightWorldState(world, time, { timeOfDayHours: 21.5, dayCount: 0, dayLengthMinutes: 24 }); expect(midDawn.sunLight.direction.y).toBeLessThan(-0.05); expect(midDawn.sunLight.intensity).toBeGreaterThan(0); expect(Math.abs(dawnEnd.sunLight.direction.y)).toBeLessThan(0.05); expect(Math.abs(duskEnd.sunLight.direction.y)).toBeLessThan(0.05); expect(lateDusk.sunLight.direction.y).toBeLessThan(-0.05); expect(midDawn.moonLight?.direction.y ?? 0).toBeGreaterThan(0.05); expect(Math.abs(preDawnEnd.moonLight?.direction.y ?? 1)).toBeLessThan(0.05); expect(Math.abs(duskEnd.moonLight?.direction.y ?? 1)).toBeLessThan(0.05); expect(lateDusk.moonLight?.direction.y ?? 0).toBeGreaterThan(0.05); }); it("uses the authored peak altitude as the 12-hour baseline orbit height", () => { const world = createDefaultWorldSettings(); const time = createDefaultProjectTimeSettings(); time.sunriseTimeOfDayHours = 7; time.sunsetTimeOfDayHours = 19; time.dawnDurationHours = 2; time.duskDurationHours = 2; world.celestialOrbits.sun.peakAltitudeDegrees = 28; world.celestialOrbits.moon.peakAltitudeDegrees = 42; const sunPeak = resolveRuntimeDayNightWorldState(world, time, { timeOfDayHours: 14, dayCount: 0, dayLengthMinutes: 24 }); const moonPeak = resolveRuntimeDayNightWorldState(world, time, { timeOfDayHours: 2, dayCount: 0, dayLengthMinutes: 24 }); const expectedSunPeakY = Math.sin((28 * Math.PI) / 180); const expectedMoonPeakY = Math.sin((42 * Math.PI) / 180); expect(sunPeak.sunLight.direction.y).toBeCloseTo(expectedSunPeakY, 3); expect(moonPeak.moonLight?.direction.y ?? 0).toBeCloseTo( expectedMoonPeakY, 3 ); expect(sunPeak.sunLight.direction.y).toBeLessThan(0.55); expect(moonPeak.moonLight?.direction.y ?? 0).toBeLessThan(0.7); }); it("changes orbit height and sideways horizon position from day and night length", () => { const world = createDefaultWorldSettings(); const shortDay = createDefaultProjectTimeSettings(); const longDay = createDefaultProjectTimeSettings(); world.celestialOrbits.sun.azimuthDegrees = 0; world.celestialOrbits.sun.peakAltitudeDegrees = 45; world.celestialOrbits.moon.azimuthDegrees = 0; world.celestialOrbits.moon.peakAltitudeDegrees = 32; shortDay.sunriseTimeOfDayHours = 8; shortDay.sunsetTimeOfDayHours = 16; shortDay.dawnDurationHours = 2; shortDay.duskDurationHours = 2; longDay.sunriseTimeOfDayHours = 4; longDay.sunsetTimeOfDayHours = 20; longDay.dawnDurationHours = 2; longDay.duskDurationHours = 2; const shortDayPeak = resolveRuntimeDayNightWorldState(world, shortDay, { timeOfDayHours: 13, dayCount: 0, dayLengthMinutes: 24 }); const longDayPeak = resolveRuntimeDayNightWorldState(world, longDay, { timeOfDayHours: 13, dayCount: 0, dayLengthMinutes: 24 }); const shortDaySunrise = resolveRuntimeDayNightWorldState(world, shortDay, { timeOfDayHours: 9, dayCount: 0, dayLengthMinutes: 24 }); const longDaySunrise = resolveRuntimeDayNightWorldState(world, longDay, { timeOfDayHours: 5, dayCount: 0, dayLengthMinutes: 24 }); const shortNightMoonPeak = resolveRuntimeDayNightWorldState( world, longDay, { timeOfDayHours: 1, dayCount: 0, dayLengthMinutes: 24 } ); const longNightMoonPeak = resolveRuntimeDayNightWorldState( world, shortDay, { timeOfDayHours: 1, dayCount: 0, dayLengthMinutes: 24 } ); expect(shortDayPeak.sunLight.direction.y).toBeLessThan( longDayPeak.sunLight.direction.y ); expect(Math.abs(shortDaySunrise.sunLight.direction.y)).toBeLessThan(0.05); expect(Math.abs(longDaySunrise.sunLight.direction.y)).toBeLessThan(0.05); expect(shortDaySunrise.sunLight.direction.x).toBeGreaterThan(0.05); expect(longDaySunrise.sunLight.direction.x).toBeLessThan(-0.05); expect(longNightMoonPeak.moonLight?.direction.y ?? 0).toBeGreaterThan( shortNightMoonPeak.moonLight?.direction.y ?? 0 ); }); it("uses independent authored orbit settings for the moon path", () => { const world = createDefaultWorldSettings(); const time = createDefaultProjectTimeSettings(); time.sunriseTimeOfDayHours = 7; time.sunsetTimeOfDayHours = 19; time.dawnDurationHours = 2; time.duskDurationHours = 2; world.celestialOrbits.sun.azimuthDegrees = 180; world.celestialOrbits.moon.azimuthDegrees = 90; world.celestialOrbits.sun.peakAltitudeDegrees = 40; world.celestialOrbits.moon.peakAltitudeDegrees = 25; const sunPeak = resolveRuntimeDayNightWorldState(world, time, { timeOfDayHours: 14, dayCount: 0, dayLengthMinutes: 24 }); const moonPeak = resolveRuntimeDayNightWorldState(world, time, { timeOfDayHours: 2, dayCount: 0, dayLengthMinutes: 24 }); expect(sunPeak.sunLight.direction.x).toBeLessThan(-0.7); expect(Math.abs(sunPeak.sunLight.direction.z)).toBeLessThan(0.05); expect(sunPeak.sunLight.direction.y).toBeCloseTo( Math.sin((40 * Math.PI) / 180), 3 ); expect(moonPeak.moonLight?.direction.z ?? 0).toBeGreaterThan(0.85); expect(Math.abs(moonPeak.moonLight?.direction.x ?? 1)).toBeLessThan(0.05); expect(moonPeak.moonLight?.direction.y ?? 0).toBeCloseTo( Math.sin((25 * Math.PI) / 180), 3 ); }); it("uses the scene night image as a runtime overlay when the night background is an image", () => { const world = createDefaultWorldSettings(); const time = createDefaultProjectTimeSettings(); time.sunriseTimeOfDayHours = 7; time.sunsetTimeOfDayHours = 20; time.dawnDurationHours = 2; time.duskDurationHours = 2; world.timeOfDay.night.background = { mode: "image", assetId: "asset-night-sky", environmentIntensity: 0.42 }; const preSunrise = resolveRuntimeDayNightWorldState(world, time, { timeOfDayHours: 6.5, dayCount: 0, dayLengthMinutes: 24 }); const postSunset = resolveRuntimeDayNightWorldState(world, time, { timeOfDayHours: 20.5, dayCount: 0, dayLengthMinutes: 24 }); const midnight = resolveRuntimeDayNightWorldState(world, time, { timeOfDayHours: 0, dayCount: 0, dayLengthMinutes: 24 }); expect(preSunrise.nightBackgroundOverlay?.assetId).toBe("asset-night-sky"); expect(preSunrise.nightBackgroundOverlay?.opacity ?? 0).toBeGreaterThan(0); expect(postSunset.nightBackgroundOverlay?.opacity ?? 0).toBeGreaterThan(0); expect(midnight.nightBackgroundOverlay?.opacity ?? 0).toBeCloseTo(1); }); it("falls back to day and night image maps when dusk image mode has no assigned asset", () => { const world = createDefaultWorldSettings(); const time = createDefaultProjectTimeSettings(); time.sunriseTimeOfDayHours = 7; time.sunsetTimeOfDayHours = 20; time.dawnDurationHours = 2; time.duskDurationHours = 2; world.background = { mode: "image", assetId: "asset-day-sky", environmentIntensity: 0.8 }; world.timeOfDay.dusk.background = { mode: "image", assetId: "", environmentIntensity: 0.55 }; world.timeOfDay.night.background = { mode: "image", assetId: "asset-night-sky", environmentIntensity: 0.35 }; const dusk = resolveRuntimeDayNightWorldState(world, time, { timeOfDayHours: 20.5, dayCount: 0, dayLengthMinutes: 24 }); expect(dusk.background).toEqual({ mode: "image", assetId: "asset-day-sky", environmentIntensity: 0.8 }); expect(dusk.nightBackgroundOverlay?.assetId).toBe("asset-night-sky"); expect(dusk.nightBackgroundOverlay?.opacity ?? 0).toBeGreaterThan(0); }); it("prefers a twilight-specific image background when one is authored", () => { const world = createDefaultWorldSettings(); const time = createDefaultProjectTimeSettings(); time.sunriseTimeOfDayHours = 7; time.sunsetTimeOfDayHours = 20; time.dawnDurationHours = 2; time.duskDurationHours = 2; world.background = { mode: "image", assetId: "asset-day-sky", environmentIntensity: 0.8 }; world.timeOfDay.dawn.background = { mode: "image", assetId: "asset-dawn-sky", environmentIntensity: 0.6 }; world.timeOfDay.night.background = { mode: "image", assetId: "asset-night-sky", environmentIntensity: 0.35 }; const dawn = resolveRuntimeDayNightWorldState(world, time, { timeOfDayHours: 7.5, dayCount: 0, dayLengthMinutes: 24 }); expect(dawn.background).toEqual({ mode: "image", assetId: "asset-dawn-sky", environmentIntensity: 0.6 }); expect(dawn.nightBackgroundOverlay?.assetId).toBe("asset-night-sky"); }); it("leaves scene lighting untouched when the scene disables project time influence", () => { const world = createDefaultWorldSettings(); const time = createDefaultProjectTimeSettings(); world.projectTimeLightingEnabled = false; const resolved = resolveRuntimeDayNightWorldState(world, time, { timeOfDayHours: 0, dayCount: 0, dayLengthMinutes: 24 }); expect(resolved.ambientLight).toEqual(world.ambientLight); expect(resolved.sunLight).toEqual(world.sunLight); expect(resolved.background).toEqual(world.background); expect(resolved.moonLight).toBeNull(); }); it("keeps shader sky mode active across time phases without reusing image overlays", () => { const world = createDefaultWorldSettings(); const time = createDefaultProjectTimeSettings(); world.background = { mode: "shader" }; time.sunriseTimeOfDayHours = 7; time.sunsetTimeOfDayHours = 20; time.dawnDurationHours = 2; time.duskDurationHours = 2; const dusk = resolveRuntimeDayNightWorldState(world, time, { timeOfDayHours: 20.5, dayCount: 0, dayLengthMinutes: 24 }); const midnight = resolveRuntimeDayNightWorldState(world, time, { timeOfDayHours: 0, dayCount: 0, dayLengthMinutes: 24 }); expect(dusk.background).toEqual({ mode: "shader" }); expect(midnight.background).toEqual({ mode: "shader" }); expect(dusk.nightBackgroundOverlay).toBeNull(); expect(midnight.nightBackgroundOverlay).toBeNull(); }); });