use crate::form;
use crate::panels::tree::{self, TreeRow};
use crate::store::Store;
use brep_render::engine_state::EngineState;
use brep_render::style::{settings_form_fields, FormField, RenderSettings};
use eframe::egui;
use serde_json::Value;
use std::collections::{HashMap, HashSet};
pub struct SettingsPanel {
pub open: bool,
hits: HashMap<String, egui::Rect>,
display_collapsed: bool,
closed_groups: HashSet<String>,
per_solid_collapsed: bool,
solid_override_edit: HashMap<String, [u8; 3]>,
}
impl SettingsPanel {
pub fn new() -> Self {
Self {
open: false,
hits: HashMap::new(),
display_collapsed: false,
closed_groups: HashSet::new(),
per_solid_collapsed: true,
solid_override_edit: HashMap::new(),
}
}
pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn Store) {
if self.open {
let mut open = true;
egui::Window::new("Settings")
.open(&mut open)
.movable(true)
.resizable(true)
.default_size([320.0, 400.0])
.default_pos([720.0, 56.0])
.show(ctx, |ui| {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| self.body(ui, state, store));
});
self.open = open;
#[cfg(target_arch = "wasm32")]
publish("__brepSettingsHit", &self.hits_json());
}
}
fn body(&mut self, ui: &mut egui::Ui, state: &mut EngineState, store: &dyn Store) {
self.hits.clear();
ui.spacing_mut().item_spacing.y = 2.0;
self.settings_section(ui, state, store);
self.per_solid_color_section(ui, state);
}
fn settings_section(&mut self, ui: &mut egui::Ui, state: &mut EngineState, store: &dyn Store) {
let mut settings_json: Value =
serde_json::from_str(&state.settings_json()).unwrap_or(Value::Null);
let fields = settings_form_fields();
let mut groups: Vec<(String, Vec<&FormField>)> = Vec::new();
for f in &fields {
if let Some(g) = groups.iter_mut().find(|(n, _)| *n == f.group) {
g.1.push(f);
} else {
groups.push((f.group.clone(), vec![f]));
}
}
let root_open = !self.display_collapsed;
let root_resp = tree::node(
ui,
TreeRow {
guides: &[],
is_last: true,
expandable: true,
expanded: root_open,
root: true,
glyph: None,
label: "Display settings",
selected: false,
draggable: false,
},
|_| {},
);
if root_resp.toggled || root_resp.label.clicked() {
self.display_collapsed = !self.display_collapsed;
}
let mut changed = false;
if root_open {
let n = groups.len();
for (gi, (gname, gfields)) in groups.iter().enumerate() {
let is_last = gi + 1 == n;
let open = !self.closed_groups.contains(gname);
let resp = tree::node(ui, TreeRow::branch(&[], is_last, open, gname), |_| {});
self.hits.insert(format!("group:{gname}"), resp.box_rect);
if resp.toggled || resp.label.clicked() {
if open {
self.closed_groups.insert(gname.clone());
} else {
self.closed_groups.remove(gname);
}
}
if !open {
continue;
}
let base = tree::child_guides(&[], is_last);
let m = gfields.len();
for (fi, &f) in gfields.iter().enumerate() {
changed |= self.settings_leaf(ui, f, &mut settings_json, &base, fi + 1 == m);
}
}
}
if changed {
let json = settings_json.to_string();
let _ = state.apply_settings_json(&json);
store.save("settings", &json);
}
if root_open {
ui.add_space(2.0);
if ui.button("Reset to defaults").clicked() {
state.settings = RenderSettings::default();
let json = state.settings.to_json();
let _ = state.apply_settings_json(&json);
store.save("settings", &json);
}
}
}
fn settings_leaf(
&mut self,
ui: &mut egui::Ui,
field: &FormField,
current: &mut Value,
guides: &[bool],
is_last: bool,
) -> bool {
let mut changed = false;
let mut rect = egui::Rect::NOTHING;
tree::node(ui, TreeRow::leaf(guides, is_last, &field.label), |ui| {
let (ch, r) = form::field_input(ui, field, current, None, None, &mut None);
changed = ch;
rect = r;
});
self.hits.insert(format!("field:{}", field.key()), rect);
changed
}
fn per_solid_color_section(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
let names: Vec<String> = state
.scene
.solids()
.iter()
.map(|solid| solid.name.clone())
.collect();
let open = !self.per_solid_collapsed;
let root_resp = tree::node(
ui,
TreeRow {
guides: &[],
is_last: true,
expandable: true,
expanded: open,
root: true,
glyph: None,
label: "Per-solid colors",
selected: false,
draggable: false,
},
|ui| {
ui.add_space(6.0);
ui.label(egui::RichText::new(format!("{}", names.len())).weak());
},
);
self.hits.insert("box:__per_solid".into(), root_resp.box_rect);
if root_resp.toggled || root_resp.label.clicked() {
self.per_solid_collapsed = !self.per_solid_collapsed;
}
if !open {
return;
}
let base = tree::child_guides(&[], true);
if names.is_empty() {
tree::node(ui, TreeRow::leaf(&base, true, "(no solids)"), |_| {});
return;
}
let mut color_action: Option<(String, Option<String>)> = None;
let m = names.len();
for (i, name) in names.iter().enumerate() {
let last = i + 1 == m;
let current_override = state.scene.solid(name).and_then(|s| s.color_override);
let cached = self.solid_override_edit.get(name).copied();
let mut enabled = current_override.is_some();
let mut rgb = current_override
.map(|c| {
[
(c[0] * 255.0).round() as u8,
(c[1] * 255.0).round() as u8,
(c[2] * 255.0).round() as u8,
]
})
.or(cached)
.unwrap_or([255, 51, 51]);
let mut enable_rect = egui::Rect::NOTHING;
let mut toggled = false;
let mut picker_changed = false;
let resp = tree::node(ui, TreeRow::leaf(&base, last, name), |ui| {
let cb = ui.add(egui::Checkbox::new(&mut enabled, ""));
enable_rect = cb.rect;
toggled = cb.changed();
if enabled {
picker_changed = ui.color_edit_button_srgb(&mut rgb).changed();
}
});
self.hits.insert(format!("solid:{name}"), resp.label.rect);
self.hits.insert(format!("solid-enable:{name}"), enable_rect);
if enabled {
self.solid_override_edit.insert(name.clone(), rgb);
if toggled || picker_changed {
let hex = format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]);
color_action = Some((name.clone(), Some(hex)));
}
} else if toggled {
color_action = Some((name.clone(), None));
}
}
if let Some((name, hex)) = color_action {
state.set_color_override(&name, hex.as_deref());
}
}
#[cfg(target_arch = "wasm32")]
pub fn hits_json(&self) -> String {
let map: serde_json::Map<String, Value> = self
.hits
.iter()
.map(|(k, r)| {
(
k.clone(),
serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
)
})
.collect();
Value::Object(map).to_string()
}
}
#[cfg(target_arch = "wasm32")]
fn publish(name: &str, json: &str) {
if let Some(win) = web_sys::window() {
let _ = js_sys::Reflect::set(
&win,
&wasm_bindgen::JsValue::from_str(name),
&wasm_bindgen::JsValue::from_str(json),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::collections::HashMap as Map;
#[derive(Default)]
struct MemStore {
map: RefCell<Map<String, String>>,
}
impl Store for MemStore {
fn load(&self, key: &str) -> Option<String> {
self.map.borrow().get(key).cloned()
}
fn save(&self, key: &str, val: &str) {
self.map.borrow_mut().insert(key.to_string(), val.to_string());
}
}
fn run_frame(
ctx: &egui::Context,
panel: &mut SettingsPanel,
state: &mut EngineState,
store: &dyn Store,
events: Vec<egui::Event>,
) {
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(400.0, 800.0),
)),
events,
..Default::default()
};
let _ = ctx.run_ui(raw, |ui| panel.body(ui, state, store));
}
fn click_at(
ctx: &egui::Context,
panel: &mut SettingsPanel,
state: &mut EngineState,
store: &dyn Store,
pos: egui::Pos2,
) {
run_frame(
ctx,
panel,
state,
store,
vec![
egui::Event::PointerMoved(pos),
egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::default(),
},
],
);
run_frame(
ctx,
panel,
state,
store,
vec![egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::default(),
}],
);
}
#[test]
fn settings_tree_renders_every_field_as_a_leaf() {
let ctx = egui::Context::default();
let mut state = EngineState::new();
let mut panel = SettingsPanel::new();
let store = MemStore::default();
run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
for field in settings_form_fields() {
let key = format!("field:{}", field.key());
assert!(
panel.hits.contains_key(&key),
"missing tree leaf for settings field {key}; have {:?}",
panel.hits.keys().collect::<Vec<_>>()
);
}
}
#[test]
fn settings_tree_field_edit_applies_and_persists() {
let ctx = egui::Context::default();
let mut state = EngineState::new();
let mut panel = SettingsPanel::new();
let store = MemStore::default();
run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
assert!(!state.settings.wireframe, "wireframe starts off");
let rect = *panel
.hits
.get("field:wireframe")
.expect("wireframe leaf checkbox rect");
click_at(&ctx, &mut panel, &mut state, &store, rect.center());
assert!(
state.settings.wireframe,
"clicking the checkbox flips the engine setting through apply_settings_json"
);
let saved: Value = serde_json::from_str(
&store.load("settings").expect("edit persisted through the store"),
)
.unwrap();
assert_eq!(
saved["wireframe"],
Value::Bool(true),
"the whole settings JSON is persisted with the edit"
);
}
#[test]
fn settings_tree_edit_preserves_externally_changed_setting() {
let ctx = egui::Context::default();
let mut state = EngineState::new();
let mut panel = SettingsPanel::new();
let store = MemStore::default();
run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
state.settings.wireframe = true;
assert!(state.settings.wireframe);
run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
let before = state.settings.flat_shading;
let rect = *panel
.hits
.get("field:flatShading")
.expect("flatShading leaf checkbox rect");
click_at(&ctx, &mut panel, &mut state, &store, rect.center());
assert_ne!(
state.settings.flat_shading, before,
"the tree edit took effect"
);
assert!(
state.settings.wireframe,
"the externally-set wireframe survived the unrelated tree edit (re-seed intact)"
);
}
}