From c00d3ce6c8349a10d9de9b7ab88607674c31ef02 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Sun, 12 Apr 2026 04:29:39 +0200 Subject: [PATCH] Add project time settings command and related files --- .../set-project-time-settings-command.ts | 47 +++ src/document/project-time-settings.ts | 48 +++ src/runtime-three/runtime-project-time.ts | 336 ++++++++++++++++++ 3 files changed, 431 insertions(+) create mode 100644 src/commands/set-project-time-settings-command.ts create mode 100644 src/document/project-time-settings.ts create mode 100644 src/runtime-three/runtime-project-time.ts diff --git a/src/commands/set-project-time-settings-command.ts b/src/commands/set-project-time-settings-command.ts new file mode 100644 index 00000000..b266606e --- /dev/null +++ b/src/commands/set-project-time-settings-command.ts @@ -0,0 +1,47 @@ +import { createOpaqueId } from "../core/ids"; +import { + cloneProjectTimeSettings, + type ProjectTimeSettings +} from "../document/project-time-settings"; + +import type { EditorCommand } from "./command"; + +interface SetProjectTimeSettingsCommandOptions { + label: string; + time: ProjectTimeSettings; +} + +export function createSetProjectTimeSettingsCommand( + options: SetProjectTimeSettingsCommandOptions +): EditorCommand { + const nextTime = cloneProjectTimeSettings(options.time); + let previousTime: ProjectTimeSettings | null = null; + + return { + id: createOpaqueId("command"), + label: options.label, + execute(context) { + const currentProjectDocument = context.getProjectDocument(); + + if (previousTime === null) { + previousTime = cloneProjectTimeSettings(currentProjectDocument.time); + } + + context.setProjectDocument({ + ...currentProjectDocument, + time: cloneProjectTimeSettings(nextTime) + }); + }, + undo(context) { + if (previousTime === null) { + return; + } + + const currentProjectDocument = context.getProjectDocument(); + context.setProjectDocument({ + ...currentProjectDocument, + time: cloneProjectTimeSettings(previousTime) + }); + } + }; +} \ No newline at end of file diff --git a/src/document/project-time-settings.ts b/src/document/project-time-settings.ts new file mode 100644 index 00000000..5eb2a93c --- /dev/null +++ b/src/document/project-time-settings.ts @@ -0,0 +1,48 @@ +export const HOURS_PER_DAY = 24 as const; +export const DEFAULT_PROJECT_DAY_LENGTH_MINUTES = 24 as const; +export const DEFAULT_PROJECT_START_TIME_OF_DAY_HOURS = 9 as const; + +export interface ProjectTimeSettings { + startTimeOfDayHours: number; + dayLengthMinutes: number; +} + +export function normalizeTimeOfDayHours(hours: number): number { + const wrappedHours = hours % HOURS_PER_DAY; + return wrappedHours < 0 ? wrappedHours + HOURS_PER_DAY : wrappedHours; +} + +export function formatTimeOfDayHours(hours: number): string { + const normalizedHours = normalizeTimeOfDayHours(hours); + const totalMinutes = Math.round(normalizedHours * 60) % (HOURS_PER_DAY * 60); + const wholeHours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + + return `${String(wholeHours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`; +} + +export function createDefaultProjectTimeSettings(): ProjectTimeSettings { + return { + startTimeOfDayHours: DEFAULT_PROJECT_START_TIME_OF_DAY_HOURS, + dayLengthMinutes: DEFAULT_PROJECT_DAY_LENGTH_MINUTES + }; +} + +export function cloneProjectTimeSettings( + settings: ProjectTimeSettings +): ProjectTimeSettings { + return { + startTimeOfDayHours: settings.startTimeOfDayHours, + dayLengthMinutes: settings.dayLengthMinutes + }; +} + +export function areProjectTimeSettingsEqual( + left: ProjectTimeSettings, + right: ProjectTimeSettings +): boolean { + return ( + left.startTimeOfDayHours === right.startTimeOfDayHours && + left.dayLengthMinutes === right.dayLengthMinutes + ); +} \ No newline at end of file diff --git a/src/runtime-three/runtime-project-time.ts b/src/runtime-three/runtime-project-time.ts new file mode 100644 index 00000000..f05848cd --- /dev/null +++ b/src/runtime-three/runtime-project-time.ts @@ -0,0 +1,336 @@ +import type { Vec3 } from "../core/vector"; +import { + formatTimeOfDayHours, + HOURS_PER_DAY, + normalizeTimeOfDayHours, + type ProjectTimeSettings +} from "../document/project-time-settings"; +import { + cloneWorldBackgroundSettings, + type WorldAmbientLightSettings, + type WorldBackgroundSettings, + type WorldSettings, + type WorldSunLightSettings +} from "../document/world-settings"; + +const NIGHT_AMBIENT_COLOR = "#162033"; +const NIGHT_SUN_COLOR = "#6f7fb5"; +const NIGHT_SOLID_BACKGROUND_COLOR = "#09111f"; +const NIGHT_GRADIENT_TOP_COLOR = "#0b1730"; +const NIGHT_GRADIENT_BOTTOM_COLOR = "#182134"; +const DEFAULT_NOON_DIRECTION: Vec3 = { + x: 0.45, + y: 0.88, + z: 0.15 +}; +const UP_AXIS: Vec3 = { + x: 0, + y: 1, + z: 0 +}; + +export interface RuntimeClockState { + timeOfDayHours: number; + dayCount: number; + dayLengthMinutes: number; +} + +export interface RuntimeDayNightWorldState { + ambientLight: WorldAmbientLightSettings; + sunLight: WorldSunLightSettings; + background: WorldBackgroundSettings; + daylightFactor: number; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function smoothstep(edge0: number, edge1: number, value: number): number { + if (edge0 === edge1) { + return value < edge0 ? 0 : 1; + } + + const t = clamp((value - edge0) / (edge1 - edge0), 0, 1); + return t * t * (3 - 2 * t); +} + +function lerp(left: number, right: number, amount: number): number { + return left + (right - left) * amount; +} + +function normalizeVec3(vector: Vec3): Vec3 { + const length = Math.hypot(vector.x, vector.y, vector.z); + + if (length <= 1e-6) { + return { + ...DEFAULT_NOON_DIRECTION + }; + } + + return { + x: vector.x / length, + y: vector.y / length, + z: vector.z / length + }; +} + +function cross(left: Vec3, right: Vec3): Vec3 { + return { + x: left.y * right.z - left.z * right.y, + y: left.z * right.x - left.x * right.z, + z: left.x * right.y - left.y * right.x + }; +} + +function dot(left: Vec3, right: Vec3): number { + return left.x * right.x + left.y * right.y + left.z * right.z; +} + +function scaleVec3(vector: Vec3, scale: number): Vec3 { + return { + x: vector.x * scale, + y: vector.y * scale, + z: vector.z * scale + }; +} + +function addVec3(left: Vec3, right: Vec3): Vec3 { + return { + x: left.x + right.x, + y: left.y + right.y, + z: left.z + right.z + }; +} + +function rotateAroundAxis(vector: Vec3, axis: Vec3, radians: number): Vec3 { + const cosine = Math.cos(radians); + const sine = Math.sin(radians); + + return normalizeVec3( + addVec3( + addVec3( + scaleVec3(vector, cosine), + scaleVec3(cross(axis, vector), sine) + ), + scaleVec3(axis, dot(axis, vector) * (1 - cosine)) + ) + ); +} + +function parseHexColor(colorHex: string): { r: number; g: number; b: number } { + return { + r: Number.parseInt(colorHex.slice(1, 3), 16), + g: Number.parseInt(colorHex.slice(3, 5), 16), + b: Number.parseInt(colorHex.slice(5, 7), 16) + }; +} + +function formatHexColor(color: { r: number; g: number; b: number }): string { + const toHex = (value: number) => + Math.round(clamp(value, 0, 255)).toString(16).padStart(2, "0"); + + return `#${toHex(color.r)}${toHex(color.g)}${toHex(color.b)}`; +} + +function blendHexColors(leftHex: string, rightHex: string, amount: number): string { + const left = parseHexColor(leftHex); + const right = parseHexColor(rightHex); + + return formatHexColor({ + r: lerp(left.r, right.r, amount), + g: lerp(left.g, right.g, amount), + b: lerp(left.b, right.b, amount) + }); +} + +function resolveNoonSunDirection(direction: Vec3): Vec3 { + const normalizedDirection = normalizeVec3(direction); + + if (normalizedDirection.y >= 0.2) { + return normalizedDirection; + } + + return normalizeVec3({ + x: normalizedDirection.x, + y: Math.abs(normalizedDirection.y) + 0.35, + z: normalizedDirection.z + }); +} + +function resolveTimeDrivenSunDirection( + noonDirection: Vec3, + timeOfDayHours: number +): Vec3 { + const orbitAxisCandidate = cross(noonDirection, UP_AXIS); + const orbitAxis = + Math.hypot( + orbitAxisCandidate.x, + orbitAxisCandidate.y, + orbitAxisCandidate.z + ) <= 1e-6 + ? { + x: 1, + y: 0, + z: 0 + } + : normalizeVec3(orbitAxisCandidate); + const orbitRadians = + ((normalizeTimeOfDayHours(timeOfDayHours) - 12) / HOURS_PER_DAY) * + Math.PI * 2; + + return rotateAroundAxis(noonDirection, orbitAxis, orbitRadians); +} + +function resolveTimeDrivenBackground( + background: WorldBackgroundSettings, + daylightFactor: number +): WorldBackgroundSettings { + if (background.mode === "solid") { + return { + mode: "solid", + colorHex: blendHexColors( + NIGHT_SOLID_BACKGROUND_COLOR, + background.colorHex, + daylightFactor + ) + }; + } + + if (background.mode === "verticalGradient") { + return { + mode: "verticalGradient", + topColorHex: blendHexColors( + NIGHT_GRADIENT_TOP_COLOR, + background.topColorHex, + daylightFactor + ), + bottomColorHex: blendHexColors( + NIGHT_GRADIENT_BOTTOM_COLOR, + background.bottomColorHex, + daylightFactor + ) + }; + } + + return cloneWorldBackgroundSettings(background); +} + +export function createRuntimeClockState( + settings: ProjectTimeSettings +): RuntimeClockState { + return { + timeOfDayHours: normalizeTimeOfDayHours(settings.startTimeOfDayHours), + dayCount: 0, + dayLengthMinutes: settings.dayLengthMinutes + }; +} + +export function cloneRuntimeClockState( + state: RuntimeClockState +): RuntimeClockState { + return { + timeOfDayHours: state.timeOfDayHours, + dayCount: state.dayCount, + dayLengthMinutes: state.dayLengthMinutes + }; +} + +export function areRuntimeClockStatesEqual( + left: RuntimeClockState, + right: RuntimeClockState +): boolean { + return ( + left.timeOfDayHours === right.timeOfDayHours && + left.dayCount === right.dayCount && + left.dayLengthMinutes === right.dayLengthMinutes + ); +} + +export function reconfigureRuntimeClockState( + state: RuntimeClockState, + settings: ProjectTimeSettings +): RuntimeClockState { + return { + ...cloneRuntimeClockState(state), + dayLengthMinutes: settings.dayLengthMinutes + }; +} + +export function advanceRuntimeClockState( + state: RuntimeClockState, + dtSeconds: number +): RuntimeClockState { + if (dtSeconds <= 0) { + return cloneRuntimeClockState(state); + } + + const safeDayLengthMinutes = Math.max(state.dayLengthMinutes, 0.001); + const hoursAdvanced = + (dtSeconds / (safeDayLengthMinutes * 60)) * HOURS_PER_DAY; + const absoluteHours = state.timeOfDayHours + hoursAdvanced; + + return { + timeOfDayHours: normalizeTimeOfDayHours(absoluteHours), + dayCount: state.dayCount + Math.floor(absoluteHours / HOURS_PER_DAY), + dayLengthMinutes: state.dayLengthMinutes + }; +} + +export function formatRuntimeClockTime(state: RuntimeClockState): string { + return formatTimeOfDayHours(state.timeOfDayHours); +} + +export function resolveRuntimeDayNightWorldState( + world: WorldSettings, + clock: RuntimeClockState | null +): RuntimeDayNightWorldState { + if (clock === null) { + return { + ambientLight: { + colorHex: world.ambientLight.colorHex, + intensity: world.ambientLight.intensity + }, + sunLight: { + colorHex: world.sunLight.colorHex, + intensity: world.sunLight.intensity, + direction: { + ...world.sunLight.direction + } + }, + background: cloneWorldBackgroundSettings(world.background), + daylightFactor: 1 + }; + } + + const noonDirection = resolveNoonSunDirection(world.sunLight.direction); + const sunDirection = resolveTimeDrivenSunDirection( + noonDirection, + clock.timeOfDayHours + ); + const daylightFactor = smoothstep(-0.2, 0.18, sunDirection.y); + const ambientFactor = lerp(0.18, 1, daylightFactor); + const sunFactor = lerp(0.02, 1, smoothstep(-0.05, 0.22, sunDirection.y)); + + return { + ambientLight: { + colorHex: blendHexColors( + NIGHT_AMBIENT_COLOR, + world.ambientLight.colorHex, + lerp(0.15, 1, daylightFactor) + ), + intensity: world.ambientLight.intensity * ambientFactor + }, + sunLight: { + colorHex: blendHexColors( + NIGHT_SUN_COLOR, + world.sunLight.colorHex, + lerp(0.1, 1, daylightFactor) + ), + intensity: world.sunLight.intensity * sunFactor, + direction: sunDirection + }, + background: resolveTimeDrivenBackground(world.background, daylightFactor), + daylightFactor + }; +} \ No newline at end of file