use crate::automation::hit_keys::HitKeyDoc;
use crate::column_tree::{self, CellKind, ColumnLayout, ColumnSpec, ColumnTreeSpec, RowAction, RowNode};
use brep_render::engine_state::{ConnectionPatch, EngineState};
use brep_render::brep_kernel::{PortKind, RouteStatus, WireHarnessReport};
use eframe::egui;
use serde_json::Value;
use std::collections::HashMap;
const NAME: &str = "name";
const FROM: &str = "from";
const TO: &str = "to";
const DIAMETER: &str = "diameter";
const LENGTH: &str = "length";
const STATUS: &str = "status";
const ACTIONS: &str = "actions";
const REMOVE: &str = "remove";
const ROUTED_COLOR: &str = "#3fb950";
const WARN_COLOR: &str = "#d29922";
const ERROR_COLOR: &str = "#f85149";
pub struct WireHarnessPanel {
hits: HashMap<String, egui::Rect>,
layout: ColumnLayout,
columns: Vec<ColumnSpec>,
hovered: Option<String>,
}
impl Default for WireHarnessPanel {
fn default() -> Self {
Self::new()
}
}
impl WireHarnessPanel {
pub fn new() -> Self {
Self {
hits: HashMap::new(),
layout: ColumnLayout::default(),
columns: Vec::new(),
hovered: None,
}
}
pub fn show(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
self.hits.clear();
self.hits.insert("wh:panel:clip".into(), ui.clip_rect());
let harness = state.wire_harness_state();
let empty = WireHarnessReport::default();
let report = state.wire_harness_report().cloned().unwrap_or(empty);
let endpoints = endpoint_choices(&report);
let routed = report.routes.iter().filter(|route| route.feasible).count();
let mut add_clicked = false;
let mut bundles = harness.build_bundles;
let mut bundles_changed = false;
ui.horizontal(|ui| {
let add = ui
.button("Add wire")
.on_hover_text("Add a connection between two termination ports");
self.hits.insert("wh:add".into(), add.rect);
add_clicked = add.clicked();
let toggle = ui
.checkbox(&mut bundles, "Build bundles")
.on_hover_text("Sweep a bundle solid along every routed segment (off keeps the routing only)");
self.hits.insert("wh:bundles".into(), toggle.rect);
bundles_changed = toggle.changed();
ui.label(
egui::RichText::new(format!(
"{} connection{} | {} endpoint{} | {routed} routed",
harness.connections.len(),
if harness.connections.len() == 1 { "" } else { "s" },
endpoints.len(),
if endpoints.len() == 1 { "" } else { "s" },
))
.weak(),
);
});
for problem in &report.segment_problems {
ui.label(egui::RichText::new(problem).weak().color(hex(WARN_COLOR)));
}
ui.add_space(2.0);
self.sync_columns(&endpoints);
let rows: Vec<RowNode> = harness
.connections
.iter()
.map(|connection| {
let route = report
.routes
.iter()
.find(|route| route.connection_id == connection.id);
let (status_text, status_color, tooltip) = match route {
Some(route) if route.feasible => ("Routed", ROUTED_COLOR, String::new()),
Some(route) => (status_word(route.status), status_color(route.status), route.message.clone()),
None => ("Not routed", WARN_COLOR, "the model has not run yet".to_string()),
};
RowNode::new(&connection.id)
.cell(NAME, Value::String(connection.name.clone()))
.cell(FROM, Value::String(label_for(&endpoints, &connection.from)))
.cell(TO, Value::String(label_for(&endpoints, &connection.to)))
.cell(DIAMETER, Value::from(connection.diameter))
.cell(
LENGTH,
Value::String(
route
.and_then(|route| route.length)
.map(format_length)
.unwrap_or_else(|| "\u{2014}".to_string()),
),
)
.cell(
STATUS,
serde_json::json!([{ "glyph": status_text, "color": status_color, "tooltip": tooltip }]),
)
.actions(vec![RowAction::new(REMOVE, "Remove wire")
.tooltip("Delete this connection")
.destructive()])
})
.collect();
let spec = ColumnTreeSpec {
id: "wire-harness",
columns: &self.columns,
root_label: Some("Wires"),
root_cells: None,
empty_hint: Some(
"(no connections — add ports, attach spline ends to them, then Add wire)",
),
hits_prefix: "wh:",
};
let out = column_tree::column_tree(ui, &spec, &mut self.layout, &rows, Some(&mut self.hits));
if out.hovered != self.hovered {
match &out.hovered {
Some(id) => state.wire_harness_hover_connection(id),
None => state.wire_harness_hover_end(),
}
self.hovered = out.hovered.clone();
}
if add_clicked {
let terminations: Vec<&str> = endpoints
.iter()
.filter(|choice| choice.termination)
.map(|choice| choice.id.as_str())
.collect();
let from = terminations.first().copied().unwrap_or("");
let to = terminations.get(1).or(terminations.first()).copied().unwrap_or("");
state.wire_harness_add_connection(from, to, 1.0);
return;
}
if bundles_changed {
state.wire_harness_set_build_bundles(bundles);
return;
}
if let Some(click) = out.actions.first() {
if click.action == REMOVE {
if let Err(error) = state.wire_harness_remove_connection(&click.row_id) {
state.push_notice(error);
}
}
return;
}
if let Some(edit) = out.edits.first() {
let patch = match edit.column.as_str() {
NAME => ConnectionPatch {
name: Some(edit.value.as_str().unwrap_or("").to_string()),
..Default::default()
},
FROM => ConnectionPatch {
from: Some(id_for(&endpoints, edit.value.as_str().unwrap_or(""))),
..Default::default()
},
TO => ConnectionPatch {
to: Some(id_for(&endpoints, edit.value.as_str().unwrap_or(""))),
..Default::default()
},
DIAMETER => ConnectionPatch {
diameter: edit.value.as_f64(),
..Default::default()
},
_ => return,
};
if let Err(error) = state.wire_harness_update_connection(&edit.row_id, &patch) {
state.push_notice(error);
}
}
}
fn sync_columns(&mut self, endpoints: &[EndpointChoice]) {
let options: Vec<String> = endpoints
.iter()
.filter(|choice| choice.termination)
.map(|choice| choice.label.clone())
.collect();
let same = self
.columns
.iter()
.find(|column| column.key == FROM)
.is_some_and(|column| column.kind == CellKind::Choice { options: options.clone() });
if same {
return;
}
self.columns = vec![
ColumnSpec::new(NAME, "Wire", CellKind::Text).width(90.0),
ColumnSpec::new(FROM, "From", CellKind::Choice { options: options.clone() }).width(110.0),
ColumnSpec::new(TO, "To", CellKind::Choice { options }).width(110.0),
ColumnSpec::new(DIAMETER, "Dia", CellKind::Numeric { step: 0.1 }).width(60.0),
ColumnSpec::new(LENGTH, "Length", CellKind::ReadOnly).width(70.0),
ColumnSpec::new(STATUS, "Status", CellKind::Badges).width(120.0),
ColumnSpec::new(ACTIONS, "", CellKind::Actions { label: "\u{22EF}".into() }).width(30.0),
];
}
pub fn hits_json(&self) -> String {
crate::automation::hit_rects::hits_json(&self.hits)
}
}
#[derive(Debug, Clone, PartialEq)]
struct EndpointChoice {
id: String,
label: String,
termination: bool,
}
fn endpoint_choices(report: &WireHarnessReport) -> Vec<EndpointChoice> {
let mut counts: HashMap<&str, usize> = HashMap::new();
for endpoint in &report.endpoints {
*counts.entry(endpoint.label.as_str()).or_default() += 1;
}
report
.endpoints
.iter()
.map(|endpoint| EndpointChoice {
id: endpoint.id.clone(),
label: if counts.get(endpoint.label.as_str()).copied().unwrap_or(0) > 1
|| endpoint.label.is_empty()
{
format!("{} ({})", endpoint.label, endpoint.id)
} else {
endpoint.label.clone()
},
termination: endpoint.kind == PortKind::Termination,
})
.collect()
}
fn label_for(endpoints: &[EndpointChoice], id: &str) -> String {
endpoints
.iter()
.find(|choice| choice.id == id)
.map(|choice| choice.label.clone())
.unwrap_or_else(|| id.to_string())
}
fn id_for(endpoints: &[EndpointChoice], label: &str) -> String {
endpoints
.iter()
.find(|choice| choice.label == label)
.map(|choice| choice.id.clone())
.unwrap_or_else(|| label.to_string())
}
fn status_word(status: RouteStatus) -> &'static str {
match status {
RouteStatus::Routed => "Routed",
RouteStatus::MissingEndpoint => "Missing port",
RouteStatus::WaypointEndpoint => "Waypoint end",
RouteStatus::SameEndpoint => "Same port",
RouteStatus::NoSegments => "No splines",
RouteStatus::NoRoute => "No route",
RouteStatus::PortReuse => "Port reused",
}
}
fn status_color(status: RouteStatus) -> &'static str {
match status {
RouteStatus::Routed => ROUTED_COLOR,
RouteStatus::MissingEndpoint | RouteStatus::SameEndpoint | RouteStatus::NoSegments => WARN_COLOR,
RouteStatus::WaypointEndpoint | RouteStatus::NoRoute | RouteStatus::PortReuse => ERROR_COLOR,
}
}
fn format_length(length: f64) -> String {
brep_render::formatting::compact_decimal(length, 2)
}
fn hex(text: &str) -> egui::Color32 {
let digits = text.trim_start_matches('#');
let byte = |at: usize| u8::from_str_radix(&digits[at..at + 2], 16).unwrap_or(0);
egui::Color32::from_rgb(byte(0), byte(2), byte(4))
}
pub static HIT_KEYS: &[HitKeyDoc] = &[
HitKeyDoc { panel: "wireharness", prefix: "wh:add", meaning: "add a connection", command: Some("wire_harness_add_connection") },
HitKeyDoc { panel: "wireharness", prefix: "wh:bundles", meaning: "toggle bundle solids", command: Some("wire_harness_set_build_bundles") },
HitKeyDoc { panel: "wireharness", prefix: "wh:panel:clip", meaning: "the visible region of the pane", command: None },
HitKeyDoc { panel: "wireharness", prefix: "wh:", meaning: "a connection row control", command: Some("wire_harness_update_connection") },
];