Blacksite/crates/editor/src/project/settings_ui.rs

584 lines
21 KiB
Rust

//! Editor-only Project Settings panel (egui). Data lives in the `settings` crate.
use std::path::Path;
use bevy::prelude::*;
use bevy_egui::egui;
use game_hot::sync_project_sun_from_settings;
use settings::{
save_project_settings_to_string, sync_sim_tuning, ExposureMode, ProjectSettings,
ProjectSettingsChanged, ProjectSettingsIo,
};
use crate::project::collaboration::{
file_status_indicator_ui, publish_authored_file, CollaborationState, FileSnapshot,
FileWriteIntent,
};
use crate::scene_io::SceneIo;
/// Whether the Project Settings window is visible (editor UI state only).
#[derive(Resource, Debug, Default)]
pub struct ProjectSettingsPanel {
pub open: bool,
pub draft: Option<ProjectSettings>,
disk_snapshot: Option<FileSnapshot>,
save_error: Option<String>,
}
pub struct SettingsUiPlugin;
impl Plugin for SettingsUiPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ProjectSettingsPanel>().add_systems(
PostUpdate,
apply_project_settings_changes
.after(crate::scene_view::sync_editor_camera_viewport_target),
);
}
}
pub fn open_project_settings_panel(panel: &mut ProjectSettingsPanel) {
panel.open = true;
panel.draft = None;
}
pub fn project_settings_window(
world: &mut World,
ctx: &egui::Context,
panel: &mut ProjectSettingsPanel,
) {
if !panel.open {
panel.draft = None;
panel.disk_snapshot = None;
panel.save_error = None;
return;
}
if panel.draft.is_none() {
panel.draft = Some(world.resource::<ProjectSettings>().clone());
let io = world.resource::<ProjectSettingsIo>();
panel.disk_snapshot = Some(
io.loaded_source
.as_deref()
.map(|source| {
FileSnapshot::from_loaded_bytes(Path::new(&io.path), source.as_bytes())
})
.unwrap_or_else(FileSnapshot::missing),
);
panel.save_error = None;
}
let mut open = panel.open;
egui::Window::new("Project Settings")
.open(&mut open)
.default_width(420.0)
.show(ctx, |ui| {
let ProjectSettingsPanel {
draft: Some(draft),
disk_snapshot,
save_error,
..
} = panel
else {
return;
};
let io_path = world.resource::<ProjectSettingsIo>().path.clone();
let mut io_dirty = world.resource::<ProjectSettingsIo>().dirty;
draw_project_settings_form(
world,
ui,
draft,
&io_path,
&mut io_dirty,
disk_snapshot,
save_error,
);
world.resource_mut::<ProjectSettingsIo>().dirty = io_dirty;
});
panel.open = open;
if !open {
panel.draft = None;
panel.disk_snapshot = None;
panel.save_error = None;
}
}
fn draw_project_settings_form(
world: &mut World,
ui: &mut egui::Ui,
draft: &mut ProjectSettings,
io_path: &str,
io_dirty: &mut bool,
disk_snapshot: &mut Option<FileSnapshot>,
save_error: &mut Option<String>,
) {
let caps = world
.get_resource::<settings::RenderingCapabilities>()
.cloned()
.unwrap_or_default();
ui.horizontal_wrapped(|ui| {
ui.small(egui::RichText::new(io_path).monospace());
if let Some(state) = world.get_resource::<CollaborationState>() {
let status = state.file_status(Path::new(io_path));
let _ = file_status_indicator_ui(ui, &status, Path::new(io_path));
}
});
if let Some(error) = save_error.as_deref() {
ui.colored_label(crate::ui::theme::ERROR, error);
}
ui.separator();
ui.collapsing("Global Illumination", |ui| {
egui::ComboBox::from_label("GiMode")
.selected_text(format!("{:?}", draft.rendering.gi_mode))
.show_ui(ui, |ui| {
for mode in [
settings::GiMode::Auto,
settings::GiMode::Forward,
settings::GiMode::Solari,
] {
if ui
.selectable_value(&mut draft.rendering.gi_mode, mode, format!("{mode:?}"))
.clicked()
{}
}
});
ui.small("Auto uses Solari when RT GPU support is available; otherwise Forward PBR.");
let status = match (draft.rendering.gi_mode, caps.rt_supported) {
(settings::GiMode::Forward, _) => "Forward PBR",
(settings::GiMode::Solari, true) => "Solari active",
(settings::GiMode::Solari, false) => "Solari requested — will fall back (no RT)",
(settings::GiMode::Auto, true) => "Auto → Solari when playing",
(settings::GiMode::Auto, false) => "Auto → Forward fallback (no RT)",
};
ui.label(format!("Status: {status}"));
ui.small("Open Window → Rendering for resolved active-camera stack.");
});
ui.collapsing("Lighting", |ui| {
ui.label("Ambient");
ui.horizontal(|ui| {
let mut rgb = [
draft.rendering.ambient_color[0],
draft.rendering.ambient_color[1],
draft.rendering.ambient_color[2],
];
if ui.color_edit_button_rgb(&mut rgb).changed() {
draft.rendering.ambient_color = rgb;
}
});
ui.add(
egui::Slider::new(&mut draft.rendering.ambient_brightness, 0.0..=200.0)
.text("Ambient brightness"),
);
ui.add(
egui::Slider::new(&mut draft.rendering.sun_illuminance, 1_000.0..=150_000.0)
.text("Sun illuminance (lux)"),
);
ui.add(
egui::Slider::new(&mut draft.rendering.shadow_cascades, 1..=4).text("Shadow cascades"),
);
ui.add(
egui::Slider::new(&mut draft.rendering.shadow_first_cascade, 1.0..=20.0)
.text("First cascade (m)"),
);
ui.add(
egui::Slider::new(&mut draft.rendering.shadow_max_distance, 50.0..=500.0)
.text("Shadow max distance"),
);
});
ui.collapsing("Post Process", |ui| {
ui.checkbox(&mut draft.rendering.hdr, "HDR");
ui.small("The viewport uses HDR when Lit mode is active.");
ui.horizontal(|ui| {
ui.label("Exposure");
if ui
.selectable_label(
draft.rendering.exposure_mode == ExposureMode::Manual,
"Manual",
)
.clicked()
{
draft.rendering.exposure_mode = ExposureMode::Manual;
}
if ui
.selectable_label(draft.rendering.exposure_mode == ExposureMode::Auto, "Auto")
.clicked()
{
draft.rendering.exposure_mode = ExposureMode::Auto;
}
});
let manual = draft.rendering.exposure_mode == ExposureMode::Manual;
if manual {
ui.add(
egui::Slider::new(&mut draft.rendering.exposure_ev100, 0.0..=20.0)
.text("Exposure EV100"),
);
} else {
ui.add_enabled(
false,
egui::Slider::new(&mut draft.rendering.exposure_ev100, 0.0..=20.0)
.text("Exposure EV100"),
);
ui.small("Scene brightness adapts automatically (histogram metering).");
ui.add(
egui::Slider::new(
&mut draft.rendering.auto_exposure_speed_brighten,
0.1..=10.0,
)
.text("Auto brighten (f-stops/s)"),
);
ui.add(
egui::Slider::new(&mut draft.rendering.auto_exposure_speed_darken, 0.1..=10.0)
.text("Auto darken (f-stops/s)"),
);
if !draft.rendering.hdr {
ui.colored_label(
egui::Color32::from_rgb(255, 180, 100),
"Auto exposure requires HDR — enable HDR or use Manual.",
);
} else {
ui.small("Requires GPU compute; see Window → Rendering if unavailable.");
}
}
ui.checkbox(&mut draft.rendering.tonemapping_aces, "Tonemapping ACES");
ui.checkbox(&mut draft.rendering.bloom, "Bloom");
ui.checkbox(&mut draft.rendering.taa, "TAA");
ui.checkbox(&mut draft.rendering.ssao, "SSAO");
ui.checkbox(&mut draft.rendering.atmosphere, "Atmosphere");
ui.label("Fog");
ui.horizontal(|ui| {
let mut fog_rgb = draft.rendering.fog_color;
if ui.color_edit_button_rgb(&mut fog_rgb).changed() {
draft.rendering.fog_color = fog_rgb;
}
});
ui.add(
egui::Slider::new(&mut draft.rendering.fog_density, 0.0..=0.01)
.logarithmic(true)
.text("Fog density"),
);
});
ui.collapsing("Audio Buses", |ui| {
let bus_ids: Vec<String> = draft.audio.buses.iter().map(|bus| bus.id.clone()).collect();
let mut remove_bus = None;
for (index, bus) in draft.audio.buses.iter_mut().enumerate() {
egui::Frame::new()
.fill(egui::Color32::from_rgb(24, 26, 29))
.stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(53, 57, 63)))
.corner_radius(4.0)
.inner_margin(8.0)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.strong(&bus.label);
ui.small(
egui::RichText::new(format!("[{}]", bus.id))
.color(egui::Color32::from_rgb(139, 146, 156)),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let required = matches!(
bus.id.as_str(),
settings::AUDIO_BUS_MASTER_ID
| settings::AUDIO_BUS_MUSIC_ID
| settings::AUDIO_BUS_SFX_ID
);
if ui
.add_enabled(!required, egui::Button::new("Remove"))
.clicked()
{
remove_bus = Some(index);
}
});
});
ui.horizontal_wrapped(|ui| {
ui.label("Name");
ui.add_sized([140.0, 20.0], egui::TextEdit::singleline(&mut bus.label));
ui.checkbox(&mut bus.muted, "Mute");
});
ui.add(
egui::Slider::new(
&mut bus.gain_db,
settings::AUDIO_GAIN_DB_MIN..=settings::AUDIO_GAIN_DB_MAX,
)
.text("Gain")
.suffix(" dB"),
);
if bus.id != settings::AUDIO_BUS_MASTER_ID {
let parent_label = bus
.parent
.as_deref()
.and_then(|parent| bus_ids.iter().find(|id| id.as_str() == parent))
.map(String::as_str)
.unwrap_or("None");
egui::ComboBox::from_id_salt(("audio_bus_parent", index))
.selected_text(parent_label)
.show_ui(ui, |ui| {
for candidate in &bus_ids {
if candidate != &bus.id {
ui.selectable_value(
&mut bus.parent,
Some(candidate.clone()),
candidate,
);
}
}
});
}
});
ui.add_space(4.0);
}
if let Some(index) = remove_bus {
draft.audio.buses.remove(index);
}
if ui.button("Add Bus").clicked() {
let mut suffix = 1usize;
let id = loop {
let candidate = format!("bus_{suffix}");
if draft.audio.bus(&candidate).is_none() {
break candidate;
}
suffix += 1;
};
draft.audio.buses.push(settings::AudioBusSettings::new(
id,
"New Bus",
Some(settings::AUDIO_BUS_MASTER_ID),
));
}
if let Err(error) = draft.audio.validate() {
ui.colored_label(egui::Color32::from_rgb(235, 105, 105), error.to_string());
}
});
ui.separator();
ui.heading("Physics");
ui.add(egui::Slider::new(&mut draft.physics.walk_speed, 1.0..=15.0).text("Walk speed"));
ui.add(egui::Slider::new(&mut draft.physics.run_speed, 1.0..=20.0).text("Run speed"));
ui.add(egui::Slider::new(&mut draft.physics.gravity, 5.0..=40.0).text("Gravity"));
ui.add(egui::Slider::new(&mut draft.physics.jump_speed, 3.0..=15.0).text("Jump speed"));
ui.separator();
ui.heading("Input");
ui.add(
egui::Slider::new(&mut draft.input.yaw_sensitivity, 0.0005..=0.01).text("Yaw sensitivity"),
);
ui.add(
egui::Slider::new(&mut draft.input.pitch_sensitivity, 0.0005..=0.01)
.text("Pitch sensitivity"),
);
ui.add(
egui::Slider::new(&mut draft.input.editor_fly_speed, 2.0..=40.0).text("Editor fly speed"),
);
ui.separator();
let settings_valid = draft.audio.validate().is_ok();
if ui
.add_enabled(settings_valid, egui::Button::new("Apply"))
.clicked()
{
commit_project_settings(world, draft);
*io_dirty = true;
}
if ui
.add_enabled(settings_valid, egui::Button::new("Save to disk"))
.clicked()
{
commit_project_settings(world, draft);
*io_dirty = true;
let settings = world.resource::<ProjectSettings>().clone();
let Some(expected) = disk_snapshot.clone() else {
let error = "Project settings disk baseline is unavailable; reload before saving";
*save_error = Some(error.into());
world.resource_mut::<SceneIo>().set_status(error);
return;
};
match persist_project_settings(world, &settings, &expected) {
Ok((snapshot, source)) => {
let mut io = world.resource_mut::<ProjectSettingsIo>();
io.dirty = false;
io.loaded_source = Some(source);
*disk_snapshot = Some(snapshot);
*save_error = None;
*io_dirty = false;
world
.resource_mut::<SceneIo>()
.set_status(format!("Saved project settings to {io_path}"));
}
Err(error) => {
warn!("Save project settings failed: {error}");
*save_error = Some(error.clone());
world
.resource_mut::<SceneIo>()
.set_status(format!("Save project settings failed: {error}"));
}
}
}
if ui.button("Revert from disk").clicked() {
match read_project_settings_document(Path::new(io_path)) {
Ok((settings, source, snapshot)) => {
*draft = settings;
commit_project_settings(world, draft);
let mut io = world.resource_mut::<ProjectSettingsIo>();
io.dirty = false;
io.loaded_source = Some(source);
*disk_snapshot = Some(snapshot);
*save_error = None;
*io_dirty = false;
}
Err(error) => {
*save_error = Some(error.clone());
world
.resource_mut::<SceneIo>()
.set_status(format!("Revert project settings failed: {error}"));
}
}
}
if *io_dirty {
ui.label(egui::RichText::new("Unsaved changes").color(egui::Color32::YELLOW));
}
}
fn persist_project_settings(
world: &mut World,
settings: &ProjectSettings,
expected: &FileSnapshot,
) -> Result<(FileSnapshot, String), String> {
let path = world.resource::<ProjectSettingsIo>().path.clone();
let source = save_project_settings_to_string(settings).map_err(|error| error.to_string())?;
let snapshot = publish_authored_file(
world,
Path::new(&path),
source.as_bytes(),
expected,
FileWriteIntent::ProjectSettings,
)?;
Ok((snapshot, source))
}
fn read_project_settings_document(
path: &Path,
) -> Result<(ProjectSettings, String, FileSnapshot), String> {
let source = std::fs::read_to_string(path)
.map_err(|error| format!("could not read {}: {error}", path.display()))?;
let settings: ProjectSettings = ron::from_str(&source)
.map_err(|error| format!("invalid project settings in {}: {error}", path.display()))?;
let snapshot = FileSnapshot::from_loaded_bytes(path, source.as_bytes());
Ok((settings, source, snapshot))
}
pub(crate) fn reload_project_settings_after_file_conflict(
world: &mut World,
path: &Path,
) -> Result<String, String> {
let active_path = world.resource::<ProjectSettingsIo>().path.clone();
if Path::new(&active_path) != path {
return Err("the project settings conflict no longer matches the active manifest".into());
}
let (settings, source, snapshot) = read_project_settings_document(path)?;
commit_project_settings(world, &settings);
{
let mut io = world.resource_mut::<ProjectSettingsIo>();
io.dirty = false;
io.loaded_source = Some(source);
}
{
let mut panel = world.resource_mut::<ProjectSettingsPanel>();
panel.draft = Some(settings);
panel.disk_snapshot = Some(snapshot);
panel.save_error = None;
}
Ok(format!("Reloaded project settings from {}", path.display()))
}
pub(crate) fn project_settings_conflict_copy_saved(world: &mut World) {
world.resource_mut::<ProjectSettingsPanel>().save_error = None;
}
fn commit_project_settings(world: &mut World, draft: &ProjectSettings) {
let previous = world.resource::<ProjectSettings>().rendering.gi_mode;
*world.resource_mut::<ProjectSettings>() = draft.clone();
if previous != draft.rendering.gi_mode {
info!(
"Project settings GI mode changed: {:?} -> {:?}",
previous, draft.rendering.gi_mode
);
} else {
debug!(
"Project settings applied: gi_mode={:?}",
draft.rendering.gi_mode
);
}
world.write_message(ProjectSettingsChanged);
}
fn apply_project_settings_changes(
mut reader: MessageReader<ProjectSettingsChanged>,
mut commands: Commands,
settings: Res<ProjectSettings>,
tuning: ResMut<settings::SimTuning>,
scene_suns: Query<
(
&shared::LightDesc,
Option<&shared::AuthoringComponentStates>,
Option<&shared::InspectorOrder>,
),
With<shared::LevelObject>,
>,
project_suns: Query<(&mut DirectionalLight, &mut Visibility), With<shared::ProjectSun>>,
) {
if reader.read().next().is_none() {
return;
}
sync_sim_tuning(&settings, tuning);
{
let ambient = settings.rendering.clone();
commands.insert_resource(GlobalAmbientLight {
color: Color::srgb(
ambient.ambient_color[0],
ambient.ambient_color[1],
ambient.ambient_color[2],
),
brightness: ambient.ambient_brightness,
..default()
});
}
sync_project_sun_from_settings(settings, scene_suns, project_suns);
// Full stack refresh runs next frame via `render_view::sync_project_render_view`
// when `ProjectSettings` changes.
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn project_settings_save_refuses_an_external_revision() {
let root = std::env::temp_dir().join(format!(
"blacksite-project-settings-external-write-{}",
uuid::Uuid::new_v4()
));
let path = root.join("assets/project.ron");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let loaded = save_project_settings_to_string(&ProjectSettings::default()).unwrap();
std::fs::write(&path, &loaded).unwrap();
let expected = FileSnapshot::from_loaded_bytes(&path, loaded.as_bytes());
let mut world = World::new();
world.insert_resource(ProjectSettingsIo {
path: path.to_string_lossy().into_owned(),
loaded_source: Some(loaded),
..Default::default()
});
world.insert_resource(SceneIo::default());
std::fs::write(&path, b"external").unwrap();
let result = persist_project_settings(&mut world, &ProjectSettings::default(), &expected);
assert!(result.unwrap_err().contains("changed outside Blacksite"));
assert_eq!(std::fs::read(&path).unwrap(), b"external");
std::fs::remove_dir_all(root).unwrap();
}
}