use brep_render::engine_state::EngineState;
use eframe::egui;
use serde_json::Value;
use std::collections::{BTreeMap, HashMap};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Tab {
Metadata,
Info,
}
struct PinnedInfoWindow {
target: String,
open: bool,
tab: Tab,
seeded: bool,
values: BTreeMap<String, String>,
new_key: String,
new_value: String,
default_pos: [f32; 2],
hits: HashMap<String, egui::Rect>,
}
impl PinnedInfoWindow {
fn new(target: impl Into<String>, default_pos: [f32; 2]) -> Self {
Self {
target: target.into(),
open: true,
tab: Tab::Info,
seeded: false,
values: BTreeMap::new(),
new_key: String::new(),
new_value: String::new(),
default_pos,
hits: HashMap::new(),
}
}
fn ensure_seeded(&mut self, state: &EngineState) {
if self.seeded {
return;
}
self.seeded = true;
if let Some(record) = parse(&state.object_metadata_json(&self.target)).as_object() {
for (key, value) in record {
self.values
.insert(key.clone(), value.as_str().unwrap_or("").to_string());
}
}
}
fn show(&mut self, ctx: &egui::Context, state: &mut EngineState) {
self.hits.clear();
if !self.open {
return;
}
self.ensure_seeded(state);
let mut open = true;
egui::Window::new(&self.target)
.id(egui::Id::new(("brep-info-window", self.target.as_str())))
.open(&mut open)
.movable(true)
.resizable(true)
.default_size([300.0, 360.0])
.default_pos(self.default_pos)
.show(ctx, |ui| {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| self.body(ui, state));
});
self.open = open;
}
fn body(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
ui.horizontal(|ui| {
let meta = ui.selectable_value(&mut self.tab, Tab::Metadata, "Metadata");
let info = ui.selectable_value(&mut self.tab, Tab::Info, "Info");
self.hits
.insert(format!("{}:tab:metadata", self.target), meta.rect);
self.hits.insert(format!("{}:tab:info", self.target), info.rect);
});
ui.separator();
let name = self.target.clone();
match self.tab {
Tab::Metadata => self.metadata_tab(ui, state, &name),
Tab::Info => info_tab(ui, state, &name),
}
}
fn metadata_tab(&mut self, ui: &mut egui::Ui, state: &mut EngineState, name: &str) {
ui.label("Attributes (name-keyed; survive edits, rollback and re-tessellation):");
ui.add_space(2.0);
let keys: Vec<String> = self.values.keys().cloned().collect();
let mut remove: Option<String> = None;
egui::Grid::new(("info-metadata-grid", name))
.num_columns(3)
.striped(true)
.show(ui, |ui| {
for key in &keys {
ui.label(key);
if let Some(value) = self.values.get_mut(key) {
let edit =
ui.add(egui::TextEdit::singleline(value).desired_width(140.0));
if edit.changed() {
state.set_metadata_attribute(name, key, value);
}
self.hits.insert(format!("{name}:value:{key}"), edit.rect);
}
let del = ui.button("\u{00d7}").on_hover_text("Remove attribute");
self.hits.insert(format!("{name}:remove:{key}"), del.rect);
if del.clicked() {
remove = Some(key.clone());
}
ui.end_row();
}
});
if let Some(key) = remove {
state.remove_metadata_attribute(name, &key);
self.values.remove(&key);
}
if keys.is_empty() {
ui.weak("(no attributes yet)");
}
ui.add_space(6.0);
ui.separator();
ui.label("Add attribute:");
ui.horizontal(|ui| {
let key = ui.add(
egui::TextEdit::singleline(&mut self.new_key)
.hint_text("name")
.desired_width(110.0),
);
let val = ui.add(
egui::TextEdit::singleline(&mut self.new_value)
.hint_text("value")
.desired_width(140.0),
);
let add = ui.button("Add");
self.hits.insert(format!("{name}:new:key"), key.rect);
self.hits.insert(format!("{name}:new:value"), val.rect);
self.hits.insert(format!("{name}:new:add"), add.rect);
let commit = add.clicked()
|| (val.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)));
let trimmed = self.new_key.trim().to_string();
if commit && !trimmed.is_empty() {
state.set_metadata_attribute(name, &trimmed, &self.new_value);
self.values.insert(trimmed, self.new_value.clone());
self.new_key.clear();
self.new_value.clear();
}
});
ui.add_space(6.0);
ui.weak("Hint: the well-known `density` attribute (mass per mm\u{00b3}) drives a solid's weight.");
}
}
#[derive(Default)]
pub struct InfoWindows {
windows: Vec<PinnedInfoWindow>,
opened_count: usize,
}
impl InfoWindows {
pub fn new() -> Self {
Self::default()
}
pub fn open_for(&mut self, names: &[String]) {
for name in names {
if name.is_empty() {
continue;
}
if self.windows.iter().any(|w| w.open && w.target == *name) {
continue;
}
let k = (self.opened_count % 8) as f32;
self.opened_count += 1;
let pos = [1040.0 - 24.0 * k, 56.0 + 24.0 * k];
self.windows.push(PinnedInfoWindow::new(name.clone(), pos));
}
}
pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState) {
for w in &mut self.windows {
w.show(ctx, state);
}
self.prune();
}
fn prune(&mut self) {
self.windows.retain(|w| w.open);
}
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub fn targets(&self) -> Vec<String> {
self.windows
.iter()
.filter(|w| w.open)
.map(|w| w.target.clone())
.collect()
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub fn published_json(&self, state: &mut EngineState) -> String {
let windows: Vec<Value> = self
.windows
.iter()
.filter(|w| w.open)
.map(|w| {
serde_json::json!({
"target": w.target,
"tab": match w.tab { Tab::Metadata => "metadata", Tab::Info => "info" },
"metadata": parse(&state.object_metadata_json(&w.target)),
"info": parse(&state.object_info_json(&w.target)),
})
})
.collect();
serde_json::json!({ "count": windows.len(), "windows": windows }).to_string()
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub fn hits_json(&self) -> String {
let mut map = serde_json::Map::new();
for w in &self.windows {
for (key, rect) in &w.hits {
map.insert(
key.clone(),
serde_json::json!([rect.min.x, rect.min.y, rect.width(), rect.height()]),
);
}
}
Value::Object(map).to_string()
}
}
fn info_tab(ui: &mut egui::Ui, state: &mut EngineState, name: &str) {
let info = parse(&state.object_info_json(name));
if info.get("ok").and_then(Value::as_bool) != Some(true) {
ui.label(
info.get("message")
.and_then(Value::as_str)
.unwrap_or("no info for this object"),
);
return;
}
let kind = info.get("kind").and_then(Value::as_str).unwrap_or("");
egui::Grid::new(("info-info-grid", name))
.num_columns(2)
.striped(true)
.show(ui, |ui| {
grid_row(ui, "Name / ID", name.to_string());
grid_row(ui, "Kind", kind.to_string());
grid_row(ui, "Creating feature", creating_feature_str(&info));
match kind {
"solid" => {
grid_row(ui, "Volume (mm\u{00b3})", num(getf(&info, "volume")));
grid_row(ui, "Surface area (mm\u{00b2})", num(getf(&info, "surfaceArea")));
grid_row(ui, "Edge length total (mm)", num(getf(&info, "edgeLengthTotal")));
grid_row(ui, "Density (mass/mm\u{00b3})", num(getf(&info, "density")));
grid_row(ui, "Weight (mass)", num(getf(&info, "weight")));
}
"face" => {
grid_row(ui, "Solid", getstr(&info, "solid"));
grid_row(ui, "Area (mm\u{00b2})", num(getf(&info, "area")));
grid_row(ui, "Edge length total (mm)", num(getf(&info, "edgeLengthTotal")));
}
"edge" => {
grid_row(ui, "Solid", getstr(&info, "solid"));
grid_row(ui, "Length (mm)", num(getf(&info, "length")));
}
_ => {}
}
});
ui.add_space(6.0);
ui.weak("Read-only. Set `density` on the Metadata tab to drive a solid's weight.");
}
fn creating_feature_str(info: &Value) -> String {
match info.get("creatingFeature") {
Some(Value::Object(feature)) => {
let id = feature.get("id").and_then(Value::as_str).unwrap_or("");
let kind = feature.get("type").and_then(Value::as_str).unwrap_or("");
if kind.is_empty() {
id.to_string()
} else {
format!("{id} ({kind})")
}
}
_ => "\u{2014}".to_string(),
}
}
fn parse(json: &str) -> Value {
serde_json::from_str(json).unwrap_or(Value::Null)
}
fn grid_row(ui: &mut egui::Ui, label: &str, value: String) {
ui.label(label);
ui.label(value);
ui.end_row();
}
fn getf(value: &Value, key: &str) -> f64 {
value.get(key).and_then(Value::as_f64).unwrap_or(0.0)
}
fn getstr(value: &Value, key: &str) -> String {
value.get(key).and_then(Value::as_str).unwrap_or("").to_string()
}
fn num(x: f64) -> String {
if x == 0.0 {
return "0".to_string();
}
let mut s = format!("{x:.3}");
if s.contains('.') {
while s.ends_with('0') {
s.pop();
}
if s.ends_with('.') {
s.pop();
}
}
s
}
#[cfg(test)]
mod tests {
use super::*;
fn names(list: &[&str]) -> Vec<String> {
list.iter().map(|s| s.to_string()).collect()
}
#[test]
fn open_for_creates_one_window_per_name() {
let mut m = InfoWindows::new();
m.open_for(&names(&["A", "B", "C"]));
assert_eq!(m.targets(), names(&["A", "B", "C"]));
}
#[test]
fn dedup_keeps_existing_window_for_same_name() {
let mut m = InfoWindows::new();
m.open_for(&names(&["A"]));
m.open_for(&names(&["A"]));
assert_eq!(m.targets(), names(&["A"]));
}
#[test]
fn later_open_does_not_retarget_earlier_window() {
let mut m = InfoWindows::new();
m.open_for(&names(&["A"]));
m.open_for(&names(&["B"]));
assert_eq!(m.targets(), names(&["A", "B"]));
assert_eq!(m.windows[0].target, "A");
assert_eq!(m.windows[1].target, "B");
}
#[test]
fn empty_names_are_ignored() {
let mut m = InfoWindows::new();
m.open_for(&names(&["", "A", ""]));
assert_eq!(m.targets(), names(&["A"]));
}
#[test]
fn prune_drops_closed_windows() {
let mut m = InfoWindows::new();
m.open_for(&names(&["A", "B"]));
m.windows[0].open = false;
m.prune();
assert_eq!(m.targets(), names(&["B"]));
assert_eq!(m.windows.len(), 1);
}
#[test]
fn reopen_after_close_creates_a_fresh_window() {
let mut m = InfoWindows::new();
m.open_for(&names(&["A"]));
m.windows[0].open = false;
m.prune();
m.open_for(&names(&["A"]));
assert_eq!(m.targets(), names(&["A"]));
}
}