diff --git a/docs/development/scene-settings-manager.md b/docs/development/scene-settings-manager.md new file mode 100644 index 0000000000..1c76a29df3 --- /dev/null +++ b/docs/development/scene-settings-manager.md @@ -0,0 +1,757 @@ +# Scene Settings Manager + +> Contextual, automatic setting overrides for Community Shaders — no feature code changes required. + +## Table of Contents + +- [What Is the Scene Settings Manager?](#what-is-the-scene-settings-manager) +- [How Settings Flow (Priority Order)](#how-settings-flow-priority-order) +- [Design Philosophy: Zero Coupling](#design-philosophy-zero-coupling) +- [Interior Only Settings](#interior-only-settings) +- [Time of Day Settings](#time-of-day-settings) +- [UI Guide](#ui-guide) +- [For Mod Authors: Overwrite Files](#for-mod-authors-overwrite-files) +- [For Developers: Adding Features to the Whitelist](#for-developers-adding-features-to-the-whitelist) +- [The Whitelist and Why Features Don't Opt In](#the-whitelist-and-why-features-dont-opt-in) +- [Comparison: Scene Settings Manager vs Settings Override Manager](#comparison-scene-settings-manager-vs-settings-override-manager) +- [FAQ](#faq) + +--- + +## What Is the Scene Settings Manager? + +The Scene Settings Manager lets you automatically adjust Community Shaders feature settings based on **where you are** and **what time it is**. It has two modes: + +- **Interior Only** — Override settings when you enter an interior cell. Values revert automatically when you leave. +- **Time of Day** — Smoothly blend settings across six time-of-day periods (Dawn, Sunrise, Day, Sunset, Dusk, Night) while you're in an exterior cell. + +Both modes work entirely through the existing `SaveSettings`/`LoadSettings` JSON interface that every feature already has. Features don't need to do anything special — the Scene Settings Manager reads their current values, patches in overrides, and writes them back. The feature never knows the difference. + +The two modes are **mutually exclusive by context** — Interior Only is active in interiors, Time of Day is active in exteriors. You can have entries for both; the system automatically activates the correct one based on where you are. It's impossible for both to be active simultaneously. + +--- + +## How Settings Flow (Priority Order) + +Settings in Community Shaders follow a layered override system. Each layer can modify values from the layer below it. Later layers win. + +The feature's settings on its settings page act as the **master settings** — they are the source of truth that the Scene Settings Manager builds from. When the Scene Settings Manager activates (on cell transition or per-frame TOD blending), it reads the feature's current values via `SaveSettings()` and stores them as the **baseline**. All scene overrides are then applied on top of that baseline. When scene settings deactivate (leaving an interior, or TOD reverting), the baseline is restored — putting the feature back to exactly where its master settings had it. + +``` +┌────────────────────────────────────────────────────┐ +│ Scene Settings Manager │ ← Highest priority (runtime, contextual) +│ ┌─────────────────────┐ ┌───────────────────────┐ │ +│ │ Interior Only │ │ Time of Day │ │ +│ │ (overwrite files │ │ (overwrite files │ │ +│ │ + user settings) │ │ + user settings) │ │ +│ └─────────────────────┘ └───────────────────────┘ │ +├────────────────────────────────────────────────────┤ +│ Settings Override Manager │ ← Applied at boot (mod author JSON files) +│ ┌─────────────────────┐ ┌───────────────────────┐ │ +│ │ Mod Override Files │ │ User Override Files │ │ +│ │ (Overrides/*.json) │ │ (Overrides/User/) │ │ +│ └─────────────────────┘ └───────────────────────┘ │ +├────────────────────────────────────────────────────┤ +│ User Settings (In-Game CS Menu) │ ← Runtime (saved to SettingsUser.json) +│ ┌───────────────────────────────────────────────┐ │ +│ │ Slider, checkbox, and input changes made by │ │ +│ │ the user through the CS in-game menu │ │ +│ └───────────────────────────────────────────────┘ │ +├────────────────────────────────────────────────────┤ +│ Feature Default Settings │ ← Lowest priority (hardcoded + INI) +│ ┌─────────────────────┐ ┌───────────────────────┐ │ +│ │ Hardcoded Defaults │ │ Feature INI File │ │ +│ │ (C++ source code) │ │ (loaded at boot) │ │ +│ └─────────────────────┘ └───────────────────────┘ │ +└────────────────────────────────────────────────────┘ +``` + +### Layer Details + +| Layer | When Applied | Persists? | Who Creates It | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| **Feature Defaults** | At boot, baked into the feature code and loaded from the feature's INI file | Always present | Feature developers | +| **User Settings** | At runtime, whenever the user changes a setting through the in-game CS menu. Saved to `SettingsUser.json`. | Yes (saved to disk on change) | Users (in-game UI sliders, checkboxes, etc.) | +| **Settings Override Manager** | At boot, after defaults are loaded. Mod author JSON files in `Overrides/` folder merge on top of defaults. User `.user` files sit on top of those. | Yes (files on disk) | Mod authors and users | +| **Scene Settings Manager** | At runtime, contextually. Interior Only applies on cell transitions. Time of Day blends continuously in exteriors. | User settings saved to disk. Overwrite files on disk. Values revert when context changes. | Mod authors (overwrite files) and users (in-game UI) | + +### Flow in Practice + +Here's what happens to a single setting — say, `ScreenSpaceGI.Intensity`: + +1. **Boot**: Feature loads its default (e.g., `1.0` from INI). +2. **Boot**: If a Settings Override Manager file sets `Intensity: 0.8`, the feature now uses `0.8`. +3. **User tweaks**: You open the CS menu and drag the Intensity slider to `0.9`. This is saved to `SettingsUser.json` and becomes the active value. +4. **Gameplay (exterior)**: If a Time of Day entry sets `Intensity` to `0.5` at Night and `1.2` at Day, the Scene Settings Manager saves the current baseline (`0.9`), then blends between period values using the current game hour. Uncovered periods fall back to the saved baseline. +5. **Gameplay (enter interior)**: Time of Day deactivates. If an Interior Only entry sets `Intensity` to `0.3`, the current exterior value is saved and the interior override applies. +6. **Gameplay (exit interior)**: The interior override reverts to the saved exterior value. Time of Day reactivates in the exterior. + +Within the Scene Settings Manager itself, **Overwrite files** (from mod authors) take priority over **User settings** (from the in-game UI). Both are visible and manageable in the same panel. This is a last-write-wins system applied in order: user entries first, then overwrites on top. + +--- + +## Design Philosophy: Zero Coupling + +The Scene Settings Manager's most important design principle is **zero coupling to feature code**. + +### How It Works Under the Hood + +Every feature in Community Shaders already implements two methods: + +```cpp +virtual void SaveSettings(json&) {} // Serialize current settings to JSON +virtual void LoadSettings(json&) {} // Deserialize settings from JSON +``` + +The Scene Settings Manager exploits this existing interface: + +1. **Read**: Call `feature->SaveSettings(json)` to get the feature's current state as a JSON blob. +2. **Patch**: Modify specific keys in that JSON (the overrides). +3. **Write**: Call `feature->LoadSettings(json)` to push the modified settings back. + +That's it. The feature's own serialization code handles all type conversion, validation, and clamping. The Scene Settings Manager never touches feature internals — it only operates on the JSON interface that already exists for saving settings to disk. + +``` +┌──────────────┐ ┌─────────────────────┐ ┌──────────────┐ +│ Feature │──────►│ Scene Settings Mgr │──────►│ Feature │ +│ SaveSettings │ JSON │ (patch overrides) │ JSON │ LoadSettings │ +└──────────────┘ └─────────────────────┘ └──────────────┘ +``` + +### Why This Matters + +- **No feature code changes needed.** A feature gets Scene Settings Manager support by being added to a whitelist — a single line in a static list. The feature itself is unmodified. +- **Forward-compatible.** Features that don't exist yet will work with the Scene Settings Manager the moment they're added to the whitelist. If someone is developing a new feature that hasn't been merged yet, it can still be whitelisted in advance. +- **Any JSON-serializable setting works.** Floats get smoothly blended between time-of-day periods (integers, booleans, and strings are rejected from TOD — only continuous float sliders can transition). For Interior Only, all setting types are supported. If a feature adds new settings, they're automatically available — no registration step needed. +- **Round-trip verification.** After applying an override, the manager reads the value back and logs a warning if the feature clamped it. This catches range violations without requiring the Scene Settings Manager to know anything about valid ranges. + +### Contrast with Tighter Coupling + +To appreciate the zero-coupling approach, consider what a tightly-coupled system would look like: + +- Features would need to **register** each controllable variable with name, type, range, and interpolation function. +- Adding a new setting to scene control would require **code changes in the feature**. +- Type-specific interpolation logic would need to be **duplicated or centralized** for every variable type. + +The Scene Settings Manager avoids all of this. It treats features as black boxes with a JSON interface. This means: + +- A mod author can create overwrite files targeting any setting that appears in a feature's JSON — even settings the Scene Settings Manager developers have never heard of. +- The system scales to any number of features and settings without increasing complexity. + +--- + +## Interior Only Settings + +### How It Works + +Interior Only settings activate when you enter an interior cell and deactivate when you leave. + +**Detection**: The system listens for Skyrim's `MenuOpenCloseEvent`. When the Loading Menu closes, it checks the current cell's sky mode. If `sky->mode != kFull`, you're in an interior. + +**Lifecycle**: + +``` +┌────────────┐ Loading Menu ┌──────────────────┐ +│ Exterior │───── closes ──────►│ Check sky mode │ +│ (normal) │ │ sky->mode? │ +└────────────┘ └────────┬─────────┘ + │ + ┌────────────┴────────────┐ + │ │ + kFull (exterior) !kFull (interior) + │ │ + ┌─────────▼──────────┐ ┌──────────▼──────────┐ + │ Revert interior │ │ Save exterior vals │ + │ settings if active │ │ Apply overrides │ + │ Activate TOD │ │ Deactivate TOD │ + └────────────────────┘ └─────────────────────┘ +``` + +**What "save and restore" means:** + +1. Before applying interior overrides, the manager calls `SaveSettings()` on each affected feature and stores **only the keys it's about to override** (a partial baseline). +2. Interior overrides are applied via `LoadSettings()`. +3. When you exit to an exterior, the saved baseline values are written back — restoring the feature to its pre-interior state. + +This partial-save approach means features keep any settings you changed in-game (via the CS menu) that aren't part of the interior override. Only the specific overridden keys revert. + +User settings (entries you add through the in-game UI) are persisted automatically to `SceneSettings/InteriorOnly.json`. They survive game restarts — you don't need to save them + +### Example + +Say you have Interior Only overrides for: + +- `ScreenSpaceGI.EnableGI` → `false` (disable GI in interiors) +- `SubsurfaceScattering.Intensity` → `0.2` (reduce SSS indoors) + +When you enter Dragonsreach: + +1. Current values of `EnableGI` and `Intensity` are saved. +2. `EnableGI` is set to `false`, `Intensity` to `0.2`. +3. You play through the interior with these settings active. + +When you exit to Whiterun: + +1. `EnableGI` reverts to its saved value (e.g., `true`). +2. `Intensity` reverts to its saved value (e.g., `0.5`). +3. Time of Day reactivates (if you have TOD entries). + +--- + +## Time of Day Settings + +### How It Works + +Time of Day (TOD) settings smoothly blend feature values across six periods while you're in an exterior cell. + +**Periods and Hour Boundaries**: + +| Period | Hours | Description | +| ------- | ------------- | ------------------------------------- | +| Dawn | 4:00 – 6:00 | Pre-sunrise golden hour | +| Sunrise | 6:00 – 8:00 | Sun coming up | +| Day | 8:00 – 17:00 | Full daylight | +| Sunset | 17:00 – 19:00 | Sun going down | +| Dusk | 19:00 – 21:00 | Post-sunset blue hour | +| Night | 21:00 – 4:00 | Full darkness (wraps around midnight) | + +**Blending**: At the boundary between two periods, values blend over a 30-minute (0.5 game-hour) transition zone. Outside the transition zone, the current period's value is used at full weight. + +User settings for Time of Day are persisted automatically to `SceneSettings/TimeOfDay.json` and survive game restarts. + +**Float values** are linearly interpolated between periods based on these factors. If a setting isn't defined for a particular period, the saved baseline value is used for that period's weight — so the blend always sums to the correct total. + +**Only float settings are allowed** in Time of Day. Integers, booleans, and strings cannot be smoothly interpolated between periods and are rejected — both from the UI dialog and from overwrite files. If an overwrite file contains a non-float setting, it is skipped with a log warning. + +### Performance Optimizations + +The blending runs every frame, so the system includes several optimizations: + +- **Hour throttle**: The blend only recalculates when the game hour has changed by more than 0.001 (about 0.36 real-time seconds at default timescale). This skips 98%+ of per-frame work. +- **Epsilon cache**: For each float value, the last-applied result is cached. If the new result differs by less than 0.001, the `LoadSettings()` call is skipped entirely. +- **Batch updates**: All dirty keys for a single feature are collected and applied in a single `LoadSettings()` call, rather than calling it once per key. + +### Example + +Say you set `CloudShadows.Opacity`: + +- Dawn: `0.3` +- Day: `0.8` +- Sunset: `0.5` +- Night: `0.1` + +(Sunrise and Dusk are left undefined — they'll fall back to the baseline.) + +At 5:30 (mid-Dawn, 30 min before Sunrise transition): + +- Dawn factor = 1.0, result = `0.3` + +At 5:45 (Dawn→Sunrise transition starts, 15 min left): + +- Dawn factor ≈ 0.5, Sunrise factor ≈ 0.5 +- Sunrise has no override → uses baseline (say `0.6`) +- Result = 0.5 × 0.3 + 0.5 × 0.6 = `0.45` + +At 12:00 (mid-Day): + +- Day factor = 1.0, result = `0.8` + +--- + +## UI Guide + +The Scene Settings Manager is accessed through the **Weather Editor** window (`F8` by default). It adds two categories to the objects window sidebar: **Interior Only** and **Time of Day**. + +### Accessing the Scene Settings Panels + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Weather Editor [X] │ +├──────────────────┬──────────────────────────────────────────────┤ +│ Categories │ │ +│ ─────────────── │ (Panel content │ +│ Weather │ depends on │ +│ ImageSpace │ selected category) │ +│ Lighting Templ. │ │ +│ Cell Lighting │ │ +│ Vol. Lighting │ │ +│ Shader Particle │ │ +│ Lens Flare │ │ +│ Visual Effect │ │ +│ ─────────────── │ │ +│▸ Interior Only │ ◄── Scene Settings Manager categories │ +│▸ Time of Day │ │ +│ │ │ +└──────────────────┴──────────────────────────────────────────────┘ +``` + +Select **Interior Only** or **Time of Day** from the left sidebar to open the corresponding panel. + +--- + +### Interior Only Panel (UI) + +``` +┌───────────────────────────────────────────────────────────────┐ +│ Interior Only Settings [+] │ +│ ────────────────────────────────────────────────────────── │ +│ │ +│ Overwrite Files [Pause All] [Delete All] │ +│ ───────────────────────────────────────────────────── │ +│ ▼ Screen Space GI: │ +│ EnableGI [V] [●] [X] │ +│ AmbientIntensity [0.500___] [●] [X] │ +│ · · · · · · · · · · · · · · · · · · · · · · │ +│ ▼ Subsurface Scattering: │ +│ Intensity [0.200___] [●] [X] │ +│ │ +│ User Settings [Pause All] [Delete All] │ +│ ───────────────────────────────────────────────────── │ +│ ▼ Linear Lighting: │ +│ GammaCorrection [2.200___] [●] [X] │ +│ │ +└───────────────────────────────────────────────────────────────┘ +``` + +The **[+]** button is **right-aligned** on the header line. + +Clicking it opens the **Add Feature Settings** dialog: + +``` +┌───────────────────────────────────────┐ +│ Add Feature Settings [X] │ +│ ┌──────────────────────────────────┐ │ +│ │ Select Feature... ▼ │ │ +│ └──────────────────────────────────┘ │ +│ ──────────────────────────────────── │ +│ [Select All] [Select None] │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ [✓] EnableGI │ │ +│ │ [ ] AmbientIntensity │ │ +│ │ [ ] IndirectLightingStrength │ │ +│ │ [ ] MaxDistance │ │ +│ │ [ ] NumSteps │ │ +│ │ ... (scrollable) │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ [ Add (1) ] │ +└───────────────────────────────────────┘ +``` + +**Elements:** + +| Element | Description | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **[+] button** | Opens the Add Feature Settings dialog to select a feature and its settings. | +| **Feature dropdown** | Lists whitelisted features. Selecting one populates the setting checkbox list below. | +| **Select All / Select None** | Bulk-select or clear all checkboxes in the settings list. | +| **Settings checkbox list** | Scrollable list of JSON keys from the feature's `SaveSettings()`. For Time of Day, only float keys are shown (integers, booleans, and strings are excluded). Already-added settings appear checked and disabled. | +| **Add button** | Adds all checked settings with their current values. Shows the count of selected settings. Closes the dialog on success. | +| **Overwrite Files section** | Entries loaded from mod author JSON files. Values are read-only (greyed out) — mod authors set them. You can pause or delete individual entries or all at once. | +| **User Settings section** | Entries you added through the UI. Values are editable. | +| **Value editor** | Checkbox for booleans, number input for floats/integers. | +| **[●] toggle** | Pause/resume individual entries. Paused entries are ignored without being deleted. | +| **[X] button** | Delete the entry. For overwrites, this deletes the file from disk (with confirmation). | +| **Pause All / Delete All** | Bulk controls per section. | + +**Entries are grouped by feature** with collapsible tree nodes, sorted alphabetically. Light separators appear between feature groups for visual clarity. + +--- + +### Time of Day Panel (UI) + +A row of named add buttons sits below the header, one per period plus an "Add All" shortcut: + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Time of Day Settings (Exterior Only) [Day 12.0h] │ +│ ────────────────────────────────────────────────────────────────────────── │ +│ [Add Dawn][Add Sunrise][Add Day][Add Sunset][Add Dusk][Add Night][Add All] │ +│ ────────────────────────────────────────────────────────────────────────── │ +│ │ +│ Overwrite Files [Pause All] [Delete All] │ +│ ┌─────────┬────────┬────────┬────────┬────────┬──────┬───────┐ │ +│ │Setting │ Dawn │Sunrise │ Day │ Sunset │ Dusk │ Night │ │ +│ │ │ [●][X] │ [●][X] │ [●][X] │ [●][X] │[●][X]│[●][X] │ │ +│ ├─────────┼────────┼────────┼────────┼────────┼──────┼───────┤ │ +│ │CloudShadows: │ │ +│ │ Opacity │ 0.300 │ -- │ 0.800 │ 0.500 │ -- │ 0.100 │ │ +│ │ │ [●][X] │ │ [●][X] │ [●][X] │ │[●][X] │ │ +│ └─────────┴────────┴────────┴────────┴────────┴──────┴───────┘ │ +│ │ +│ User Settings [Pause All] [Delete All] │ +│ ┌─────────┬────────┬────────┬────────┬────────┬──────┬───────┐ │ +│ │Setting │ Dawn │Sunrise │ Day │ Sunset │ Dusk │ Night │ │ +│ │ │ [●][X] │ [●][X] │ [●][X] │ [●][X] │[●][X]│[●][X] │ │ +│ ├─────────┼────────┼────────┼────────┼────────┼──────┼───────┤ │ +│ │Skylighting: │ │ +│ │ MixAmt │ 0.400 │ 0.600 │ 0.800 │ 0.600 │0.400 │ 0.200 │ │ +│ │ │ [●][X] │ [●][X] │ [●][X] │ [●][X] │[●][X]│[●][X] │ │ +│ └─────────┴────────┴────────┴────────┴────────┴──────┴───────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +Clicking an **Add** button opens the **Add Feature Settings** dialog (see above). **Only float settings appear** in the TOD dialog — integers, booleans, and strings cannot be smoothly transitioned between periods and are excluded. Overwrite files containing non-float TOD settings are also rejected at load time with a log warning. + +**Elements:** + +| Element | Description | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Header** | Shows the current period and game hour (e.g., `[Day 12.0h]`). | +| **Add buttons** | An inline row of small buttons — one per period ("Add Dawn" through "Add Night") plus "Add All". Each opens a dialog scoped to that period; "Add All" populates all 6 periods at once. | +| **Header controls** | Each period column header includes a toggle [●] (pause/unpause all entries in that period) and [X] (delete all entries in that period) below the period name. | +| **Period columns** | One column per period. The active period column is highlighted; inactive periods are dimmed. `--` means no override for that period (falls back to baseline). | +| **Row-level controls** | Each setting row has a toggle (pause all periods) and delete (remove all periods) button in the Setting column. | +| **Per-cell controls** | Each individual period cell has its own value editor, pause toggle, and delete button. | +| **Setting filter** | The add dialog only shows float settings. Integers, booleans, and strings are excluded since they cannot be smoothly interpolated between periods. Overwrite files are also validated — non-float TOD entries are rejected at load. | + +--- + +### Feature Settings Page (Scene Toggle) + +When scene settings are actively controlling a feature, its settings page in the main CS menu shows a toggle: + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Screen Space GI v2.1.0 [●] [▼] │ +│ High quality ambient occlusion and indirect lighting. │ +│ ──────────────────────────────────────────────────── │ +│ │ +│ [●] Scene Specific Settings │ +│ ──────────────────────────── │ +│ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ +│ │ ░░░ (All settings greyed out while scene settings ░░░ │ │ +│ │ ░░░ are active.) ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ +│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +**Behaviour:** + +- The **Scene Specific Settings** toggle appears only when scene entries exist for this feature (active or paused). +- When **active** (toggle on, green): All feature settings are **disabled** (greyed out). The Scene Settings Manager is controlling values. +- When **paused** (toggle off): Scene settings stop applying. Feature settings become editable again. This is per-feature — it doesn't affect other features. +- The **"Apply Override" button** (from the Settings Override Manager) is also disabled while scene settings are active, to prevent conflicting writes. + +**Why all settings are greyed out, not just the overridden ones:** + +The entire feature settings page is disabled because the scene settings toggle is drawn at the top of the feature page, before any individual settings are rendered. Since ImGui draws top-to-bottom, disabling at the page level greys out everything below it — there's no per-setting knowledge at that point in the draw call. This is functionally fine because the Scene Settings Manager only modifies the specific keys it has entries for; all other settings stay at their current values. Pausing all scene settings to edit a non-overridden setting is the natural workflow — if you're tweaking settings manually, you'd want scene overwrites paused anyway to see your changes without interference. And if you need a setting to change contextually while scene settings are active, you'd be adding it as a scene setting entry rather than editing it on the feature page. + +--- + +## For Mod Authors: Overwrite Files + +Mod authors can ship pre-configured scene settings as JSON files. These appear in the **Overwrite Files** section of the UI, separate from user settings. Users can pause or delete them, but can't edit their values. + +### Directory Structure + +``` +Data/ +└── SKSE/ + └── Plugins/ + └── CommunityShaders/ + └── SceneSettings/ + ├── InteriorOnly.json ← User settings (auto-saved) + ├── TimeOfDay.json ← User settings (auto-saved) + ├── InteriorOnly/ ← Overwrite files directory + │ ├── MyModPack_ScreenSpaceGI_EnableGI.json + │ ├── MyModPack_SubsurfaceScattering_Intensity.json + │ └── AnotherMod_LinearLighting_GammaCorrection.json + └── TimeOfDay/ ← Overwrite files directory + ├── Dawn/ ← Per-period subdirectories + │ ├── MyModPack_CloudShadows_Opacity.json + │ └── MyModPack_Skylighting_MixAmount.json + ├── Sunrise/ + │ └── MyModPack_CloudShadows_Opacity.json + ├── Day/ + │ └── MyModPack_CloudShadows_Opacity.json + ├── Sunset/ + │ └── MyModPack_CloudShadows_Opacity.json + ├── Dusk/ + │ └── MyModPack_Skylighting_MixAmount.json + └── Night/ + ├── MyModPack_CloudShadows_Opacity.json + └── MyModPack_Skylighting_MixAmount.json +``` + +### Interior Only Overwrites + +Place JSON files in `CommunityShaders/SceneSettings/InteriorOnly/`. + +**File format:** + +```json +{ + "_feature": "ScreenSpaceGI", + "EnableGI": false +} +``` + +**Rules:** + +- Each file must contain **exactly one setting** (one non-metadata key). +- The `_feature` field identifies the target feature. If omitted, the system tries to infer it from the filename (the part after the last underscore must match a feature short name). +- Keys starting with `_` are treated as metadata and ignored when extracting the setting. + +### Time of Day Overwrites + +Place JSON files in `CommunityShaders/SceneSettings/TimeOfDay/{PeriodName}/`. + +Each period has its own subdirectory: `Dawn/`, `Sunrise/`, `Day/`, `Sunset/`, `Dusk/`, `Night/`. + +The file format is the same as Interior Only: + +```json +{ + "_feature": "CloudShadows", + "Opacity": 0.3 +} +``` + +To set `Opacity` across multiple periods, create the same-named file in each period's directory with different values: + +``` +TimeOfDay/Dawn/MyMod_CloudShadows_Opacity.json → {"_feature": "CloudShadows", "Opacity": 0.3} +TimeOfDay/Day/MyMod_CloudShadows_Opacity.json → {"_feature": "CloudShadows", "Opacity": 0.8} +TimeOfDay/Sunset/MyMod_CloudShadows_Opacity.json → {"_feature": "CloudShadows", "Opacity": 0.5} +TimeOfDay/Night/MyMod_CloudShadows_Opacity.json → {"_feature": "CloudShadows", "Opacity": 0.1} +``` + +Periods without a file fall back to the feature's baseline value during blending. + +### Overwrite File Format + +| Field | Required? | Description | +| -------------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------- | +| `_feature` | Recommended | The feature's short name (e.g., `"ScreenSpaceGI"`, `"CloudShadows"`). If omitted, inferred from filename. | +| `{settingKey}` | Required (exactly 1) | The JSON key matching the feature's `SaveSettings()` output, with the desired override value. | +| `_*` (any key starting with `_`) | Optional | Metadata fields, ignored by the system. Use for comments, authorship, etc. | + +**Example with metadata:** + +```json +{ + "_feature": "Skylighting", + "_author": "MyModPack", + "_description": "Reduce skylighting at night for darker evenings", + "_version": "1.0", + "MixAmount": 0.2 +} +``` + +### Best Practices for Mod Authors + +1. **One setting per file.** The system enforces this — files with multiple non-metadata keys are skipped. This makes overrides granular: users can delete one specific override without losing others. + +2. **Use descriptive filenames.** The naming convention `{ModName}_{FeatureName}_{SettingKey}.json` is recommended. The system can infer the feature name from the part after the last underscore if `_feature` is missing. + +3. **Test with the UI.** After installing your overwrite files, open the Weather Editor and check the Interior Only or Time of Day panel. Your entries should appear in the "Overwrite Files" section. If they don't, check the CS log for warnings. + +4. **Don't override everything.** Only override settings that genuinely need to change for your visual goal. Leave others at baseline so users' personal settings are respected. + +5. **Ship files through your mod manager.** Overwrite files are just JSON in a folder — they can be installed and uninstalled via any mod manager (MO2, Vortex) like any other loose file. + +6. **You can't target non-whitelisted features.** The file will be loaded but the feature won't pass the whitelist filter, so it will be silently skipped with a log warning. This is intentional — some features aren't safe to hot-swap. + +### Conflict Resolution + +If two mods ship overwrite files for the same feature, setting, and period, the **first one loaded wins** (alphabetical order by filename). Duplicate entries are silently skipped. The second mod's file won't appear in the UI. Use distinctive mod name prefixes in filenames to avoid conflicts. + +If both an overwrite file and a user setting exist for the same feature and key, the **overwrite wins**. User settings are applied first, then overwrites layer on top (last-write-wins). The value editor for that user entry is greyed out in the UI to indicate it's being overridden. + +### Troubleshooting: Overwrite File Not Appearing + +If your file doesn't show up in the Overwrite Files section of the UI, check: + +1. The file is in the correct directory (`SceneSettings/InteriorOnly/` or `SceneSettings/TimeOfDay/{Period}/`). +2. The file has a `.json` extension. +3. The file contains exactly one non-metadata key (keys starting with `_` are metadata). +4. The `_feature` field (or inferred feature name from the filename) matches a loaded, whitelisted feature. +5. You restarted Skyrim after adding the file. +6. Check `CommunityShaders.log` for warnings about skipped or malformed files. + +--- + +## For Developers: Adding Features to the Whitelist + +Adding Scene Settings Manager support for a feature is trivial — it requires **no changes to the feature itself**. + +### Steps + +1. Open `src/SceneSettingsManager.cpp`. +2. Find the appropriate whitelist: + - `GetInteriorRelevantFeatureNames()` for interior support. + - `GetExteriorRelevantFeatureNames()` for time-of-day support. +3. Add the feature's short name to the `unordered_set`: + +```cpp +std::vector SceneSettingsManager::GetExteriorRelevantFeatureNames() +{ + static const std::unordered_set whitelist = { + "CloudShadows", + "ExponentialHeightFog", + "GrassLighting", + "YourNewFeature", // ← add here + }; + return FilterFeatureNames(whitelist); +} +``` + +### Requirements + +Before whitelisting a feature, verify: + +1. **`LoadSettings()` is safe to call at runtime.** It should not trigger shader recompilation, buffer reallocation, or other expensive operations. If it does, the feature may only be safe for the Interior whitelist (called once per cell transition) and not the Exterior/TOD whitelist (called per frame). + +2. **Settings are JSON-serializable.** The feature's `SaveSettings()` and `LoadSettings()` should produce and consume a flat JSON object. Nested objects are not supported by the Scene Settings Manager's per-key override system. + +3. **The feature does not crash when unknown keys are present.** `LoadSettings()` receives the full JSON blob with the patched key — it should gracefully ignore keys it doesn't recognize. + +That's it. No interface to implement, no registration call to add. The Scene Settings Manager picks up the feature automatically through `Feature::FindFeatureByShortName()` and interacts with it purely through the SaveSettings/LoadSettings JSON round-trip. + +--- + +## The Whitelist and Why Features Don't Opt In + +### How the Whitelist Works + +Not every feature is exposed to the Scene Settings Manager. Features must be present in one of two static whitelists: + +**Interior-Relevant Features** — settings that make sense to override in interiors: + +``` +ScreenSpaceGI SubsurfaceScattering LinearLighting +ImageBasedLighting PostProcessing ScreenSpacePointLightShadows +ScreenSpaceRayTracing VanillaFresnel +``` + +**Exterior/Time-of-Day-Relevant Features** — settings that make sense to vary across the day: + +``` +CloudShadows ExponentialHeightFog GrassLighting +ImageBasedLighting LinearLighting Skylighting +SubsurfaceScattering TerrainShadows WetnessEffects +``` + +### Why Not Make It Opt-In Per Feature? + +You might wonder: why not have each feature declare `bool SupportsSceneSettings()` or register itself? There are several reasons: + +1. **Zero feature code changes.** The whole point is that features never need to know the Scene Settings Manager exists. An opt-in system would require every feature to add a method, defeating the decoupled design. + +2. **Centralized safety control.** Some features can't safely have their settings hot-swapped at runtime. For example, `ScreenSpaceGI` is excluded from the exterior/TOD whitelist because its `LoadSettings()` triggers synchronous recompilation of 6 compute shaders — causing massive lag if called every frame during time-of-day blending. A centralized whitelist lets the maintainers exclude problematic features without touching feature code. + +3. **Easy to extend.** Adding a new feature to the whitelist is a single-line diff. There's no API to implement, no registration call to add, no interface to satisfy. When a new feature is developed — even one that hasn't been merged yet — it can be added to the whitelist in the same PR or in a follow-up. + +4. **Even whitelist changes are less work than a coupled system.** In the unlikely event that the whitelist needs updating (a feature is added, removed, or moved between lists), the change is a single line in one file — `SceneSettingsManager.cpp`. In a coupled system where features opt themselves in, the same change would require editing the feature's own code, which is strictly more work. Any change you'd need to make to the whitelist is a change you'd _also_ need to make in a coupled system — except in the coupled version, you'd be editing the feature itself, dealing with its build dependencies, and potentially breaking its tests. The whitelist approach is always equal or less effort. + +### Features That Aren't Whitelisted (and Why) + +Some features are intentionally missing: + +- **ScreenSpaceGI** (exterior whitelist): `LoadSettings()` recompiles 6 compute shaders synchronously. Fine for interior transitions (happens once), but not for per-frame TOD blending. It _is_ on the interior whitelist since interior overrides only apply once on cell transition. +- **VolumetricLighting, LightLimitFix**: Heavy GPU features where hot-swapping settings could cause transient artifacts or require buffer reallocation. +- **TerrainBlending, TerrainVariation**: Terrain features that work at a mesh level and don't benefit from per-frame setting changes. + +As features are improved and their `LoadSettings()` paths become cheaper, they can be promoted to the whitelist with a single-line change. Even in this scenario, the whitelist approach is less work than a coupled system — a coupled system would require the same decision about whether to enable scene settings support, but the change would live inside the feature's own code rather than in a centralized, reviewable list. + +--- + +## Comparison: Scene Settings Manager vs Settings Override Manager + +Community Shaders has two systems that modify feature settings after boot. They serve different purposes and operate at different layers. + +| | Scene Settings Manager | Settings Override Manager | +| ------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------- | +| **Purpose** | Context-dependent overrides (interior/exterior, time of day) | Permanent baseline overrides (mod author presets) | +| **When applied** | At runtime, on cell transitions and per-frame (TOD) | At boot, when features first load settings | +| **Reverts?** | Yes — automatically when context changes | No — permanent until manually reset | +| **Feature coupling** | Zero — uses SaveSettings/LoadSettings JSON round-trip | Zero — merges JSON on top of defaults at boot | +| **Granularity** | Per-setting, per-context (interior, per-period) | Per-setting, per-feature, or global | +| **User editing** | In-game UI (Weather Editor panels) | "Apply Override" button per feature | +| **Mod author format** | One setting per JSON file, in SceneSettings/ subfolders | Multi-setting JSON files in Overrides/ folder | +| **File naming** | `{ModName}_{FeatureName}_{SettingKey}.json` | `{ModName}_{FeatureName}.json` or `{ModName}_Global.json` | +| **Priority** | Higher — applies on top of everything else | Lower — applies at boot, overwritten by scene settings | +| **Blending** | Yes — float values smoothly interpolate between TOD periods | No — values are merged, not blended | +| **Interior/Exterior awareness** | Yes — core feature | No — applies everywhere | + +The Settings Override Manager establishes the **baseline** that the Scene Settings Manager saves and modifies. They're complementary: + +- A mod author might use the **Override Manager** to set `CloudShadows.Opacity` to `0.6` as their recommended default. +- They might then use the **Scene Settings Manager** to set `Opacity` to `0.3` at Night and `0.9` at Day. +- The saved baseline would be `0.6` (from the Override Manager), and TOD would blend between `0.3`, `0.9`, and the baseline for uncovered periods. + +--- + +## FAQ + +### General + +**Q: Do I need to restart Skyrim after adding overwrite files?** +A: Yes. Overwrite files are discovered once during initialization. Changes to overwrite files on disk require a restart. + +**Q: Can I use both Interior Only and Time of Day at the same time?** +A: They're mutually exclusive by context. Interior Only applies in interiors; Time of Day applies in exteriors. You can have entries for both — the system activates the correct one based on where you are. + +**Q: What happens if I'm in an interior and exterior settings are active?** +A: This can't happen. The system detects cell type and automatically deactivates the wrong mode. If Interior Only is active, Time of Day is always off (and vice versa). + +**Q: Do user settings persist between game sessions?** +A: Yes. User settings are saved to `SceneSettings/InteriorOnly.json` and `SceneSettings/TimeOfDay.json` automatically whenever you add, remove, or modify entries. + +### Settings & Values + +**Q: A setting I want to override doesn't appear in the dropdown. Why?** +A: The feature may not be on the whitelist, or the setting may have a type that isn't exposed through `SaveSettings()`. For Time of Day, only float settings appear — integers, booleans, and strings are excluded because they cannot be smoothly transitioned between periods. This applies to both the UI dialog and overwrite files. Check the whitelist in `SceneSettingsManager.cpp`. + +**Q: Can I set a value outside the feature's normal range?** +A: You can enter any value, but the feature will clamp it to its valid range during `LoadSettings()`. The Scene Settings Manager logs a warning when this happens. Check the in-game log if your override seems to have no effect. + +**Q: My overwrite file isn't showing up in the Overwrite Files section.** +A: Check: + +1. The file is in the correct directory (`SceneSettings/InteriorOnly/` or `SceneSettings/TimeOfDay/{Period}/`). +2. The file has a `.json` extension. +3. The file contains exactly one non-metadata key. +4. The `_feature` field (or inferred feature name) matches a loaded, whitelisted feature. +5. You restarted Skyrim after adding the file. +6. Check `CommunityShaders.log` for warnings about skipped files. + +**Q: I have an overwrite and a user setting for the same feature+key. Which wins?** +A: The overwrite wins. User settings are applied first, then overwrites overwrite them (last-write-wins). The user value editor is greyed out for settings that have an active overwrite. + +### Performance + +**Q: Does the Scene Settings Manager affect performance?** +A: The overhead is negligible. Time of Day blending runs per-frame but uses hour throttling (skips recalculation unless the game clock advanced enough) and epsilon caching (skips `LoadSettings()` calls if values haven't meaningfully changed). Interior Only overrides are applied once on cell transition. + +**Q: Why is ScreenSpaceGI excluded from the Time of Day whitelist?** +A: Its `LoadSettings()` triggers synchronous recompilation of 6 compute shaders. This is fine for a one-time interior transition, but would cause massive lag if called every frame during TOD blending. It's on the Interior whitelist for this reason. + +### Mod Authoring + +**Q: Can I ship a single file that overrides multiple settings?** +A: No. Each overwrite file must contain exactly one non-metadata setting. This is by design — it lets users delete individual overrides without losing the rest. Create one file per setting. + +**Q: What if two mods ship overwrite files for the same feature+setting+period?** +A: The first one loaded wins (alphabetical order by filename). Duplicate entries are silently skipped. The second mod's file will not appear. Use distinctive mod prefixes in filenames to avoid conflicts. + +**Q: Can I target features that aren't on the whitelist?** +A: No. The overwrite file will be loaded but the feature won't be found in the whitelist filter, so it will be skipped with a log warning. The whitelist is intentional — some features aren't safe to hot-swap. + +### Development + +**Q: I'm developing a new feature. When should I add it to the whitelist?** +A: Once your feature's `LoadSettings()` is safe to call at runtime without expensive side effects (shader recompilation, buffer reallocation). You can add it to the whitelist in the same PR as the feature, or in a follow-up. No other code changes are needed. + +**Q: Does the Scene Settings Manager work with VR?** +A: Yes. It uses the same SaveSettings/LoadSettings interface, which is VR-agnostic. The cell detection uses `RE::Sky::Mode` which works across all Skyrim variants. + +**Q: How do I test my overwrite files during development?** +A: Place them in the appropriate directory, start Skyrim, and open the Weather Editor. Your entries should appear in the Overwrite Files section. Check the log for any warnings about skipped or malformed files. diff --git a/src/Menu/ThemeManager.h b/src/Menu/ThemeManager.h index 96aa7c56dc..711fbd5a79 100644 --- a/src/Menu/ThemeManager.h +++ b/src/Menu/ThemeManager.h @@ -190,12 +190,25 @@ class ThemeManager static constexpr float AUTOHIDE_EXPAND_DELAY = 0.25f; // Delay before expanding panel (seconds) static constexpr float AUTOHIDE_PANEL_WIDTH_RATIO = 0.2f; // Ratio of window width for panel (2/10) - // Scene settings panel constants - static constexpr float SCENE_VALUE_INPUT_WIDTH = 240.0f; // Width for float/int value inputs - static constexpr float SCENE_DELETE_BUTTON_WIDTH = 40.0f; // Width for delete (X) buttons - static constexpr float SCENE_FEATURE_DROPDOWN_RATIO = 0.45f; // Feature dropdown width ratio - static constexpr float SCENE_SETTING_DROPDOWN_RATIO = 0.6f; // Setting dropdown width ratio + // Scene settings panel constants (multipliers of ImGui::GetFontSize()) + static constexpr float SCENE_VALUE_INPUT_EM = 5.7f; // Width for float/int value inputs + static constexpr float SCENE_DELETE_BUTTON_EM = 1.0f; // Width for delete (X) buttons static constexpr float SCENE_VALUE_LABEL_OFFSET_RATIO = 0.5f; // Value label right-alignment ratio + static constexpr float SCENE_TOD_PARAM_COL_EM = 6.0f; // Parameter column width (TOD table) + static constexpr float SCENE_TOD_PERIOD_COL_EM = 4.3f; // Per-period column width (TOD table) + static constexpr float SCENE_TOD_INACTIVE_ALPHA = 0.5f; // Alpha for inactive TOD periods + static constexpr float SCENE_TOD_ACTIVE_THRESHOLD = 0.01f; // Factor threshold to consider a period active + static constexpr float SCENE_ENTRY_INDENT_EM = 0.4f; // Indent for setting entries under feature headers + static constexpr float SCENE_TOD_FEATURE_TEXT_SCALE = 0.85f; // Smaller text scale for feature names in TOD table + static constexpr float SCENE_TOD_LABEL_EM = 2.6f; // Fixed width for period labels in add-setting rows + static constexpr float SCENE_ADD_BUTTON_EM = 1.5f; // Size for the + add-setting button + static constexpr float SCENE_GROUP_SEPARATOR_ALPHA = 0.4f; // Alpha for light separators between feature groups + static constexpr float SCENE_ADD_DIALOG_WIDTH_EM = 22.0f; // Width of add-setting dialog + static constexpr float SCENE_ADD_DIALOG_HEIGHT_EM = 20.0f; // Max height of add-setting dialog + static constexpr float SCENE_ADD_LIST_HEIGHT_EM = 12.0f; // Height of scrollable setting list in dialog + + /// Resolve a font-relative multiplier to pixels using current font size. + static float Em(float multiplier) { return multiplier * ImGui::GetFontSize(); } // Combo search input constants static constexpr float COMBO_SEARCH_ICON_SIZE = 16.0f; // Icon size for search inside combos diff --git a/src/SceneSettingsManager.cpp b/src/SceneSettingsManager.cpp index 24424b8ff3..127c1d843e 100644 --- a/src/SceneSettingsManager.cpp +++ b/src/SceneSettingsManager.cpp @@ -5,6 +5,7 @@ #include "Utils/FileSystem.h" #include "Utils/Game.h" +#include #include #include #include @@ -16,6 +17,8 @@ std::string SceneSettingsManager::GetSceneTypeName(SceneType type) switch (type) { case SceneType::InteriorOnly: return "InteriorOnly"; + case SceneType::TimeOfDay: + return "TimeOfDay"; default: return "Unknown"; } @@ -31,13 +34,115 @@ std::filesystem::path SceneSettingsManager::GetOverwritesPath(SceneType type) return Util::PathHelpers::GetSceneSettingsPath() / GetSceneTypeName(type); } +// --- Time of Day Period Helpers --- + +const char* SceneSettingsManager::GetPeriodName(TimeOfDayPeriod period) +{ + int idx = static_cast(period); + return (idx >= 0 && idx < kPeriodCount) ? kPeriodNames[idx] : "Unknown"; +} + +SceneSettingsManager::TimeOfDayPeriod SceneSettingsManager::GetPeriodFromName(const std::string& name) +{ + for (int i = 0; i < kPeriodCount; ++i) { + if (name == GetPeriodName(static_cast(i))) + return static_cast(i); + } + return TimeOfDayPeriod::Count; +} + +float SceneSettingsManager::GetCurrentGameHour() +{ + // Prefer calendar (ground truth), which the Weather Editor slider writes to. + // sky->currentGameHour may lag when timeScale is 0 (time paused). + auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); + float hour = 12.0f; + if (calendar && calendar->gameHour) + hour = calendar->gameHour->value; + else if (auto sky = globals::game::sky) + hour = sky->currentGameHour; + + // Normalize into [0, 24) so midnight is 0 and never 24. + hour = std::clamp(hour, 0.0f, 24.0f); + if (hour >= 24.0f) + hour = 0.0f; + return hour; +} + +void SceneSettingsManager::GetTimeOfDayFactors(float outFactors[kPeriodCount]) +{ + for (int i = 0; i < kPeriodCount; ++i) + outFactors[i] = 0.0f; + + float hour = GetCurrentGameHour(); + + // Normalize to [0, 24) — Night wraps, so also check hour + 24 for pre-dawn hours + for (int i = 0; i < kPeriodCount; ++i) { + float start = kPeriodHours[i][0]; + float end = kPeriodHours[i][1]; + float h = (end > 24.0f && hour < start) ? hour + 24.0f : hour; + + if (h >= start && h < end) { + // Inside this period — check if we're in the blend-out zone near the end. + float distFromEnd = end - h; + + if (distFromEnd < kTransitionHours) { + // Blending out to next period + float t = distFromEnd / kTransitionHours; + outFactors[i] = t; + outFactors[(i + 1) % kPeriodCount] = 1.0f - t; + } else { + outFactors[i] = 1.0f; + } + return; + } + } + + // Fallback: noon = Day + outFactors[static_cast(TimeOfDayPeriod::Day)] = 1.0f; +} + +SceneSettingsManager::TimeOfDayPeriod SceneSettingsManager::GetDominantPeriod() +{ + float factors[kPeriodCount]; + GetTimeOfDayFactors(factors); + + int best = 0; + for (int i = 1; i < kPeriodCount; ++i) + if (factors[i] > factors[best]) + best = i; + return static_cast(best); +} + +SceneSettingsManager::TimeOfDayPeriod SceneSettingsManager::GetCurrentPeriod() +{ + float hour = GetCurrentGameHour(); + for (int i = 0; i < kPeriodCount; ++i) { + float start = kPeriodHours[i][0]; + float end = kPeriodHours[i][1]; + float h = (end > 24.0f && hour < start) ? hour + 24.0f : hour; + if (h >= start && h < end) + return static_cast(i); + } + return TimeOfDayPeriod::Day; +} + // --- Feature Metadata (static helpers, zero coupling) --- +static std::vector FilterFeatureNames(const std::unordered_set& whitelist) +{ + auto allNames = Feature::GetLoadedFeatureNames(); + std::vector filtered; + filtered.reserve(allNames.size()); + for (auto& name : allNames) + if (whitelist.contains(name)) + filtered.push_back(std::move(name)); + return filtered; +} + std::vector SceneSettingsManager::GetInteriorRelevantFeatureNames() { - // Features that are relevant for interior-only setting overrides. - // Excludes exterior-only features: terrain, grass, LOD, sky, cloud shadows. - static const std::unordered_set interiorRelevantFeatures = { + static const std::unordered_set whitelist = { "ScreenSpaceGI", "ScreenSpaceShadows", "SubsurfaceScattering", @@ -48,15 +153,31 @@ std::vector SceneSettingsManager::GetInteriorRelevantFeatureNames() "ScreenSpaceRayTracing", "VanillaFresnel", }; + return FilterFeatureNames(whitelist); +} - auto allNames = Feature::GetLoadedFeatureNames(); - std::vector filtered; - filtered.reserve(allNames.size()); - for (auto& name : allNames) { - if (interiorRelevantFeatures.contains(name)) - filtered.push_back(std::move(name)); - } - return filtered; +std::vector SceneSettingsManager::GetExteriorRelevantFeatureNames() +{ + // NOTE: ScreenSpaceGI excluded — its LoadSettings() unconditionally triggers + // synchronous recompilation of 6 compute shaders, causing massive lag. + static const std::unordered_set whitelist = { + "CloudShadows", + "ExponentialHeightFog", + "GrassLighting", + "ImageBasedLighting", + "LinearLighting", + "Skylighting", + "SubsurfaceScattering", + "TerrainShadows", + "WetnessEffects", + }; + return FilterFeatureNames(whitelist); +} + +std::string SceneSettingsManager::GetFeatureDisplayName(const std::string& featureShortName) +{ + auto* feature = Feature::FindFeatureByShortName(featureShortName); + return feature ? feature->GetName() : featureShortName; } std::vector SceneSettingsManager::GetFeatureSettingKeys(const std::string& featureShortName) @@ -105,6 +226,26 @@ SceneSettingsManager::SettingType SceneSettingsManager::DetectSettingType(const return SettingType::Unknown; } +std::vector SceneSettingsManager::GetTransitionableSettingKeys(const std::string& featureShortName) +{ + auto* feature = Feature::FindFeatureByShortName(featureShortName); + if (!feature) + return {}; + + json settings; + feature->SaveSettings(settings); + if (!settings.is_object()) + return {}; + + std::vector keys; + for (auto& [key, val] : settings.items()) { + if (DetectSettingType(val) == SettingType::Float) + keys.push_back(key); + } + std::sort(keys.begin(), keys.end()); + return keys; +} + // --- Generic Entry Management --- std::vector& SceneSettingsManager::GetEntriesMut(SceneType type) @@ -124,6 +265,15 @@ bool SceneSettingsManager::IsEntryActive(const SettingEntry& entry) const return !entry.paused && !IsFeaturePaused(entry.featureShortName); } +bool SceneSettingsManager::HasActiveEntries(SceneType type) const +{ + for (const auto& entry : GetEntries(type)) { + if (IsEntryActive(entry)) + return true; + } + return false; +} + bool SceneSettingsManager::HasEntryFromSource(SceneType type, const std::string& featureShortName, const std::string& settingKey, EntrySource source) const { for (const auto& entry : GetEntries(type)) { @@ -133,6 +283,17 @@ bool SceneSettingsManager::HasEntryFromSource(SceneType type, const std::string& return false; } +bool SceneSettingsManager::HasEntryForPeriod(const std::string& featureShortName, const std::string& settingKey, + TimeOfDayPeriod period, EntrySource source) const +{ + for (const auto& entry : GetEntries(SceneType::TimeOfDay)) { + if (entry.source == source && entry.period == period && + entry.featureShortName == featureShortName && entry.settingKey == settingKey) + return true; + } + return false; +} + bool SceneSettingsManager::HasActiveOverwrite(SceneType type, const std::string& featureShortName, const std::string& settingKey) const { for (const auto& entry : GetEntries(type)) { @@ -143,9 +304,38 @@ bool SceneSettingsManager::HasActiveOverwrite(SceneType type, const std::string& return false; } -void SceneSettingsManager::AddSetting(SceneType type, const std::string& featureShortName, const std::string& settingKey, const json& value) +bool SceneSettingsManager::HasDuplicateEntry(SceneType type, const std::string& featureShortName, + const std::string& settingKey, EntrySource source, TimeOfDayPeriod period) const { - if (HasEntryFromSource(type, featureShortName, settingKey, EntrySource::User)) + if (type == SceneType::TimeOfDay) + return HasEntryForPeriod(featureShortName, settingKey, period, source); + return HasEntryFromSource(type, featureShortName, settingKey, source); +} + +void SceneSettingsManager::AddSetting(SceneType type, const std::string& featureShortName, const std::string& settingKey, const json& value, + TimeOfDayPeriod period) +{ + if (type == SceneType::TimeOfDay) { + // Reject invalid period values (Count is the sentinel, not a real period) + if (period == TimeOfDayPeriod::Count || static_cast(period) < 0 || static_cast(period) >= kPeriodCount) { + logger::warn("[SceneSettings] Rejecting TOD setting with invalid period: {}.{}", featureShortName, settingKey); + return; + } + + // TOD only supports float settings (smooth interpolation) + if (DetectSettingType(value) != SettingType::Float) { + logger::warn("[SceneSettings] Rejecting non-float TOD setting: {}.{}", featureShortName, settingKey); + return; + } + + // Reject non-finite values (NaN/Inf) to prevent unstable blending + if (!std::isfinite(value.get())) { + logger::warn("[SceneSettings] Rejecting non-finite TOD value for {}.{}", featureShortName, settingKey); + return; + } + } + + if (HasDuplicateEntry(type, featureShortName, settingKey, EntrySource::User, period)) return; auto& vec = GetEntriesMut(type); @@ -155,6 +345,7 @@ void SceneSettingsManager::AddSetting(SceneType type, const std::string& feature entry.settingKey = settingKey; entry.value = value; entry.source = EntrySource::User; + entry.period = period; vec.push_back(std::move(entry)); SaveUserSettings(type); ReapplyIfActive(); @@ -168,12 +359,19 @@ void SceneSettingsManager::RemoveSetting(SceneType type, size_t index) auto& entry = vec[index]; if (entry.source == EntrySource::Overwrite && !entry.sourceFilename.empty()) { - auto filepath = GetOverwritesPath(type) / entry.sourceFilename; + // For TimeOfDay overwrites, files are in period subfolders + auto basePath = GetOverwritesPath(type); + auto filepath = (type == SceneType::TimeOfDay && entry.period != TimeOfDayPeriod::Count) ? basePath / GetPeriodName(entry.period) / entry.sourceFilename : basePath / entry.sourceFilename; std::error_code ec; - if (std::filesystem::remove(filepath, ec)) + bool removed = std::filesystem::remove(filepath, ec); + if (removed) { logger::info("[SceneSettings] Deleted overwrite file: {}", filepath.string()); - else - logger::error("[SceneSettings] Failed to delete overwrite file: {} ({})", filepath.string(), ec.message()); + } else if (ec && ec.value() != 0) { + // Real I/O error — keep in-memory entry so the overwrite stays active + logger::error("[SceneSettings] Failed to delete overwrite file: {} ({}) — keeping entry", filepath.string(), ec.message()); + return; + } + // ec.value()==0 && !removed means file didn't exist — safe to drop entry } logger::info("[SceneSettings] Removed {} entry: {}.{} (source={})", GetSceneTypeName(type), @@ -220,17 +418,38 @@ bool SceneSettingsManager::AreAllOverwritesPaused(SceneType type) const void SceneSettingsManager::DeleteAllOverwrites(SceneType type) { auto overwritesPath = GetOverwritesPath(type); - std::error_code ec; auto& vec = GetEntriesMut(type); - for (const auto& entry : vec) { - if (entry.source == EntrySource::Overwrite && !entry.sourceFilename.empty()) - std::filesystem::remove(overwritesPath / entry.sourceFilename, ec); + + // Track which overwrite entries had their files successfully removed (or already absent). + // Entries whose disk delete fails are kept in memory so they stay visible for retry. + std::vector shouldErase(vec.size(), false); + for (size_t i = 0; i < vec.size(); ++i) { + const auto& entry = vec[i]; + if (entry.source != EntrySource::Overwrite) + continue; + if (entry.sourceFilename.empty()) { + // No backing file — safe to drop + shouldErase[i] = true; + continue; + } + auto filepath = (type == SceneType::TimeOfDay && entry.period != TimeOfDayPeriod::Count) ? overwritesPath / GetPeriodName(entry.period) / entry.sourceFilename : overwritesPath / entry.sourceFilename; + std::error_code ec; + bool removed = std::filesystem::remove(filepath, ec); + if (removed || (!ec || ec.value() == 0)) { + // File deleted or already absent — mark for in-memory removal + shouldErase[i] = true; + } else { + logger::error("[SceneSettings] Failed to delete overwrite file: {} ({}) — keeping entry", filepath.string(), ec.message()); + } } - std::erase_if(vec, [](const SettingEntry& e) { - return e.source == EntrySource::Overwrite; - }); + // Erase only entries whose backing files were successfully cleaned up + // (iterate in reverse to preserve index validity) + for (size_t i = vec.size(); i-- > 0;) { + if (shouldErase[i]) + vec.erase(vec.begin() + static_cast(i)); + } allOverwritesPausedMap[type] = false; ReapplyIfActive(); @@ -269,13 +488,37 @@ void SceneSettingsManager::UpdateEntryValue(SceneType type, size_t index, const if (index >= vec.size()) return; - vec[index].value = newValue; + // Enforce float-only invariant for TimeOfDay entries + if (type == SceneType::TimeOfDay) { + if (!newValue.is_number()) { + logger::warn("[SceneSettings] UpdateEntryValue: rejecting non-numeric TOD value for {}.{}", + vec[index].featureShortName, vec[index].settingKey); + return; + } + // Normalize to float so DetectSettingType sees Float, not Integer + float floatVal = newValue.get(); + if (!std::isfinite(floatVal)) { + logger::warn("[SceneSettings] UpdateEntryValue: rejecting non-finite TOD value ({}) for {}.{}", + floatVal, vec[index].featureShortName, vec[index].settingKey); + return; + } + vec[index].value = floatVal; + } else { + vec[index].value = newValue; + } if (!deferSave && vec[index].source == EntrySource::User) SaveUserSettings(type); - // Only apply if no active overwrite covers this key (overwrites take priority) - if (isCurrentlyApplied && !vec[index].paused && !IsFeaturePaused(vec[index].featureShortName)) { + // For TimeOfDay, recompute blended values; for others, apply directly + if (type == SceneType::TimeOfDay) { + if (isTimeOfDayActive) { + // Reset the hour throttle so a user edit (e.g. slider drag) is + // applied immediately rather than waiting for the game clock to advance. + lastBlendedHour = -1.0f; + ApplyTimeOfDayBlended(); + } + } else if (isCurrentlyApplied && !vec[index].paused && !IsFeaturePaused(vec[index].featureShortName)) { if (vec[index].source == EntrySource::Overwrite || !HasActiveOverwrite(type, vec[index].featureShortName, vec[index].settingKey)) ApplySettingToFeature(vec[index]); @@ -301,20 +544,28 @@ RE::BSEventNotifyControl SceneSettingsManager::MenuOpenCloseEventHandler::Proces void SceneSettingsManager::Update() { - // Revert interior overrides on main/loading menu (same check as LinearLighting) - if (isCurrentlyApplied) { - bool isMainOrLoading = globals::game::ui && - (globals::game::ui->IsMenuOpen(RE::MainMenu::MENU_NAME) || globals::game::ui->IsMenuOpen(RE::LoadingMenu::MENU_NAME)); - if (isMainOrLoading) { + // Revert overrides on main/loading menu (same check as LinearLighting) + bool isMainOrLoading = globals::game::ui && + (globals::game::ui->IsMenuOpen(RE::MainMenu::MENU_NAME) || globals::game::ui->IsMenuOpen(RE::LoadingMenu::MENU_NAME)); + + if (isMainOrLoading) { + if (isCurrentlyApplied) { RevertToExteriorSettings(); isCurrentlyApplied = false; } + if (isTimeOfDayActive) + DeactivateTimeOfDay(); + return; } if (queuedCellTransition) { queuedCellTransition = false; OnCellTransition(); } + + // Continuously update time-of-day blended values when exterior + if (isTimeOfDayActive) + UpdateTimeOfDay(); } void SceneSettingsManager::OnCellTransition() @@ -324,36 +575,71 @@ void SceneSettingsManager::OnCellTransition() if (auto sky = globals::game::sky) interior = sky->mode.get() != RE::Sky::Mode::kFull; - if (interior && !isCurrentlyApplied) { - SaveExteriorSettings(SceneType::InteriorOnly); - ApplySettings(SceneType::InteriorOnly); - isCurrentlyApplied = true; - } else if (!interior && isCurrentlyApplied) { - RevertToExteriorSettings(); - isCurrentlyApplied = false; + if (interior) { + // Entering interior: deactivate TOD first, then apply interior overrides + if (isTimeOfDayActive) + DeactivateTimeOfDay(); + if (!isCurrentlyApplied) { + SaveExteriorSettings(SceneType::InteriorOnly); + ApplySettings(SceneType::InteriorOnly); + isCurrentlyApplied = true; + } + } else { + // Entering exterior: revert interior overrides, then activate TOD + if (isCurrentlyApplied) { + RevertToExteriorSettings(); + isCurrentlyApplied = false; + } + if (!isTimeOfDayActive) + ActivateTimeOfDay(); } } void SceneSettingsManager::ReapplyIfActive() { - if (!isCurrentlyApplied) - return; + if (isCurrentlyApplied) { + RevertToExteriorSettings(); + SaveExteriorSettings(SceneType::InteriorOnly); + ApplySettings(SceneType::InteriorOnly); + } - // Full revert + re-apply so removed/paused entries get exterior values restored - RevertToExteriorSettings(); - SaveExteriorSettings(SceneType::InteriorOnly); - ApplySettings(SceneType::InteriorOnly); + // Determine if we're in an exterior right now + bool isExterior = false; + if (auto sky = globals::game::sky) + isExterior = sky->mode.get() == RE::Sky::Mode::kFull; + + bool hasActiveEntries = HasActiveEntries(SceneType::TimeOfDay); + + if (isTimeOfDayActive) { + if (hasActiveEntries) { + // Re-blend with updated entries + RevertTimeOfDayBaseline(); + SaveTimeOfDayBaseline(); + ApplyTimeOfDayBlended(); + } else { + // All entries removed — deactivate + DeactivateTimeOfDay(); + } + } else if (isExterior && hasActiveEntries && !isCurrentlyApplied) { + // User added first TOD entry while already in an exterior — activate now + ActivateTimeOfDay(); + } } bool SceneSettingsManager::IsSettingControlled(const std::string& featureShortName, const std::string& settingKey) const { - if (!isCurrentlyApplied) + if (!isCurrentlyApplied && !isTimeOfDayActive) return false; if (IsFeaturePaused(featureShortName)) return false; // Check all scene types for active overrides for (const auto& [type, vec] : entries) { + // Skip inactive scene types + if (type == SceneType::InteriorOnly && !isCurrentlyApplied) + continue; + if (type == SceneType::TimeOfDay && !isTimeOfDayActive) + continue; for (const auto& entry : vec) { if (entry.paused) continue; @@ -366,10 +652,18 @@ bool SceneSettingsManager::IsSettingControlled(const std::string& featureShortNa bool SceneSettingsManager::HasActiveSettingsForFeature(const std::string& featureShortName) const { - if (!isCurrentlyApplied) + if (!isCurrentlyApplied && !isTimeOfDayActive) return false; for (const auto& [type, vec] : entries) { + // Only report entries from scene types that are currently active. + // InteriorOnly entries should not show as active when in an exterior, + // and TimeOfDay entries should not show as active when in an interior. + if (type == SceneType::InteriorOnly && !isCurrentlyApplied) + continue; + if (type == SceneType::TimeOfDay && !isTimeOfDayActive) + continue; + for (const auto& entry : vec) { if (!entry.paused && entry.featureShortName == featureShortName) return true; @@ -392,16 +686,13 @@ void SceneSettingsManager::SetFeaturePaused(const std::string& featureShortName, // --- Apply / Revert --- -void SceneSettingsManager::SaveExteriorSettings(SceneType type) +void SceneSettingsManager::SavePartialBaseline(SceneType type, std::map& outBaseline) { - // Collect which keys per feature need saving (only the keys we'll override) std::map> keysToSave; - for (const auto& entry : GetEntries(type)) { + for (const auto& entry : GetEntries(type)) if (IsEntryActive(entry)) keysToSave[entry.featureShortName].insert(entry.settingKey); - } - // Save only the specific keys we'll override, not the entire settings blob for (const auto& [shortName, keys] : keysToSave) { auto* feature = Feature::FindFeatureByShortName(shortName); if (!feature) @@ -410,18 +701,21 @@ void SceneSettingsManager::SaveExteriorSettings(SceneType type) json fullSettings; feature->SaveSettings(fullSettings); - // Merge into existing saved settings (don't overwrite keys saved by other scene types) - json& partial = savedExteriorSettings[shortName]; + json& partial = outBaseline[shortName]; if (!partial.is_object()) partial = json::object(); - for (const auto& key : keys) { + for (const auto& key : keys) if (fullSettings.contains(key) && !partial.contains(key)) partial[key] = fullSettings[key]; - } } } +void SceneSettingsManager::SaveExteriorSettings(SceneType type) +{ + SavePartialBaseline(type, savedExteriorSettings); +} + void SceneSettingsManager::ApplySettings(SceneType type) { // Apply user entries first, then overwrites — overwrites win via last-write-wins @@ -437,22 +731,25 @@ void SceneSettingsManager::ApplySettings(SceneType type) } } -void SceneSettingsManager::RevertToExteriorSettings() +void SceneSettingsManager::RevertFromBaseline(std::map& baseline) { - for (const auto& [shortName, savedKeys] : savedExteriorSettings) { + for (const auto& [shortName, savedKeys] : baseline) { auto* feature = Feature::FindFeatureByShortName(shortName); if (!feature) continue; json current; feature->SaveSettings(current); - for (auto& [key, val] : savedKeys.items()) current[key] = val; - feature->LoadSettings(current); } - savedExteriorSettings.clear(); + baseline.clear(); +} + +void SceneSettingsManager::RevertToExteriorSettings() +{ + RevertFromBaseline(savedExteriorSettings); } void SceneSettingsManager::ApplySettingToFeature(const SettingEntry& entry) @@ -485,6 +782,227 @@ void SceneSettingsManager::ApplySettingToFeature(const SettingEntry& entry) } } +// --- Time of Day --- + +void SceneSettingsManager::ActivateTimeOfDay() +{ + if (isTimeOfDayActive || !HasActiveEntries(SceneType::TimeOfDay)) + return; + // TOD and InteriorOnly are mutually exclusive — don't activate TOD while + // interior overrides are applied, as they write to the same feature values. + if (isCurrentlyApplied) { + logger::debug("[SceneSettings] Skipping TOD activation — interior overrides are active"); + return; + } + SaveTimeOfDayBaseline(); + isTimeOfDayActive = true; + lastDominantPeriod = GetDominantPeriod(); + ApplyTimeOfDayBlended(); + logger::info("[SceneSettings] Time of Day activated"); +} + +void SceneSettingsManager::DeactivateTimeOfDay() +{ + if (!isTimeOfDayActive) + return; + RevertTimeOfDayBaseline(); + isTimeOfDayActive = false; + lastDominantPeriod = TimeOfDayPeriod::Count; + logger::info("[SceneSettings] Time of Day deactivated"); +} + +void SceneSettingsManager::SaveTimeOfDayBaseline() +{ + SavePartialBaseline(SceneType::TimeOfDay, savedTimeOfDayBaseline); +} + +void SceneSettingsManager::RevertTimeOfDayBaseline() +{ + RevertFromBaseline(savedTimeOfDayBaseline); + lastAppliedTODFloats.clear(); + lastAppliedTODOther.clear(); + lastBlendedHour = -1.0f; +} + +void SceneSettingsManager::UpdateTimeOfDay() +{ + if (GetEntries(SceneType::TimeOfDay).empty()) { + if (isTimeOfDayActive) + DeactivateTimeOfDay(); + return; + } + // Safety: if interior overrides are somehow active while TOD is running, + // deactivate TOD to prevent conflicting writes to the same feature values. + if (isCurrentlyApplied) { + logger::warn("[SceneSettings] TOD was active while interior overrides applied — deactivating TOD"); + DeactivateTimeOfDay(); + return; + } + ApplyTimeOfDayBlended(); +} + +void SceneSettingsManager::ApplyTimeOfDayBlended() +{ + // Throttle: skip the expensive map rebuild + blend when the game hour + // hasn't moved enough to produce a visible change. On a hot per-frame + // path this avoids thousands of string-keyed map operations per second. + float currentHour = GetCurrentGameHour(); + if (lastBlendedHour >= 0.0f && std::abs(currentHour - lastBlendedHour) < kHourUpdateThreshold) + return; + lastBlendedHour = currentHour; + + float factors[kPeriodCount]; + GetTimeOfDayFactors(factors); + + // Inline dominant period computation to avoid a second GetTimeOfDayFactors call + int bestIdx = 0; + for (int i = 1; i < kPeriodCount; ++i) + if (factors[i] > factors[bestIdx]) + bestIdx = i; + auto dominant = static_cast(bestIdx); + + // Group active entries by feature, using pointers to avoid JSON copies. + struct PeriodSlot + { + const json* value = nullptr; + EntrySource source = EntrySource::User; + }; + // featureShortName -> settingKey -> periodIdx -> resolved slot + std::map>> collapsedSettings; + for (const auto& entry : GetEntries(SceneType::TimeOfDay)) { + if (!IsEntryActive(entry) || entry.period == TimeOfDayPeriod::Count) + continue; + int pIdx = static_cast(entry.period); + auto& slot = collapsedSettings[entry.featureShortName][entry.settingKey][pIdx]; + // First write always wins; Overwrite always supersedes User. + if (!slot.value || (entry.source == EntrySource::Overwrite && slot.source != EntrySource::Overwrite)) { + slot.value = &entry.value; + slot.source = entry.source; + } + } + + // Build the final PeriodRef vectors from the collapsed map + std::map>> featureSettings; + for (auto& [shortName, keyMap] : collapsedSettings) { + for (auto& [key, periodMap] : keyMap) { + auto& refs = featureSettings[shortName][key]; + refs.reserve(periodMap.size()); + for (auto& [pIdx, slot] : periodMap) + refs.push_back({ pIdx, slot.value }); + } + } + + for (auto& [shortName, settingsMap] : featureSettings) { + std::vector> dirtyKeys; + + for (auto& [key, periodRefs] : settingsMap) { + const json* baseline = FindTODBaseline(shortName, key); + if (!baseline) + continue; + + if (DetectSettingType(*baseline) == SettingType::Float) { + if (!baseline->is_number()) { + logger::warn("SceneSettingsManager: TOD baseline for '{}' key '{}' is not numeric — skipping", shortName, key); + continue; + } + float baseVal = baseline->get(); + if (!std::isfinite(baseVal)) + baseVal = 0.0f; + + float result = BlendFloatForPeriods(baseVal, periodRefs, factors, shortName, key); + + // Epsilon comparison — skip if the float barely changed. + // Use find() first to avoid default-inserting 0.0f, which would + // cause the first apply to be skipped when result ≈ 0. + auto& featureFloats = lastAppliedTODFloats[shortName]; + auto floatIt = featureFloats.find(key); + if (floatIt != featureFloats.end() && std::abs(floatIt->second - result) < kBlendEpsilon) + continue; + featureFloats[key] = result; + dirtyKeys.emplace_back(key, result); + } else { + json blendedValue = SnapNonFloatToDominant(*baseline, periodRefs, dominant, shortName, key); + + // Exact comparison for non-float (bools, ints snap — rarely change) + auto& cachedOther = lastAppliedTODOther[shortName][key]; + if (cachedOther == blendedValue) + continue; + cachedOther = blendedValue; + dirtyKeys.emplace_back(key, std::move(blendedValue)); + } + } + + if (dirtyKeys.empty()) + continue; + + // Get FRESH settings from the feature (cheap to_json, keeps non-TOD keys current) + auto* feature = Feature::FindFeatureByShortName(shortName); + if (!feature) + continue; + + json current; + feature->SaveSettings(current); + + // Patch only our TOD-controlled keys into the fresh blob + for (auto& [k, v] : dirtyKeys) + current[k] = std::move(v); + + // Single LoadSettings with up-to-date non-TOD values intact + feature->LoadSettings(current); + } + + lastDominantPeriod = dominant; +} + +const json* SceneSettingsManager::FindTODBaseline(const std::string& shortName, const std::string& key) const +{ + auto baseIt = savedTimeOfDayBaseline.find(shortName); + if (baseIt != savedTimeOfDayBaseline.end() && baseIt->second.contains(key)) + return &baseIt->second[key]; + return nullptr; +} + +float SceneSettingsManager::BlendFloatForPeriods(float baseVal, const std::vector& periodRefs, + const float* factors, const std::string& shortName, const std::string& key) const +{ + float result = 0.0f; + float coveredFactor = 0.0f; + + for (auto& pr : periodRefs) { + float f = factors[pr.periodIdx]; + if (f > 0.0f) { + if (!pr.value->is_number()) { + logger::warn("SceneSettingsManager: TOD period value for '{}' key '{}' is not numeric — falling back to baseline for this period", shortName, key); + continue; // Don't add to coveredFactor — baseline fills in via (1 - coveredFactor) * baseVal + } + float periodVal = pr.value->get(); + if (!std::isfinite(periodVal)) + periodVal = 0.0f; + result += f * periodVal; + coveredFactor += f; + } + } + + return result + (1.0f - coveredFactor) * baseVal; +} + +json SceneSettingsManager::SnapNonFloatToDominant(const json& baseline, const std::vector& periodRefs, + TimeOfDayPeriod dominant, const std::string& shortName, const std::string& key) const +{ + json blendedValue = baseline; + for (auto& pr : periodRefs) { + if (static_cast(pr.periodIdx) == dominant) { + if (pr.value->type() == baseline.type()) { + blendedValue = *pr.value; + } else { + logger::warn("SceneSettingsManager: TOD period value for '{}' key '{}' has type '{}' but baseline expects '{}' — using baseline", + shortName, key, pr.value->type_name(), baseline.type_name()); + } + } + } + return blendedValue; +} + // --- Persistence --- void SceneSettingsManager::SaveUserSettings(SceneType type) @@ -503,6 +1021,8 @@ void SceneSettingsManager::SaveUserSettings(SceneType type) item["setting"] = entry.settingKey; item["value"] = entry.value; item["paused"] = entry.paused; + if (type == SceneType::TimeOfDay && entry.period != TimeOfDayPeriod::Count) + item["period"] = GetPeriodName(entry.period); data.push_back(std::move(item)); } @@ -540,25 +1060,70 @@ void SceneSettingsManager::LoadUserSettings(SceneType type) return; auto& vec = GetEntriesMut(type); + int loadedCount = 0; for (const auto& item : data) { if (!item.contains("feature") || !item.contains("setting") || !item.contains("value")) continue; + // Validate field types before extracting — get() throws on wrong type + if (!item["feature"].is_string() || !item["setting"].is_string()) { + logger::warn("SceneSettingsManager: Skipping {} entry with non-string feature/setting field", typeName); + continue; + } + SettingEntry entry; entry.featureShortName = item["feature"].get(); entry.settingKey = item["setting"].get(); entry.value = item["value"]; - entry.paused = item.value("paused", false); + if (item.contains("paused") && !item["paused"].is_boolean()) { + logger::warn("SceneSettingsManager: '{}' entry {}.{} has non-boolean 'paused' (type: {}) — defaulting to false", + typeName, entry.featureShortName, entry.settingKey, item["paused"].type_name()); + } + entry.paused = (item.contains("paused") && item["paused"].is_boolean()) ? item["paused"].get() : false; entry.source = EntrySource::User; + // Parse period for TimeOfDay entries + if (type == SceneType::TimeOfDay) { + if (!item.contains("period")) { + logger::warn("SceneSettingsManager: TimeOfDay entry for feature '{}' key '{}' is missing 'period' — skipping to avoid ghost entry", + entry.featureShortName, entry.settingKey); + continue; + } + if (!item["period"].is_string()) { + logger::warn("SceneSettingsManager: TimeOfDay entry for feature '{}' key '{}' has non-string 'period' (type: {}) — skipping", + entry.featureShortName, entry.settingKey, item["period"].type_name()); + continue; + } + entry.period = GetPeriodFromName(item["period"].get()); + if (entry.period == TimeOfDayPeriod::Count) { + logger::warn("SceneSettingsManager: TimeOfDay entry for feature '{}' key '{}' has invalid period '{}' — skipping", + entry.featureShortName, entry.settingKey, item["period"].get()); + continue; // Invalid period name + } + + // TOD only supports float settings — use DetectSettingType to match AddSetting/DiscoverOverwrites + if (DetectSettingType(entry.value) != SettingType::Float) { + logger::warn("SceneSettingsManager: TimeOfDay entry for feature '{}' key '{}' has non-float value (type: {}) — skipping", + entry.featureShortName, entry.settingKey, entry.value.type_name()); + continue; + } + if (!std::isfinite(entry.value.get())) { + logger::warn("SceneSettingsManager: TimeOfDay entry for feature '{}' key '{}' has non-finite value — skipping", + entry.featureShortName, entry.settingKey); + continue; + } + } + if (!Feature::FindFeatureByShortName(entry.featureShortName)) continue; - if (!HasEntryFromSource(type, entry.featureShortName, entry.settingKey, EntrySource::User)) - vec.push_back(std::move(entry)); + if (HasDuplicateEntry(type, entry.featureShortName, entry.settingKey, EntrySource::User, entry.period)) + continue; + vec.push_back(std::move(entry)); + loadedCount++; } - logger::info("[SceneSettings] Loaded {} {} user settings", data.size(), typeName); + logger::info("[SceneSettings] Loaded {} {} user settings", loadedCount, typeName); } catch (const std::exception& e) { logger::error("[SceneSettings] Failed to load {} settings: {}", typeName, e.what()); } @@ -566,23 +1131,36 @@ void SceneSettingsManager::LoadUserSettings(SceneType type) void SceneSettingsManager::DiscoverOverwrites(SceneType type) { - auto overwritesPath = GetOverwritesPath(type); - auto typeName = GetSceneTypeName(type); + // TimeOfDay has period subfolders; delegate to a shared loader + if (type == SceneType::TimeOfDay) { + auto basePath = GetOverwritesPath(type); + for (int i = 0; i < kPeriodCount; ++i) { + auto period = static_cast(i); + auto periodPath = basePath / GetPeriodName(period); + DiscoverOverwritesInDir(type, periodPath, period); + } + return; + } - logger::info("[SceneSettings] Discovering {} overwrites in: {}", typeName, overwritesPath.string()); + DiscoverOverwritesInDir(type, GetOverwritesPath(type)); +} + +void SceneSettingsManager::DiscoverOverwritesInDir(SceneType type, const std::filesystem::path& dir, TimeOfDayPeriod period) +{ + auto typeName = GetSceneTypeName(type); std::error_code ec; - if (!std::filesystem::exists(overwritesPath, ec)) { - logger::info("[SceneSettings] Overwrites directory does not exist: {}", overwritesPath.string()); + if (!std::filesystem::exists(dir, ec)) return; - } + + logger::info("[SceneSettings] Discovering {} overwrites in: {}", typeName, dir.string()); auto& vec = GetEntriesMut(type); - int filesFound = 0; - int overwritesLoaded = 0; - for (const auto& dirEntry : std::filesystem::directory_iterator(overwritesPath, ec)) { + int filesFound = 0, overwritesLoaded = 0; + + for (const auto& dirEntry : std::filesystem::directory_iterator(dir, ec)) { if (ec) { - logger::error("[SceneSettings] Error iterating {} overwrites directory: {}", typeName, ec.message()); + logger::error("[SceneSettings] Error iterating {} overwrites: {}", typeName, ec.message()); break; } if (!dirEntry.is_regular_file() || dirEntry.path().extension() != ".json") @@ -605,31 +1183,24 @@ void SceneSettingsManager::DiscoverOverwrites(SceneType type) json data = json::parse(file); - // Resolve feature name: explicit _feature field, or infer from filename (ModName_FeatureName.json) + // Resolve feature name: explicit _feature field, or infer from filename std::string featureShortName = data.value("_feature", ""); if (featureShortName.empty()) { auto stem = dirEntry.path().stem().string(); auto lastUnderscore = stem.rfind('_'); if (lastUnderscore != std::string::npos) { auto candidate = stem.substr(lastUnderscore + 1); - if (Feature::FindFeatureByShortName(candidate)) { + if (Feature::FindFeatureByShortName(candidate)) featureShortName = candidate; - logger::info("[SceneSettings] Inferred feature '{}' from filename '{}'", featureShortName, filename); - } } } - if (featureShortName.empty()) { - logger::warn("[SceneSettings] Skipping overwrite '{}': no _feature field and could not infer feature from filename", filename); - continue; - } - - if (!Feature::FindFeatureByShortName(featureShortName)) { - logger::warn("[SceneSettings] Skipping overwrite '{}': feature '{}' not found", filename, featureShortName); + if (featureShortName.empty() || !Feature::FindFeatureByShortName(featureShortName)) { + logger::warn("[SceneSettings] Skipping overwrite '{}': feature not resolved", filename); continue; } - // Count non-metadata settings — must have exactly one + // Exactly one non-metadata setting per file int settingCount = 0; std::string settingKey; json settingValue; @@ -648,18 +1219,27 @@ void SceneSettingsManager::DiscoverOverwrites(SceneType type) continue; } - if (HasEntryFromSource(type, featureShortName, settingKey, EntrySource::Overwrite)) { - logger::warn("[SceneSettings] Skipping overwrite '{}': duplicate overwrite for {}.{}", filename, featureShortName, settingKey); + // TOD only supports float settings (smooth interpolation) + if (type == SceneType::TimeOfDay && DetectSettingType(settingValue) != SettingType::Float) { + logger::warn("[SceneSettings] Skipping overwrite '{}': non-float setting '{}' not allowed in Time of Day", filename, settingKey); + continue; + } + if (type == SceneType::TimeOfDay && !std::isfinite(settingValue.get())) { + logger::warn("[SceneSettings] Skipping overwrite '{}': non-finite value for setting '{}'", filename, settingKey); continue; } + // Duplicate check + if (HasDuplicateEntry(type, featureShortName, settingKey, EntrySource::Overwrite, period)) + continue; + SettingEntry entry; entry.featureShortName = featureShortName; entry.settingKey = settingKey; entry.value = settingValue; entry.source = EntrySource::Overwrite; entry.sourceFilename = filename; - + entry.period = period; vec.push_back(std::move(entry)); overwritesLoaded++; @@ -669,12 +1249,14 @@ void SceneSettingsManager::DiscoverOverwrites(SceneType type) } } - logger::info("[SceneSettings] {} overwrite discovery complete. Found {} JSON files, loaded {} overwrites", typeName, filesFound, overwritesLoaded); + if (filesFound > 0) + logger::info("[SceneSettings] {} overwrite scan: {} files, {} loaded", typeName, filesFound, overwritesLoaded); } void SceneSettingsManager::LoadAll() { DiscoverOverwrites(SceneType::InteriorOnly); LoadUserSettings(SceneType::InteriorOnly); - // Future: add other scene types here + DiscoverOverwrites(SceneType::TimeOfDay); + LoadUserSettings(SceneType::TimeOfDay); } diff --git a/src/SceneSettingsManager.h b/src/SceneSettingsManager.h index 2d0853186a..78d6813c2c 100644 --- a/src/SceneSettingsManager.h +++ b/src/SceneSettingsManager.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -13,8 +14,9 @@ using json = nlohmann::json; struct Feature; -/// Manages scene-specific setting overrides (Interior Only, TimeOfDay, WeatherSpecific). -/// Zero coupling to individual features — operates via JSON round-trip through Feature::SaveSettings/LoadSettings. +/// Manages scene-specific setting overrides (Interior Only, TimeOfDay). +/// Applies overrides via Feature::SaveSettings/LoadSettings JSON round-trips with +/// epsilon-cached blending to minimise redundant updates during time-of-day transitions. /// Event-driven: cell transitions detected via MenuOpenCloseEvent, mutations applied immediately. class SceneSettingsManager { @@ -29,10 +31,44 @@ class SceneSettingsManager enum class SceneType { - InteriorOnly - // Future: TimeOfDay, WeatherSpecific + InteriorOnly, + TimeOfDay }; + // --- Time of Day Periods --- + + enum class TimeOfDayPeriod + { + Dawn = 0, + Sunrise, + Day, + Sunset, + Dusk, + Night, + Count + }; + + /// Number of time-of-day periods (avoids repeated static_cast). + static constexpr int kPeriodCount = static_cast(TimeOfDayPeriod::Count); + + /// Display names for each period — must match TimeOfDayPeriod order. + static constexpr std::array kPeriodNames = { + "Dawn", "Sunrise", "Day", "Sunset", "Dusk", "Night" + }; + + /// Hour boundaries for each period [start, end). Night wraps around midnight (21–28 i.e. 21–4). + static constexpr float kPeriodHours[kPeriodCount][2] = { + { 4.0f, 6.0f }, // Dawn + { 6.0f, 8.0f }, // Sunrise + { 8.0f, 17.0f }, // Day + { 17.0f, 19.0f }, // Sunset + { 19.0f, 21.0f }, // Dusk + { 21.0f, 28.0f } // Night (wraps past midnight) + }; + + /// Transition blend zone in hours at each period boundary. + static constexpr float kTransitionHours = 0.5f; + // --- Event Handler --- /// Listens for LoadingMenu close to detect cell transitions. @@ -76,7 +112,8 @@ class SceneSettingsManager json value; // Override value (bool, float, int, etc.) bool paused = false; // Temporarily disabled EntrySource source = EntrySource::User; - std::string sourceFilename; // For overwrites: the filename it came from + std::string sourceFilename; // For overwrites: the filename it came from + TimeOfDayPeriod period = TimeOfDayPeriod::Count; // Which period this entry belongs to (TimeOfDay only) }; // --- Generic Entry Management (scene-type agnostic) --- @@ -85,11 +122,17 @@ class SceneSettingsManager bool HasEntryFromSource(SceneType type, const std::string& featureShortName, const std::string& settingKey, EntrySource source) const; bool HasActiveOverwrite(SceneType type, const std::string& featureShortName, const std::string& settingKey) const; - void AddSetting(SceneType type, const std::string& featureShortName, const std::string& settingKey, const json& value); + /// Add a setting. For TimeOfDay entries, specify the target period. + void AddSetting(SceneType type, const std::string& featureShortName, const std::string& settingKey, const json& value, + TimeOfDayPeriod period = TimeOfDayPeriod::Count); void RemoveSetting(SceneType type, size_t index); void TogglePauseEntry(SceneType type, size_t index); void UpdateEntryValue(SceneType type, size_t index, const json& newValue, bool deferSave = false); + /// Check if an entry already exists for a specific period (TimeOfDay) + bool HasEntryForPeriod(const std::string& featureShortName, const std::string& settingKey, + TimeOfDayPeriod period, EntrySource source) const; + void SetAllOverwritesPaused(SceneType type, bool paused); bool AreAllOverwritesPaused(SceneType type) const; bool HasOverwriteEntries(SceneType type) const; @@ -101,12 +144,10 @@ class SceneSettingsManager // --- Scene Application --- - /// Called each frame from State::Draw() to process deferred cell transitions. - /// Cell data is not yet available when the LoadingMenu close event fires, - /// so we defer the actual transition check to the next rendered frame. + /// Called every frame from State::Update(). void Update(); - /// Called by Update() when a deferred cell transition is pending. + /// Called by MenuOpenCloseEventHandler when a cell transition is detected. void OnCellTransition(); /// Check if a specific feature+setting is currently being overridden by any active scene setting @@ -134,14 +175,34 @@ class SceneSettingsManager static std::filesystem::path GetSettingsFilePath(SceneType type); static std::filesystem::path GetOverwritesPath(SceneType type); + // --- Time of Day Helpers (public for UI) --- + + static const char* GetPeriodName(TimeOfDayPeriod period); + static TimeOfDayPeriod GetPeriodFromName(const std::string& name); + static float GetCurrentGameHour(); + void GetTimeOfDayFactors(float outFactors[static_cast(TimeOfDayPeriod::Count)]); + TimeOfDayPeriod GetDominantPeriod(); + + /// Returns the period whose hour range contains the current game hour. + static TimeOfDayPeriod GetCurrentPeriod(); + // --- Feature Metadata --- /// Get loaded feature short names filtered to only interior-relevant features static std::vector GetInteriorRelevantFeatureNames(); + /// Get loaded feature short names filtered to exterior/TOD-relevant features + static std::vector GetExteriorRelevantFeatureNames(); + + /// Get the display name for a feature (e.g. "Screen Space GI" from "ScreenSpaceGI") + static std::string GetFeatureDisplayName(const std::string& featureShortName); + /// Get setting keys for a feature by JSON round-tripping its current settings static std::vector GetFeatureSettingKeys(const std::string& featureShortName); + /// Get only float setting keys that can be smoothly transitioned in Time of Day + static std::vector GetTransitionableSettingKeys(const std::string& featureShortName); + /// Get current value of a specific setting from a feature static json GetFeatureSettingValue(const std::string& featureShortName, const std::string& settingKey); @@ -168,12 +229,38 @@ class SceneSettingsManager std::map allUserPausedMap; // --- Interior state tracking --- - bool isCurrentlyApplied = false; bool queuedCellTransition = false; + bool isCurrentlyApplied = false; // Stored exterior settings per-feature (only the overridden keys) std::map savedExteriorSettings; + // --- Time of Day state --- + bool isTimeOfDayActive = false; + TimeOfDayPeriod lastDominantPeriod = TimeOfDayPeriod::Count; + + /// Baseline settings saved before TOD activation, for reverting on deactivate. + std::map savedTimeOfDayBaseline; + + /// Cache of last-applied blended float values per feature+key. + /// Used with epsilon comparison to skip redundant LoadSettings calls. + std::map> lastAppliedTODFloats; + + /// Cache of last-applied non-float values per feature+key. + std::map> lastAppliedTODOther; + + /// Float epsilon — changes smaller than this skip the LoadSettings call. + static constexpr float kBlendEpsilon = 1e-3f; + + /// Cached game hour from last blend update. Used to skip redundant + /// per-frame map rebuilds when the game hour hasn't moved enough. + float lastBlendedHour = -1.0f; + + /// Minimum game-hour delta before re-running the blend. At default + /// timescale (20×) this equals ~0.36 real seconds — imperceptible yet + /// saves 98%+ of per-frame map construction work. + static constexpr float kHourUpdateThreshold = 1e-3f; + // --- Pause states --- std::map featurePauseStates; @@ -182,10 +269,51 @@ class SceneSettingsManager // --- Helpers --- std::vector& GetEntriesMut(SceneType type); bool IsEntryActive(const SettingEntry& entry) const; + bool HasActiveEntries(SceneType type) const; + bool HasDuplicateEntry(SceneType type, const std::string& featureShortName, const std::string& settingKey, + EntrySource source, TimeOfDayPeriod period = TimeOfDayPeriod::Count) const; void ReapplyIfActive(); void ApplySettings(SceneType type); + void SavePartialBaseline(SceneType type, std::map& outBaseline); + void RevertFromBaseline(std::map& baseline); void RevertToExteriorSettings(); void SaveExteriorSettings(SceneType type); static void ApplySettingToFeature(const SettingEntry& entry); + + // --- Time of Day lifecycle --- + void UpdateTimeOfDay(); + void ActivateTimeOfDay(); + void DeactivateTimeOfDay(); + void SaveTimeOfDayBaseline(); + void RevertTimeOfDayBaseline(); + void ApplyTimeOfDayBlended(); + + // --- Time of Day blending helpers --- + + /// Lightweight ref to a TOD period entry, used during blending + /// to avoid copying JSON values from the entry storage. + struct PeriodRef + { + int periodIdx; + const json* value; + }; + + /// Look up the saved baseline value for a feature+key pair. + /// @return Pointer to the baseline JSON, or nullptr if not found. + const json* FindTODBaseline(const std::string& shortName, const std::string& key) const; + + /// Compute a weighted blend of float values across active TOD periods. + /// Uncovered periods fall back to @p baseVal so the sum is always complete. + float BlendFloatForPeriods(float baseVal, const std::vector& periodRefs, + const float* factors, const std::string& shortName, const std::string& key) const; + + /// Select the non-float value from the dominant period with type validation. + /// Falls back to @p baseline if no matching period or on type mismatch. + json SnapNonFloatToDominant(const json& baseline, const std::vector& periodRefs, + TimeOfDayPeriod dominant, const std::string& shortName, const std::string& key) const; + + // --- Overwrite discovery helper --- + void DiscoverOverwritesInDir(SceneType type, const std::filesystem::path& dir, + TimeOfDayPeriod period = TimeOfDayPeriod::Count); }; diff --git a/src/WeatherEditor/EditorWindow.cpp b/src/WeatherEditor/EditorWindow.cpp index 9979154a3c..bba2169333 100644 --- a/src/WeatherEditor/EditorWindow.cpp +++ b/src/WeatherEditor/EditorWindow.cpp @@ -4,7 +4,9 @@ #include "InteriorOnlyPanel.h" #include "Menu.h" #include "PaletteWindow.h" +#include "SceneSettingsUI.h" #include "State.h" +#include "TimeOfDayPanel.h" #include "Utils/UI.h" #include "Weather/LightingTemplateWidget.h" #include "WeatherUtils.h" @@ -204,7 +206,7 @@ void EditorWindow::ShowObjectsWindow() ImGui::Spacing(); // List of categories - const char* categories[] = { "Weather", "ImageSpace", "Lighting Template", "Cell Lighting", "Volumetric Lighting", "Shader Particle Geometry", "Lens Flare", "Visual Effect", "Interior Only" }; + const char* categories[] = { "Weather", "ImageSpace", "Lighting Template", "Cell Lighting", "Volumetric Lighting", "Shader Particle Geometry", "Lens Flare", "Visual Effect", "Interior Only", "Time of Day" }; for (int i = 0; i < IM_ARRAYSIZE(categories); ++i) { // Highlight the selected category if (ImGui::Selectable(categories[i], m_selectedCategory == categories[i])) { @@ -219,15 +221,11 @@ void EditorWindow::ShowObjectsWindow() // Right column: Objects ImGui::TableSetColumnIndex(1); + // Interior Only / Time of Day categories have their own panels if (ImGui::BeginChild("##ObjectsContent", { 0, 0 }, ImGuiChildFlags_Border, kStickyHeaderFlags)) { - // Interior Only category has its own panel - if (m_selectedCategory == "Interior Only") { - InteriorOnlyPanel::Draw(); - ImGui::EndChild(); - ImGui::EndTable(); - ImGui::End(); + if (SceneSettingsUI::DrawCategoryPanel("Interior Only", m_selectedCategory, InteriorOnlyPanel::Draw) || + SceneSettingsUI::DrawCategoryPanel("Time of Day", m_selectedCategory, TimeOfDayPanel::Draw)) return; - } // Display current active weather auto sky = globals::game::sky; diff --git a/src/WeatherEditor/InteriorOnlyPanel.cpp b/src/WeatherEditor/InteriorOnlyPanel.cpp index 11920707cb..75e25bb75e 100644 --- a/src/WeatherEditor/InteriorOnlyPanel.cpp +++ b/src/WeatherEditor/InteriorOnlyPanel.cpp @@ -2,9 +2,8 @@ #include "../Globals.h" #include "../Menu.h" -#include "../Menu/ThemeManager.h" #include "../SceneSettingsManager.h" -#include "EditorWindow.h" +#include "SceneSettingsUI.h" namespace InteriorOnlyPanel { @@ -12,210 +11,12 @@ namespace InteriorOnlyPanel using EntrySource = SceneSettingsManager::EntrySource; static constexpr auto kSceneType = SceneType::InteriorOnly; - // Layout constants from centralized theme - using C = ThemeManager::Constants; - - // Persistent state for the "Add Setting" workflow - static int selectedFeatureIdx = -1; - static int selectedSettingIdx = -1; - static std::vector cachedFeatureNames; - static std::vector cachedSettingKeys; - - // Confirmation popups - static Util::ConfirmationPopup deleteAllOverwritesPopup{ - "Delete All Overwrites?", + // Shared UI state + static SceneSettingsUI::AddSettingState addState; + static SceneSettingsUI::PopupState popups{ "Are you sure you want to delete all interior-only overwrite files?\nThis cannot be undone.", - "Delete All" - }; - - static Util::ConfirmationPopup deleteSingleOverwritePopup{ - "Delete Overwrite File?", - "", - "Delete" + "Are you sure you want to remove all user-added interior-only settings?" }; - static size_t pendingDeleteIndex = SIZE_MAX; - - static Util::ConfirmationPopup deleteAllUserPopup{ - "Delete All User Settings?", - "Are you sure you want to remove all user-added interior-only settings?", - "Delete All" - }; - - void DrawAddSettingUI() - { - auto* manager = SceneSettingsManager::GetSingleton(); - - ImGui::Spacing(); - - // Feature dropdown - if (cachedFeatureNames.empty()) - cachedFeatureNames = SceneSettingsManager::GetInteriorRelevantFeatureNames(); - - const char* featurePreview = (selectedFeatureIdx >= 0 && selectedFeatureIdx < static_cast(cachedFeatureNames.size())) ? cachedFeatureNames[selectedFeatureIdx].c_str() : "Select Feature..."; - - ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * C::SCENE_FEATURE_DROPDOWN_RATIO); - if (ImGui::BeginCombo("##FeatureSelect", featurePreview)) { - for (int i = 0; i < static_cast(cachedFeatureNames.size()); ++i) { - bool selected = (i == selectedFeatureIdx); - if (ImGui::Selectable(cachedFeatureNames[i].c_str(), selected)) { - selectedFeatureIdx = i; - selectedSettingIdx = -1; - cachedSettingKeys = SceneSettingsManager::GetFeatureSettingKeys(cachedFeatureNames[i]); - } - if (selected) - ImGui::SetItemDefaultFocus(); - } - ImGui::EndCombo(); - } - - ImGui::SameLine(); - - // Setting dropdown (only if feature is selected) - { - auto _ = Util::DisableGuard(selectedFeatureIdx < 0); - - const char* settingPreview = (selectedSettingIdx >= 0 && selectedSettingIdx < static_cast(cachedSettingKeys.size())) ? cachedSettingKeys[selectedSettingIdx].c_str() : "Select Setting..."; - - ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * C::SCENE_SETTING_DROPDOWN_RATIO); - if (ImGui::BeginCombo("##SettingSelect", settingPreview)) { - for (int i = 0; i < static_cast(cachedSettingKeys.size()); ++i) { - bool selected = (i == selectedSettingIdx); - bool alreadyAdded = selectedFeatureIdx >= 0 && - manager->HasEntryFromSource(kSceneType, cachedFeatureNames[selectedFeatureIdx], cachedSettingKeys[i], EntrySource::User); - if (alreadyAdded) { - ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyle().Colors[ImGuiCol_TextDisabled]); - ImGui::Selectable(cachedSettingKeys[i].c_str(), false, ImGuiSelectableFlags_Disabled); - ImGui::PopStyleColor(); - } else { - if (ImGui::Selectable(cachedSettingKeys[i].c_str(), selected)) - selectedSettingIdx = i; - } - - if (selected) - ImGui::SetItemDefaultFocus(); - } - ImGui::EndCombo(); - } - } - - ImGui::SameLine(); - - // Add button - bool canAdd = selectedFeatureIdx >= 0 && selectedSettingIdx >= 0; - { - auto _ = Util::DisableGuard(!canAdd); - if (ImGui::Button("Add")) { - auto& featureName = cachedFeatureNames[selectedFeatureIdx]; - auto& settingKey = cachedSettingKeys[selectedSettingIdx]; - auto currentValue = SceneSettingsManager::GetFeatureSettingValue(featureName, settingKey); - - manager->AddSetting(kSceneType, featureName, settingKey, currentValue); - selectedSettingIdx = -1; - return; - } - } - } - - void DrawSettingEntry(size_t index) - { - auto* manager = SceneSettingsManager::GetSingleton(); - const auto& entries = manager->GetEntries(kSceneType); - if (index >= entries.size()) - return; - - const auto& entry = entries[index]; - - ImGui::PushID(static_cast(index)); - - // Feature.Setting label - float availWidth = ImGui::GetContentRegionAvail().x; - ImGui::Text("%s.%s", entry.featureShortName.c_str(), entry.settingKey.c_str()); - - // Value display/editor on same line (right-aligned) - ImGui::SameLine(availWidth * C::SCENE_VALUE_LABEL_OFFSET_RATIO); - - bool isOverwrite = entry.source == EntrySource::Overwrite; - auto type = SceneSettingsManager::DetectSettingType(entry.value); - - // Overwrites are read-only; user entries overridden by an active overwrite are also disabled - bool readOnly = isOverwrite || - manager->HasActiveOverwrite(kSceneType, entry.featureShortName, entry.settingKey); - - if (readOnly) - ImGui::BeginDisabled(); - - switch (type) { - case SceneSettingsManager::SettingType::Boolean: - { - bool val = entry.value.is_boolean() ? entry.value.get() : (entry.value.get() != 0); - if (ImGui::Checkbox("##val", &val)) { - // Preserve original JSON type (integer for GPU constant buffer settings, boolean otherwise) - if (entry.value.is_boolean()) - manager->UpdateEntryValue(kSceneType, index, val); - else - manager->UpdateEntryValue(kSceneType, index, val ? 1 : 0); - } - } - break; - case SceneSettingsManager::SettingType::Float: - { - float val = entry.value.get(); - ImGui::SetNextItemWidth(C::SCENE_VALUE_INPUT_WIDTH); - if (ImGui::InputFloat("##val", &val, 0.01f, 0.1f, "%.3f")) - manager->UpdateEntryValue(kSceneType, index, val, true); - if (ImGui::IsItemDeactivatedAfterEdit()) - manager->SaveUserSettings(kSceneType); - } - break; - case SceneSettingsManager::SettingType::Integer: - { - int val = entry.value.get(); - ImGui::SetNextItemWidth(C::SCENE_VALUE_INPUT_WIDTH); - if (ImGui::InputInt("##val", &val)) - manager->UpdateEntryValue(kSceneType, index, val, true); - if (ImGui::IsItemDeactivatedAfterEdit()) - manager->SaveUserSettings(kSceneType); - } - break; - default: - ImGui::TextDisabled("(unsupported type)"); - break; - } - - if (readOnly) - ImGui::EndDisabled(); - - // Active/Pause toggle - ImGui::SameLine(); - bool active = !entry.paused; - if (Util::FeatureToggle("##active", &active)) - manager->TogglePauseEntry(kSceneType, index); - if (auto _tt = Util::HoverTooltipWrapper()) - ImGui::Text(entry.paused ? "Paused - click to resume" : "Active - click to pause"); - - // Delete button - ImGui::SameLine(); - { - auto styledButton = Util::ErrorButtonStyle(); - if (ImGui::Button("X", ImVec2(C::SCENE_DELETE_BUTTON_WIDTH, 0))) { - if (entry.source == EntrySource::Overwrite) { - pendingDeleteIndex = index; - deleteSingleOverwritePopup.message = std::format( - "Delete overwrite file '{}'?\nThis will permanently remove the file from disk.", - entry.sourceFilename); - deleteSingleOverwritePopup.Request(); - } else { - manager->RemoveSetting(kSceneType, index); - ImGui::PopID(); - return; - } - } - } - if (auto _tt = Util::HoverTooltipWrapper()) - ImGui::Text(entry.source == EntrySource::Overwrite ? "Delete overwrite file from disk" : "Remove this setting"); - - ImGui::PopID(); - } void Draw() { @@ -223,35 +24,14 @@ namespace InteriorOnlyPanel const auto& entries = manager->GetEntries(kSceneType); auto& theme = globals::menu->GetSettings().Theme; - // Header ImGui::Text("Interior Only Settings"); + SceneSettingsUI::RightAlignNextButton(); + SceneSettingsUI::DrawAddSettingButton(kSceneType, addState); ImGui::Separator(); - // Draw confirmation popups - if (deleteAllOverwritesPopup.Draw()) - manager->DeleteAllOverwrites(kSceneType); - - if (deleteSingleOverwritePopup.Draw()) { - if (pendingDeleteIndex < entries.size()) - manager->RemoveSetting(kSceneType, pendingDeleteIndex); - pendingDeleteIndex = SIZE_MAX; - } - - if (deleteAllUserPopup.Draw()) - manager->DeleteAllUserSettings(kSceneType); - - // Add setting UI (always visible) - DrawAddSettingUI(); - - // Collect indices by source - std::vector overwriteIndices, userIndices; - for (size_t i = 0; i < entries.size(); ++i) { - if (entries[i].source == EntrySource::Overwrite) - overwriteIndices.push_back(i); - else - userIndices.push_back(i); - } + SceneSettingsUI::DrawPopups(kSceneType, popups); + SceneSettingsUI::DrawAddSettingDialog(kSceneType, addState); // Empty state if (entries.empty()) { @@ -259,7 +39,7 @@ namespace InteriorOnlyPanel ImGui::TextColored(theme.StatusPalette.Disable, "No interior-only settings configured."); ImGui::TextColored(theme.StatusPalette.Disable, - "Click + to add settings that will only apply in interiors."); + "Use the + button above to add overrides."); ImGui::Spacing(); ImGui::TextWrapped( "Settings added here will override feature defaults when you enter an interior cell. " @@ -267,51 +47,6 @@ namespace InteriorOnlyPanel return; } - // --- Overwrite Files Section --- - if (!overwriteIndices.empty()) { - ImGui::Spacing(); - ImGui::TextColored(theme.StatusPalette.InfoColor, "Overwrite Files"); - ImGui::SameLine(); - - bool allPaused = manager->AreAllOverwritesPaused(kSceneType); - if (ImGui::SmallButton(allPaused ? "Unpause All" : "Pause All")) - manager->SetAllOverwritesPaused(kSceneType, !allPaused); - - ImGui::SameLine(); - if (ImGui::SmallButton("Delete All")) - deleteAllOverwritesPopup.Request(); - - ImGui::Separator(); - - for (auto i : overwriteIndices) - DrawSettingEntry(i); - } - - // --- User Settings Section (header only shown when overwrites also present) --- - if (!userIndices.empty()) { - if (!overwriteIndices.empty()) { - ImGui::Spacing(); - ImGui::TextColored(theme.FeatureHeading.ColorDefault, "User Settings"); - ImGui::SameLine(); - } - - bool allUserPaused = manager->AreAllUserPaused(kSceneType); - if (ImGui::SmallButton(allUserPaused ? "Unpause All##user" : "Pause All##user")) - manager->SetAllUserPaused(kSceneType, !allUserPaused); - - ImGui::SameLine(); - if (ImGui::SmallButton("Delete All##user")) - deleteAllUserPopup.Request(); - - if (!overwriteIndices.empty()) - ImGui::Separator(); - - for (auto i : userIndices) { - // Re-check bounds: a prior inline deletion may have shrunk the entries vector - if (i >= manager->GetEntries(kSceneType).size()) - break; - DrawSettingEntry(i); - } - } + SceneSettingsUI::DrawEntrySections(kSceneType, popups); } } diff --git a/src/WeatherEditor/InteriorOnlyPanel.h b/src/WeatherEditor/InteriorOnlyPanel.h index a282feb358..26e898e822 100644 --- a/src/WeatherEditor/InteriorOnlyPanel.h +++ b/src/WeatherEditor/InteriorOnlyPanel.h @@ -1,17 +1,9 @@ #pragma once -#include "Utils/UI.h" - /// UI panel for managing Interior Only scene settings within the Weather Editor. -/// Renders the list of entries with add/pause/delete controls. +/// Rendering delegates to shared SceneSettingsUI utilities. namespace InteriorOnlyPanel { /// Draw the full Interior Only settings panel (right column of the objects window) void Draw(); - - /// Draw the "add new setting" UI (feature dropdown + setting dropdown + confirm) - void DrawAddSettingUI(); - - /// Draw a single setting entry row - void DrawSettingEntry(size_t index); } diff --git a/src/WeatherEditor/SceneSettingsUI.cpp b/src/WeatherEditor/SceneSettingsUI.cpp new file mode 100644 index 0000000000..5d2ab5c9c5 --- /dev/null +++ b/src/WeatherEditor/SceneSettingsUI.cpp @@ -0,0 +1,393 @@ +#include "SceneSettingsUI.h" + +#include +#include + +#include "../Globals.h" +#include "../Menu.h" +#include "../Menu/ThemeManager.h" +#include "../SceneSettingsManager.h" + +namespace SceneSettingsUI +{ + using C = ThemeManager::Constants; + + // --- Feature name resolution by scene type --- + + static std::vector GetFeatureNamesForType(SceneType type) + { + return (type == SceneType::InteriorOnly) ? SceneSettingsManager::GetInteriorRelevantFeatureNames() : SceneSettingsManager::GetExteriorRelevantFeatureNames(); + } + + // --- Duplicate checking by scene type --- + + static bool IsAlreadyAdded(SceneType type, const std::string& feature, const std::string& key, Period period) + { + auto* manager = SceneSettingsManager::GetSingleton(); + return (type == SceneType::TimeOfDay) ? manager->HasEntryForPeriod(feature, key, period, EntrySource::User) : manager->HasEntryFromSource(type, feature, key, EntrySource::User); + } + + // --- Shared Drawing --- + + void DrawAddSettingButton([[maybe_unused]] SceneType type, AddSettingState& state, [[maybe_unused]] Period period, const char* labelPrefix, [[maybe_unused]] bool addToAllPeriods) + { + if (labelPrefix) { + ImGui::Text("%s", labelPrefix); + ImGui::SameLine(C::Em(C::SCENE_TOD_LABEL_EM)); + } + + if (ImGui::Button("+", ImVec2(C::Em(C::SCENE_ADD_BUTTON_EM), C::Em(C::SCENE_ADD_BUTTON_EM)))) { + state.Reset(); + state.dialogOpen = true; + state.cachedFeatureNames = GetFeatureNamesForType(type); + } + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text("Add feature settings"); + } + + void RightAlignNextButton() + { + float btnSize = C::Em(C::SCENE_ADD_BUTTON_EM); + ImGui::SameLine(ImGui::GetContentRegionAvail().x - btnSize + ImGui::GetCursorPosX()); + } + + void DrawAddSettingDialog(SceneType type, AddSettingState& state, Period period, bool addToAllPeriods) + { + if (!state.dialogOpen) + return; + + constexpr int kPeriodCount = SceneSettingsManager::kPeriodCount; + auto* manager = SceneSettingsManager::GetSingleton(); + + ImVec2 center = ImGui::GetMainViewport()->GetCenter(); + ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + ImGui::SetNextWindowSize(ImVec2(C::Em(C::SCENE_ADD_DIALOG_WIDTH_EM), 0)); + + if (!ImGui::Begin("Add Feature Settings", &state.dialogOpen, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking | ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::End(); + return; + } + + // Feature dropdown + if (state.cachedFeatureNames.empty()) + state.cachedFeatureNames = GetFeatureNamesForType(type); + + auto displayName = (state.selectedFeatureIdx >= 0 && + state.selectedFeatureIdx < static_cast(state.cachedFeatureNames.size())) ? + SceneSettingsManager::GetFeatureDisplayName(state.cachedFeatureNames[state.selectedFeatureIdx]) : + std::string("Select Feature..."); + + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::BeginCombo("##FeatureSelect", displayName.c_str())) { + for (int i = 0; i < static_cast(state.cachedFeatureNames.size()); ++i) { + auto itemLabel = SceneSettingsManager::GetFeatureDisplayName(state.cachedFeatureNames[i]); + if (ImGui::Selectable(itemLabel.c_str(), i == state.selectedFeatureIdx)) { + state.selectedFeatureIdx = i; + state.cachedSettingKeys = (type == SceneType::TimeOfDay) ? SceneSettingsManager::GetTransitionableSettingKeys(state.cachedFeatureNames[i]) : SceneSettingsManager::GetFeatureSettingKeys(state.cachedFeatureNames[i]); + state.selectedSettings.assign(state.cachedSettingKeys.size(), false); + } + if (i == state.selectedFeatureIdx) + ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + + bool hasFeature = state.selectedFeatureIdx >= 0 && !state.cachedSettingKeys.empty(); + + if (hasFeature) { + ImGui::Spacing(); + ImGui::Separator(); + + // Select All / Select None + if (ImGui::SmallButton("Select All")) + std::fill(state.selectedSettings.begin(), state.selectedSettings.end(), true); + ImGui::SameLine(); + if (ImGui::SmallButton("Select None")) + std::fill(state.selectedSettings.begin(), state.selectedSettings.end(), false); + + ImGui::Spacing(); + + // Scrollable checkbox list + auto& featureName = state.cachedFeatureNames[state.selectedFeatureIdx]; + if (ImGui::BeginChild("##SettingList", ImVec2(-FLT_MIN, C::Em(C::SCENE_ADD_LIST_HEIGHT_EM)), ImGuiChildFlags_Border)) { + for (int i = 0; i < static_cast(state.cachedSettingKeys.size()); ++i) { + auto& key = state.cachedSettingKeys[i]; + bool alreadyAdded = addToAllPeriods ? [&] { for (int p = 0; p < kPeriodCount; ++p) if (!IsAlreadyAdded(type, featureName, key, static_cast(p))) return false; return true; }() : IsAlreadyAdded(type, featureName, key, period); + + if (alreadyAdded) { + auto _ = Util::DisableGuard(true); + bool checked = true; + ImGui::Checkbox(key.c_str(), &checked); + } else { + bool sel = state.selectedSettings[i]; + if (ImGui::Checkbox(key.c_str(), &sel)) + state.selectedSettings[i] = sel; + } + } + } + ImGui::EndChild(); + + ImGui::Spacing(); + + // Count selected + int selectedCount = 0; + for (size_t i = 0; i < state.selectedSettings.size(); ++i) + if (state.selectedSettings[i]) + ++selectedCount; + + // Add button + { + auto _ = Util::DisableGuard(selectedCount == 0); + auto label = std::format("Add ({})", selectedCount); + if (ImGui::Button(label.c_str(), ImVec2(-FLT_MIN, 0))) { + for (size_t i = 0; i < state.cachedSettingKeys.size(); ++i) { + if (!state.selectedSettings[i]) + continue; + auto& key = state.cachedSettingKeys[i]; + auto currentValue = SceneSettingsManager::GetFeatureSettingValue(featureName, key); + if (addToAllPeriods) { + for (int p = 0; p < kPeriodCount; ++p) + if (!IsAlreadyAdded(type, featureName, key, static_cast(p))) + manager->AddSetting(type, featureName, key, currentValue, static_cast(p)); + } else { + if (!IsAlreadyAdded(type, featureName, key, period)) + manager->AddSetting(type, featureName, key, currentValue, period); + } + } + state.dialogOpen = false; + } + } + } + + ImGui::End(); + } + + void DrawValueEditor(SceneType type, size_t index, float inputWidth) + { + auto* manager = SceneSettingsManager::GetSingleton(); + const auto& entry = manager->GetEntries(type)[index]; + auto settingType = SceneSettingsManager::DetectSettingType(entry.value); + + switch (settingType) { + case SceneSettingsManager::SettingType::Boolean: + { + bool val = entry.value.is_boolean() ? entry.value.get() : (entry.value.get() != 0); + if (ImGui::Checkbox("##val", &val)) + manager->UpdateEntryValue(type, index, entry.value.is_boolean() ? json(val) : json(val ? 1 : 0)); + } + break; + case SceneSettingsManager::SettingType::Float: + { + float val = entry.value.is_number() ? entry.value.get() : 0.0f; + if (!std::isfinite(val)) + val = 0.0f; + ImGui::SetNextItemWidth(inputWidth); + if (ImGui::InputFloat("##val", &val, 0.0f, 0.0f, "%.3f")) + if (std::isfinite(val)) + manager->UpdateEntryValue(type, index, val, true); + if (ImGui::IsItemDeactivatedAfterEdit()) + manager->SaveUserSettings(type); + } + break; + case SceneSettingsManager::SettingType::Integer: + { + int val = entry.value.get(); + ImGui::SetNextItemWidth(inputWidth); + if (ImGui::InputInt("##val", &val, 0, 0)) + manager->UpdateEntryValue(type, index, val, true); + if (ImGui::IsItemDeactivatedAfterEdit()) + manager->SaveUserSettings(type); + } + break; + default: + ImGui::TextDisabled("(unsupported type)"); + break; + } + } + + bool DrawSettingEntry(SceneType type, size_t index, PopupState& popups) + { + auto* manager = SceneSettingsManager::GetSingleton(); + const auto& entries = manager->GetEntries(type); + if (index >= entries.size()) + return false; + + const auto& entry = entries[index]; + + ImGui::PushID(static_cast(index)); + + // Setting key label (no feature prefix — grouped by feature already) + float availWidth = ImGui::GetContentRegionAvail().x; + ImGui::Text("%s", entry.settingKey.c_str()); + + // Value editor (right-aligned) + ImGui::SameLine(availWidth * C::SCENE_VALUE_LABEL_OFFSET_RATIO); + + bool isOverwrite = entry.source == EntrySource::Overwrite; + + bool readOnly = isOverwrite || + (type != SceneType::TimeOfDay && + manager->HasActiveOverwrite(type, entry.featureShortName, entry.settingKey)); + + if (readOnly) + ImGui::BeginDisabled(); + + DrawValueEditor(type, index, C::Em(C::SCENE_VALUE_INPUT_EM)); + + if (readOnly) + ImGui::EndDisabled(); + + // Active/Pause toggle + ImGui::SameLine(); + bool active = !entry.paused; + if (Util::FeatureToggle("##active", &active)) + manager->TogglePauseEntry(type, index); + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text(entry.paused ? "Paused - click to resume" : "Active - click to pause"); + + // Delete button + ImGui::SameLine(); + { + auto styledButton = Util::ErrorButtonStyle(); + if (ImGui::Button("X", ImVec2(C::Em(C::SCENE_DELETE_BUTTON_EM), 0))) { + if (isOverwrite) { + popups.pendingDeleteIndex = index; + popups.deleteSingleOverwrite.message = std::format( + "Delete overwrite file '{}'?\nThis will permanently remove the file from disk.", + entry.sourceFilename); + popups.deleteSingleOverwrite.Request(); + } else { + manager->RemoveSetting(type, index); + ImGui::PopID(); + return true; + } + } + } + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text(isOverwrite ? "Delete overwrite file from disk" : "Remove this setting"); + + ImGui::PopID(); + return false; + } + + void DrawPopups(SceneType type, PopupState& popups) + { + auto* manager = SceneSettingsManager::GetSingleton(); + + if (popups.deleteAllOverwrites.Draw()) + manager->DeleteAllOverwrites(type); + + if (popups.deleteSingleOverwrite.Draw()) { + if (popups.pendingDeleteIndex < manager->GetEntries(type).size()) + manager->RemoveSetting(type, popups.pendingDeleteIndex); + popups.pendingDeleteIndex = SIZE_MAX; + } + + if (popups.deleteRowOverwrite.Draw()) { + // Delete in reverse index order so earlier indices remain valid + std::sort(popups.pendingDeleteRow.begin(), popups.pendingDeleteRow.end(), std::greater<>()); + for (auto idx : popups.pendingDeleteRow) + if (idx < manager->GetEntries(type).size()) + manager->RemoveSetting(type, idx); + popups.pendingDeleteRow.clear(); + } + + if (popups.deleteAllUser.Draw()) + manager->DeleteAllUserSettings(type); + } + + /// Draw entries grouped by feature with collapsible tree nodes. + static void DrawGroupedEntries(SceneType type, PopupState& popups, + const std::vector& indices) + { + auto* manager = SceneSettingsManager::GetSingleton(); + const auto& entries = manager->GetEntries(type); + + std::map> grouped; + for (auto i : indices) + if (i < entries.size()) + grouped[entries[i].featureShortName].push_back(i); + + // Sort settings within each feature group alphabetically by key + for (auto& [_, featureIndices] : grouped) + std::sort(featureIndices.begin(), featureIndices.end(), [&entries](size_t a, size_t b) { + return entries[a].settingKey < entries[b].settingKey; + }); + + bool firstGroup = true; + for (const auto& [featureName, featureIndices] : grouped) { + if (!firstGroup) { + auto sepColor = ImGui::GetStyleColorVec4(ImGuiCol_Separator); + sepColor.w *= C::SCENE_GROUP_SEPARATOR_ALPHA; + ImGui::PushStyleColor(ImGuiCol_Separator, sepColor); + ImGui::Separator(); + ImGui::PopStyleColor(); + } + firstGroup = false; + + auto label = SceneSettingsManager::GetFeatureDisplayName(featureName) + ":"; + if (ImGui::TreeNodeEx(label.c_str(), ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::Indent(C::Em(C::SCENE_ENTRY_INDENT_EM)); + for (auto i : featureIndices) + if (i < entries.size()) + DrawSettingEntry(type, i, popups); + ImGui::Unindent(C::Em(C::SCENE_ENTRY_INDENT_EM)); + ImGui::TreePop(); + } + } + } + + void DrawSectionHeader(const char* label, const ImVec4& color, const char* idSuffix, + bool allPaused, std::function onTogglePause, std::function onDeleteAll) + { + ImGui::Spacing(); + ImGui::TextColored(color, "%s", label); + ImGui::SameLine(); + auto pauseLabel = std::format("{}{}", allPaused ? "Unpause All" : "Pause All", idSuffix); + if (ImGui::SmallButton(pauseLabel.c_str())) + onTogglePause(); + ImGui::SameLine(); + auto deleteLabel = std::format("Delete All{}", idSuffix); + if (ImGui::SmallButton(deleteLabel.c_str())) + onDeleteAll(); + ImGui::Separator(); + } + + void DrawEntrySections(SceneType type, PopupState& popups) + { + auto* manager = SceneSettingsManager::GetSingleton(); + const auto& entries = manager->GetEntries(type); + auto& theme = globals::menu->GetSettings().Theme; + + // Split indices by source + std::vector overwriteIndices, userIndices; + for (size_t i = 0; i < entries.size(); ++i) + (entries[i].source == EntrySource::Overwrite ? overwriteIndices : userIndices).push_back(i); + + if (!overwriteIndices.empty()) { + DrawSectionHeader("Overwrite Files", theme.StatusPalette.InfoColor, "##ow", manager->AreAllOverwritesPaused(type), [&] { manager->SetAllOverwritesPaused(type, !manager->AreAllOverwritesPaused(type)); }, [&] { popups.deleteAllOverwrites.Request(); }); + DrawGroupedEntries(type, popups, overwriteIndices); + } + + if (!userIndices.empty()) { + DrawSectionHeader("User Settings", theme.FeatureHeading.ColorDefault, "##usr", manager->AreAllUserPaused(type), [&] { manager->SetAllUserPaused(type, !manager->AreAllUserPaused(type)); }, [&] { popups.deleteAllUser.Request(); }); + DrawGroupedEntries(type, popups, userIndices); + } + } + + bool DrawCategoryPanel(const char* category, const std::string& selected, void (*drawFn)()) + { + if (selected != category) + return false; + // Wrap in a scrollable child since the parent disables scrolling (kStickyHeaderFlags) + if (ImGui::BeginChild("##SceneSettingsScroll", ImVec2(0, 0), ImGuiChildFlags_None)) { + drawFn(); + ImGui::Spacing(); // Ensure bottom table border is visible when scrolled to end + } + ImGui::EndChild(); + ImGui::EndChild(); + ImGui::EndTable(); + ImGui::End(); + return true; + } +} \ No newline at end of file diff --git a/src/WeatherEditor/SceneSettingsUI.h b/src/WeatherEditor/SceneSettingsUI.h new file mode 100644 index 0000000000..6ad38a2a56 --- /dev/null +++ b/src/WeatherEditor/SceneSettingsUI.h @@ -0,0 +1,107 @@ +#pragma once + +#include + +#include "SceneSettingsManager.h" +#include "Utils/UI.h" + +/// Shared UI drawing utilities for scene-settings panels (Interior Only, Time of Day). +/// Eliminates duplicate ImGui code between InteriorOnlyPanel and TimeOfDayPanel. +namespace SceneSettingsUI +{ + using SceneType = SceneSettingsManager::SceneType; + using EntrySource = SceneSettingsManager::EntrySource; + using Period = SceneSettingsManager::TimeOfDayPeriod; + + /// Persistent state for the "+" add-setting dialog. + struct AddSettingState + { + bool dialogOpen = false; + int selectedFeatureIdx = -1; + std::vector cachedFeatureNames; + std::vector cachedSettingKeys; + std::vector selectedSettings; // Checkbox state per setting key + + void Reset() + { + dialogOpen = false; + selectedFeatureIdx = -1; + cachedFeatureNames.clear(); + cachedSettingKeys.clear(); + selectedSettings.clear(); + } + }; + + /// Shared confirmation popup state for a panel. + struct PopupState + { + Util::ConfirmationPopup deleteAllOverwrites; + Util::ConfirmationPopup deleteSingleOverwrite{ "Delete Overwrite File?", "", "Delete" }; + Util::ConfirmationPopup deleteRowOverwrite{ "Delete Overwrite Row?", "", "Delete" }; + Util::ConfirmationPopup deleteAllUser; + size_t pendingDeleteIndex = SIZE_MAX; + std::vector pendingDeleteRow; + + PopupState(const char* overwriteMsg, const char* userMsg) : + deleteAllOverwrites("Delete All Overwrites?", overwriteMsg, "Delete All"), + deleteAllUser("Delete All User Settings?", userMsg, "Delete All") {} + }; + + /// Draw a "+" button that opens the add-setting dialog. + /// @param type Scene type being edited. + /// @param state Persistent dialog state. + /// @param period For TimeOfDay entries, which period to add to. Count = none. + /// @param labelPrefix Optional label drawn before the button (e.g. period name). + /// @param addToAllPeriods When true, adds the setting to every period at once. + void DrawAddSettingButton(SceneType type, AddSettingState& state, + Period period = Period::Count, const char* labelPrefix = nullptr, + bool addToAllPeriods = false); + + /// Position the cursor so the next add-setting button is right-aligned on the current line. + void RightAlignNextButton(); + + /// Draw the modal dialog opened by DrawAddSettingButton. + /// Must be called each frame for each active dialog state. + void DrawAddSettingDialog(SceneType type, AddSettingState& state, + Period period = Period::Count, bool addToAllPeriods = false); + + /// Draw the value editor widget (checkbox/float input/int input) for a setting entry. + /// @param type Scene type being edited. + /// @param index Index into the entries vector. + /// @param inputWidth Width for float/int input widgets. + void DrawValueEditor(SceneType type, size_t index, float inputWidth); + + /// Draw a single setting entry row (label, value editor, pause toggle, delete). + /// @param type Scene type being edited. + /// @param index Index into the entries vector. + /// @param popups Shared popup state for confirmations. + /// @return true if the entry was deleted inline (caller should stop iterating). + bool DrawSettingEntry(SceneType type, size_t index, PopupState& popups); + + /// Process all three delete-confirmation popups relative to the given type. + void DrawPopups(SceneType type, PopupState& popups); + + /// Draw a section header with Pause All / Delete All inline buttons. + /// @param label Section label (e.g. "Overwrite Files", "User Settings"). + /// @param color Header text color. + /// @param idSuffix ImGui ID suffix for button uniqueness (e.g. "##ow"). + /// @param allPaused Whether all entries in this section are currently paused. + /// @param onTogglePause Callback when Pause/Unpause All is clicked. + /// @param onDeleteAll Callback when Delete All is clicked. + void DrawSectionHeader(const char* label, const ImVec4& color, const char* idSuffix, + bool allPaused, std::function onTogglePause, std::function onDeleteAll); + + /// Draw overwrite + user entry sections with section-header-inline controls, + /// each section's entries grouped by feature name. + /// @param type Scene type being edited. + /// @param popups Shared popup state. + void DrawEntrySections(SceneType type, PopupState& popups); + + /// Draw a standalone scene-settings panel that dispatches to this panel's Draw(). + /// @param category Category name to check (e.g. "Interior Only"). + /// @param selected Currently selected category string. + /// @param drawFn Drawing function to call if category matches. + /// @return true if category matched and panel was drawn (caller should return). + bool DrawCategoryPanel(const char* category, const std::string& selected, + void (*drawFn)()); +} diff --git a/src/WeatherEditor/TimeOfDayPanel.cpp b/src/WeatherEditor/TimeOfDayPanel.cpp new file mode 100644 index 0000000000..664ff995ad --- /dev/null +++ b/src/WeatherEditor/TimeOfDayPanel.cpp @@ -0,0 +1,421 @@ +#include "TimeOfDayPanel.h" + +#include +#include +#include +#include +#include + +#include "../Globals.h" +#include "../Menu.h" +#include "../Menu/ThemeManager.h" +#include "../SceneSettingsManager.h" +#include "SceneSettingsUI.h" + +namespace TimeOfDayPanel +{ + using SceneType = SceneSettingsManager::SceneType; + using EntrySource = SceneSettingsManager::EntrySource; + using Period = SceneSettingsManager::TimeOfDayPeriod; + using C = ThemeManager::Constants; + static constexpr auto kSceneType = SceneType::TimeOfDay; + static constexpr int kPeriodCount = SceneSettingsManager::kPeriodCount; + + // Per-period add-setting state + static SceneSettingsUI::AddSettingState periodAddState[kPeriodCount]; + static SceneSettingsUI::AddSettingState allPeriodsAddState; + + /// Reset and open the add-setting dialog for a given state. + static void OpenAddDialog(SceneSettingsUI::AddSettingState& state) + { + state.Reset(); + state.dialogOpen = true; + } + + // Shared popups + static SceneSettingsUI::PopupState popups{ + "Are you sure you want to delete all time-of-day overwrite files?\nThis cannot be undone.", + "Are you sure you want to remove all user-added time-of-day settings?" + }; + + /// Collect all unique feature+setting pairs across all periods, preserving order. + struct SettingId + { + std::string feature; + std::string key; + bool operator<(const SettingId& o) const + { + return (feature < o.feature) || (feature == o.feature && key < o.key); + } + }; + + /// Draw a single value cell for a given entry index (or empty if no entry for this period). + static void DrawValueCell(size_t entryIndex) + { + auto* manager = SceneSettingsManager::GetSingleton(); + const auto& entries = manager->GetEntries(kSceneType); + + if (entryIndex == SIZE_MAX) { + ImGui::TextDisabled("--"); + return; + } + + const auto& entry = entries[entryIndex]; + bool isOverwrite = entry.source == EntrySource::Overwrite; + + ImGui::PushID(static_cast(entryIndex)); + + bool readOnly = isOverwrite; + if (readOnly) + ImGui::BeginDisabled(); + + SceneSettingsUI::DrawValueEditor(kSceneType, entryIndex, ImGui::GetContentRegionAvail().x); + + if (readOnly) + ImGui::EndDisabled(); + + // Toggle + X on a second line + bool active = !entry.paused; + if (Util::FeatureToggle("##active", &active)) + manager->TogglePauseEntry(kSceneType, entryIndex); + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text(entry.paused ? "Paused" : "Active"); + + ImGui::SameLine(); + { + auto styledButton = Util::ErrorButtonStyle(); + if (ImGui::Button("X", ImVec2(C::Em(C::SCENE_DELETE_BUTTON_EM), 0))) { + if (isOverwrite) { + popups.pendingDeleteIndex = entryIndex; + popups.deleteSingleOverwrite.message = std::format( + "Delete overwrite file '{}'?\nThis will permanently remove the file from disk.", + entry.sourceFilename); + popups.deleteSingleOverwrite.Request(); + } else { + manager->RemoveSetting(kSceneType, entryIndex); + } + } + } + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text(isOverwrite ? "Delete overwrite file" : "Remove this setting"); + + ImGui::PopID(); + } + + /// Build a setting map for entries from a specific source. + struct SourceGroup + { + std::vector order; + std::map>> map; + }; + + static SourceGroup BuildSourceGroup(const std::vector& entries, + EntrySource source) + { + SourceGroup group; + for (size_t idx = 0; idx < entries.size(); ++idx) { + const auto& e = entries[idx]; + if (e.source != source) + continue; + int p = static_cast(e.period); + if (p < 0 || p >= kPeriodCount) + continue; + auto& featureMap = group.map[e.featureShortName]; + auto [it, inserted] = featureMap.try_emplace(e.settingKey); + if (inserted) { + it->second.fill(SIZE_MAX); + group.order.push_back({ e.featureShortName, e.settingKey }); + } + it->second[p] = idx; + } + // Sort by feature name then setting key + std::sort(group.order.begin(), group.order.end()); + return group; + } + + /// Draw TOD table rows for a set of entries grouped by feature. + static void DrawSourceRows(const SourceGroup& group, const float* factors, EntrySource source) + { + auto* manager = SceneSettingsManager::GetSingleton(); + auto& theme = globals::menu->GetSettings().Theme; + bool isOverwrite = source == EntrySource::Overwrite; + std::string lastFeature; + + for (const auto& sid : group.order) { + if (sid.feature != lastFeature) { + lastFeature = sid.feature; + + // Feature header row with highlight and smaller text + ImGui::TableNextRow(); + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, ImGui::GetColorU32(ImGuiCol_TableRowBgAlt)); + ImGui::TableSetColumnIndex(0); + ImGui::SetWindowFontScale(C::SCENE_TOD_FEATURE_TEXT_SCALE); + auto featureLabel = SceneSettingsManager::GetFeatureDisplayName(sid.feature); + ImGui::TextColored(theme.FeatureHeading.ColorDefault, "%s:", featureLabel.c_str()); + ImGui::SetWindowFontScale(1.0f); + } + + auto mapIt = group.map.find(sid.feature); + if (mapIt == group.map.end()) + continue; + auto keyIt = mapIt->second.find(sid.key); + if (keyIt == mapIt->second.end()) + continue; + const auto& perKey = keyIt->second; + + // Collect valid indices for this row + std::vector rowIndices; + for (int p = 0; p < kPeriodCount; ++p) + if (perKey[p] != SIZE_MAX) + rowIndices.push_back(perKey[p]); + + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::PushID(sid.key.c_str()); + ImGui::PushID(sid.feature.c_str()); + + ImGui::Indent(C::Em(C::SCENE_ENTRY_INDENT_EM)); + ImGui::SetWindowFontScale(C::SCENE_TOD_FEATURE_TEXT_SCALE); + ImGui::Text("%s", sid.key.c_str()); + ImGui::SetWindowFontScale(1.0f); + + // Row-level toggle + delete + { + const auto& entries = manager->GetEntries(kSceneType); + bool allPaused = std::all_of(rowIndices.begin(), rowIndices.end(), + [&](size_t i) { return i < entries.size() && entries[i].paused; }); + bool active = !allPaused; + if (Util::FeatureToggle("##rowActive", &active)) + for (auto idx : rowIndices) + if (idx < entries.size() && entries[idx].paused == active) + manager->TogglePauseEntry(kSceneType, idx); + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text(allPaused ? "Unpause all periods" : "Pause all periods"); + + ImGui::SameLine(); + { + auto styledButton = Util::ErrorButtonStyle(); + if (ImGui::Button("X", ImVec2(C::Em(C::SCENE_DELETE_BUTTON_EM), 0))) { + if (isOverwrite) { + // Collect unique filenames for the confirmation message + std::set filenames; + for (auto idx : rowIndices) + if (idx < entries.size()) + filenames.insert(entries[idx].sourceFilename); + std::string fileList; + for (const auto& f : filenames) { + if (!fileList.empty()) + fileList += ", "; + fileList += "'" + f + "'"; + } + popups.pendingDeleteRow = rowIndices; + popups.deleteRowOverwrite.message = std::format( + "Delete overwrite entries from {}?\nThis will permanently remove the file(s) from disk.", + fileList); + popups.deleteRowOverwrite.Request(); + } else { + // Delete user entries in reverse order so indices stay valid + std::sort(rowIndices.begin(), rowIndices.end(), std::greater<>()); + for (auto idx : rowIndices) + manager->RemoveSetting(kSceneType, idx); + } + } + } + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text(isOverwrite ? "Delete row from disk" : "Remove all periods"); + } + + ImGui::Unindent(C::Em(C::SCENE_ENTRY_INDENT_EM)); + ImGui::PopID(); + ImGui::PopID(); + + for (int p = 0; p < kPeriodCount; ++p) { + ImGui::TableSetColumnIndex(1 + p); + + bool isActive = factors[p] > C::SCENE_TOD_ACTIVE_THRESHOLD; + if (!isActive) + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, C::SCENE_TOD_INACTIVE_ALPHA); + + DrawValueCell(perKey[p]); + + if (!isActive) + ImGui::PopStyleVar(); + } + } + } + + /// Collect all entry indices per period from a source group. + static void CollectPerPeriodIndices(const SourceGroup& group, std::array, kPeriodCount>& out) + { + for (const auto& [_, featureMap] : group.map) + for (const auto& [__, perKey] : featureMap) + for (int p = 0; p < kPeriodCount; ++p) + if (perKey[p] != SIZE_MAX) + out[p].push_back(perKey[p]); + } + + /// Draw a TOD table for a single source group. + static void DrawSourceTable(const SourceGroup& group, const float* factors, const char* tableId, EntrySource source) + { + auto* manager = SceneSettingsManager::GetSingleton(); + const auto& entries = manager->GetEntries(kSceneType); + bool isOverwrite = source == EntrySource::Overwrite; + constexpr int kTotalCols = 1 + kPeriodCount; + + // Pre-collect per-period indices for header controls + std::array, kPeriodCount> perPeriod{}; + CollectPerPeriodIndices(group, perPeriod); + + if (ImGui::BeginTable(tableId, kTotalCols, + ImGuiTableFlags_Borders | + ImGuiTableFlags_SizingFixedFit | + ImGuiTableFlags_NoHostExtendX)) { + ImGui::TableSetupColumn("Setting", ImGuiTableColumnFlags_WidthFixed, C::Em(C::SCENE_TOD_PARAM_COL_EM)); + for (int i = 0; i < kPeriodCount; ++i) + ImGui::TableSetupColumn(SceneSettingsManager::kPeriodNames[i], + ImGuiTableColumnFlags_WidthFixed, C::Em(C::SCENE_TOD_PERIOD_COL_EM)); + + ImGui::TableSetupScrollFreeze(0, 1); + + // Header row with period names + integrated per-column controls + ImGui::TableNextRow(ImGuiTableRowFlags_Headers); + ImGui::TableSetColumnIndex(0); + ImGui::TableHeader("Setting"); + for (int i = 0; i < kPeriodCount; ++i) { + ImGui::TableSetColumnIndex(1 + i); + bool isActive = factors[i] > C::SCENE_TOD_ACTIVE_THRESHOLD; + if (!isActive) + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, C::SCENE_TOD_INACTIVE_ALPHA); + + ImGui::Text("%s", SceneSettingsManager::kPeriodNames[i]); + + const auto& indices = perPeriod[i]; + if (!indices.empty()) { + ImGui::PushID(i); + + bool allPaused = std::all_of(indices.begin(), indices.end(), + [&](size_t idx) { return idx < entries.size() && entries[idx].paused; }); + bool active = !allPaused; + if (Util::FeatureToggle("##colActive", &active)) + for (auto idx : indices) + if (idx < entries.size() && entries[idx].paused == active) + manager->TogglePauseEntry(kSceneType, idx); + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text(allPaused ? "Unpause all in this period" : "Pause all in this period"); + + ImGui::SameLine(); + { + auto styledButton = Util::ErrorButtonStyle(); + if (ImGui::Button("X", ImVec2(C::Em(C::SCENE_DELETE_BUTTON_EM), 0))) { + if (isOverwrite) { + std::set filenames; + for (auto idx : indices) + if (idx < entries.size()) + filenames.insert(entries[idx].sourceFilename); + std::string fileList; + for (const auto& f : filenames) { + if (!fileList.empty()) + fileList += ", "; + fileList += "'" + f + "'"; + } + popups.pendingDeleteRow = indices; + popups.deleteRowOverwrite.message = std::format( + "Delete all {} overwrite entries?\nThis will permanently remove file(s) {} from disk.", + SceneSettingsManager::kPeriodNames[i], fileList); + popups.deleteRowOverwrite.Request(); + } else { + auto sorted = indices; + std::sort(sorted.begin(), sorted.end(), std::greater<>()); + for (auto idx : sorted) + manager->RemoveSetting(kSceneType, idx); + } + } + } + if (auto _tt = Util::HoverTooltipWrapper()) + ImGui::Text(isOverwrite ? "Delete all in this period" : "Remove all in this period"); + + ImGui::PopID(); + } + + if (!isActive) + ImGui::PopStyleVar(); + } + + DrawSourceRows(group, factors, source); + ImGui::EndTable(); + } + } + + void Draw() + { + auto* manager = SceneSettingsManager::GetSingleton(); + const auto& entries = manager->GetEntries(kSceneType); + auto& theme = globals::menu->GetSettings().Theme; + + // Header + ImGui::Text("Time of Day Settings"); + ImGui::SameLine(); + ImGui::TextDisabled("(Exterior Only)"); + + auto currentPeriod = SceneSettingsManager::GetCurrentPeriod(); + ImGui::SameLine(); + ImGui::TextColored(theme.StatusPalette.InfoColor, "[%s %.1fh]", + SceneSettingsManager::GetPeriodName(currentPeriod), + SceneSettingsManager::GetCurrentGameHour()); + + ImGui::Separator(); + + // Add buttons: inline row of named buttons matching section header style + for (int i = 0; i < kPeriodCount; ++i) { + if (i > 0) + ImGui::SameLine(); + ImGui::PushID(i); + auto label = std::format("Add {}", SceneSettingsManager::kPeriodNames[i]); + if (ImGui::SmallButton(label.c_str())) + OpenAddDialog(periodAddState[i]); + ImGui::PopID(); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Add All")) + OpenAddDialog(allPeriodsAddState); + + // Draw all add-setting dialogs (no-op when not open) + for (int i = 0; i < kPeriodCount; ++i) + SceneSettingsUI::DrawAddSettingDialog(kSceneType, periodAddState[i], static_cast(i)); + SceneSettingsUI::DrawAddSettingDialog(kSceneType, allPeriodsAddState, Period::Count, true); + + ImGui::Separator(); + + // Popups + SceneSettingsUI::DrawPopups(kSceneType, popups); + + if (entries.empty()) { + ImGui::Spacing(); + ImGui::TextColored(theme.StatusPalette.Disable, + "No time-of-day settings configured."); + ImGui::TextColored(theme.StatusPalette.Disable, + "Use the Add buttons above to add overrides for each period."); + return; + } + + ImGui::Spacing(); + + // Build separate maps for overwrite and user entries + auto overwriteGroup = BuildSourceGroup(entries, EntrySource::Overwrite); + auto userGroup = BuildSourceGroup(entries, EntrySource::User); + + // Get active period factors for highlighting + float factors[kPeriodCount]; + manager->GetTimeOfDayFactors(factors); + + if (!overwriteGroup.order.empty()) { + SceneSettingsUI::DrawSectionHeader("Overwrite Files", theme.StatusPalette.InfoColor, "##ow", manager->AreAllOverwritesPaused(kSceneType), [&] { manager->SetAllOverwritesPaused(kSceneType, !manager->AreAllOverwritesPaused(kSceneType)); }, [&] { popups.deleteAllOverwrites.Request(); }); + DrawSourceTable(overwriteGroup, factors, "##TODOverwriteTable", EntrySource::Overwrite); + } + + if (!userGroup.order.empty()) { + SceneSettingsUI::DrawSectionHeader("User Settings", theme.FeatureHeading.ColorDefault, "##usr", manager->AreAllUserPaused(kSceneType), [&] { manager->SetAllUserPaused(kSceneType, !manager->AreAllUserPaused(kSceneType)); }, [&] { popups.deleteAllUser.Request(); }); + DrawSourceTable(userGroup, factors, "##TODUserTable", EntrySource::User); + } + } +} diff --git a/src/WeatherEditor/TimeOfDayPanel.h b/src/WeatherEditor/TimeOfDayPanel.h new file mode 100644 index 0000000000..8466f86fc0 --- /dev/null +++ b/src/WeatherEditor/TimeOfDayPanel.h @@ -0,0 +1,10 @@ +#pragma once + +/// UI panel for managing Time of Day scene settings within the Weather Editor. +/// Shows period tabs (Dawn, Sunrise, Day, Sunset, Dusk, Night) with +/// add/pause/delete controls under each. Delegates to shared SceneSettingsUI utilities. +namespace TimeOfDayPanel +{ + /// Draw the full Time of Day settings panel + void Draw(); +} diff --git a/src/WeatherEditor/WeatherUtils.cpp b/src/WeatherEditor/WeatherUtils.cpp index 29df56f6e2..c7f9528c26 100644 --- a/src/WeatherEditor/WeatherUtils.cpp +++ b/src/WeatherEditor/WeatherUtils.cpp @@ -298,10 +298,15 @@ namespace TOD float GetCurrentGameTime() { + // Prefer calendar (ground truth), which the Weather Editor slider writes to. + auto calendar = globals::game::calendar ? globals::game::calendar : RE::Calendar::GetSingleton(); + if (calendar && calendar->gameHour) + return std::clamp(calendar->gameHour->value, 0.0f, 24.0f); + auto sky = globals::game::sky; - if (sky) { + if (sky) return std::clamp(sky->currentGameHour, 0.0f, 24.0f); - } + return 12.0f; // Default to noon }