use super::action_rail::{action_rail, ActionItem};
use crate::form;
use brep_render::engine_state::EngineState;
use brep_render::features;
use brep_render::style::FieldKind;
use eframe::egui;
use serde_json::Value;
use std::collections::HashMap;
pub type FocusRequest = Option<String>;
#[derive(Default)]
pub struct ContextOutcome {
pub focus: FocusRequest,
pub info_targets: Vec<String>,
}
#[derive(Default)]
pub struct ContextBarPanel {
hits: HashMap<String, egui::Rect>,
shown_actions: Vec<String>,
shown_features: Vec<String>,
}
impl ContextBarPanel {
pub fn new() -> Self {
Self::default()
}
pub fn card(&mut self, ui: &mut egui::Ui, state: &mut EngineState) -> ContextOutcome {
self.hits.clear();
self.shown_actions.clear();
self.shown_features.clear();
if !state.has_selection() || state.ref_select_active() || state.sketch_mode() {
return ContextOutcome::default();
}
let sel = Selection::read(state);
let offers = feature_offers(&sel);
let mut items = vec![
ActionItem::new("action:clear", "\u{2716} Clear", "Clear the selection"),
ActionItem::new("action:hide", "\u{1f441} Hide", "Hide/Show selection"),
ActionItem::new(
"action:info",
"\u{1f575} Info",
"Open a pinned Info window per selected entity",
),
];
self.shown_actions.push("clear".into());
self.shown_actions.push("hide".into());
self.shown_actions.push("info".into());
if sel.owning_feature.is_some() {
items.push(ActionItem::new(
"action:edit-owning",
"Edit owning feature",
"Roll to and edit the feature that created this",
));
self.shown_actions.push("edit-owning".into());
}
for offer in &offers {
items.push(ActionItem::new(
format!("feature:{}", offer.type_code),
offer.label.clone(),
format!("Create {} from the selection", offer.label),
));
self.shown_features.push(offer.type_code.clone());
}
let summary = sel.summary();
let clicked = egui::Frame::popup(ui.style())
.show(ui, |ui| {
action_rail(
ui,
Some("Selection actions"),
Some(&summary),
&items,
&mut self.hits,
)
})
.inner;
let mut outcome = ContextOutcome::default();
match clicked.as_deref() {
Some("action:clear") => {
state.clear_selection();
}
Some("action:hide") => {
state.hide_selected();
}
Some("action:info") => {
outcome.info_targets = sel.all_names();
}
Some("action:edit-owning") => {
if let Some(fid) = sel.owning_feature.clone() {
if let Some(index) = feature_index(state, &fid) {
state.roll_to(index);
}
outcome.focus = Some(fid);
}
}
Some(key) if key.starts_with("feature:") => {
let code = &key["feature:".len()..];
if let Some(offer) = offers.iter().find(|o| o.type_code == code) {
outcome.focus = create_feature_from_selection(state, offer, &sel);
}
}
_ => {}
}
outcome
}
#[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")]
pub fn state_json(&self) -> String {
serde_json::json!({
"shown": !self.hits.is_empty(),
"actions": self.shown_actions,
"features": self.shown_features,
})
.to_string()
}
}
struct Selection {
solids: Vec<String>,
faces: Vec<String>,
edges: Vec<String>,
vertices: usize,
owning_feature: Option<String>,
}
impl Selection {
fn read(state: &EngineState) -> Self {
let v: Value = serde_json::from_str(&state.selection_json()).unwrap_or(Value::Null);
let names = |key: &str| -> Vec<String> {
v[key]
.as_array()
.map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
.unwrap_or_default()
};
let solids = names("solids");
let faces = names("faces");
let edges = names("edges");
let vertices = v["vertices"].as_u64().unwrap_or(0) as usize;
let total = solids.len() + faces.len() + edges.len();
let single = if total == 1 && vertices == 0 {
faces
.first()
.or_else(|| edges.first())
.or_else(|| solids.first())
.cloned()
} else {
None
};
let owning_feature = single
.as_deref()
.and_then(|name| state.creating_feature(name))
.map(|(id, _ty)| id);
Self {
solids,
faces,
edges,
vertices,
owning_feature,
}
}
fn kinds_present(&self) -> Vec<&'static str> {
let mut kinds = Vec::new();
if !self.solids.is_empty() {
kinds.push("SOLID");
}
if !self.faces.is_empty() {
kinds.push("FACE");
}
if !self.edges.is_empty() {
kinds.push("EDGE");
}
kinds
}
fn names_for_filter(&self, filter: &[String]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let push = |src: &[String], out: &mut Vec<String>| {
for name in src {
if !out.iter().any(|n| n == name) {
out.push(name.clone());
}
}
};
for f in filter {
match f.as_str() {
"SOLID" | "COMPONENT" => push(&self.solids, &mut out),
"FACE" | "PLANE" => push(&self.faces, &mut out),
"EDGE" => push(&self.edges, &mut out),
_ => {}
}
}
out
}
fn all_names(&self) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for src in [&self.solids, &self.faces, &self.edges] {
for name in src {
if !name.is_empty() && !out.iter().any(|n| n == name) {
out.push(name.clone());
}
}
}
out
}
fn summary(&self) -> String {
format!(
"Selected: {} solid, {} face, {} edge, {} vertex",
self.solids.len(),
self.faces.len(),
self.edges.len(),
self.vertices,
)
}
}
struct Offer {
type_code: String,
label: String,
ref_path: Vec<String>,
filter: Vec<String>,
multiple: bool,
}
fn feature_offers(sel: &Selection) -> Vec<Offer> {
let kinds = sel.kinds_present();
if kinds.is_empty() {
return Vec::new();
}
let catalogue = features::feature_catalogue();
let mut out = Vec::new();
if let Some(list) = catalogue.get("features").and_then(Value::as_array) {
for feature in list {
let Some(ty) = feature.get("type").and_then(Value::as_str) else {
continue;
};
if ty.is_empty() {
continue;
}
let fields = features::feature_form_fields(ty);
let Some(primary) = fields
.iter()
.find(|f| matches!(f.kind, FieldKind::Reference { .. }) && f.group == "References")
else {
continue;
};
let FieldKind::Reference { filter, multiple } = &primary.kind else {
continue;
};
if !filter
.iter()
.any(|f| kinds.iter().any(|k| *k == f.as_str()))
{
continue;
}
out.push(Offer {
type_code: ty.to_string(),
label: features::feature_long_name(ty),
ref_path: primary.path.clone(),
filter: filter.clone(),
multiple: *multiple,
});
}
}
out
}
fn create_feature_from_selection(
state: &mut EngineState,
offer: &Offer,
sel: &Selection,
) -> Option<String> {
let id = state.next_feature_id(&features::feature_short_name(&offer.type_code));
let mut params = features::feature_default_params(&offer.type_code);
if let Value::Object(map) = &mut params {
map.insert("id".into(), Value::String(id.clone()));
}
let names = sel.names_for_filter(&offer.filter);
let value = if offer.multiple {
Value::Array(names.into_iter().map(Value::String).collect())
} else {
Value::String(names.into_iter().next().unwrap_or_default())
};
form::set_at(&mut params, &offer.ref_path, value);
let feature = serde_json::json!({
"type": offer.type_code,
"inputParams": params,
"persistentData": {},
});
if state.add_feature(&feature.to_string()).is_ok() {
Some(id)
} else {
None
}
}
fn feature_index(state: &EngineState, id: &str) -> Option<usize> {
(0..state.history_len()).find(|&i| state.feature_id_at(i).as_deref() == Some(id))
}
#[cfg(test)]
mod tests {
use super::*;
fn sel_of(solids: &[&str], faces: &[&str], edges: &[&str]) -> Selection {
Selection {
solids: solids.iter().map(|s| s.to_string()).collect(),
faces: faces.iter().map(|s| s.to_string()).collect(),
edges: edges.iter().map(|s| s.to_string()).collect(),
vertices: 0,
owning_feature: None,
}
}
#[test]
fn face_selection_offers_face_features_not_solid_ones() {
let offers = feature_offers(&sel_of(&[], &["Box_PZ"], &[]));
let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
for want in ["E", "O.F", "PF", "O.S", "THK", "DF", "F", "CH"] {
assert!(codes.contains(&want), "FACE should offer {want}: {codes:?}");
}
for nope in ["B", "M", "XFORM", "PATTERN", "SPL", "RIB"] {
assert!(!codes.contains(&nope), "FACE must not offer {nope}: {codes:?}");
}
assert!(!codes.contains(&"P.CU"));
}
#[test]
fn edge_selection_offers_fillet_chamfer_tube() {
let offers = feature_offers(&sel_of(&[], &[], &["Box_E0"]));
let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
for want in ["F", "CH", "TU"] {
assert!(codes.contains(&want), "EDGE should offer {want}: {codes:?}");
}
assert!(!codes.contains(&"E"), "EDGE must not offer Extrude: {codes:?}");
}
#[test]
fn solid_selection_offers_solid_features() {
let offers = feature_offers(&sel_of(&["Box"], &[], &[]));
let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
for want in ["B", "M", "XFORM", "PATTERN", "SPL", "RIB"] {
assert!(codes.contains(&want), "SOLID should offer {want}: {codes:?}");
}
assert!(!codes.contains(&"F"), "SOLID must not offer Fillet: {codes:?}");
}
#[test]
fn empty_selection_offers_nothing() {
assert!(feature_offers(&sel_of(&[], &[], &[])).is_empty());
}
#[test]
fn extrude_primary_reference_is_single_profile() {
let offers = feature_offers(&sel_of(&[], &["F1"], &[]));
let extrude = offers.iter().find(|o| o.type_code == "E").expect("extrude offered");
assert_eq!(extrude.ref_path, vec!["profile".to_string()]);
assert!(!extrude.multiple, "extrude profile is a single reference");
assert!(extrude.filter.iter().any(|f| f == "FACE"));
}
#[test]
fn all_names_gathers_every_named_entity_for_info_windows() {
let sel = sel_of(&["Box"], &["Box_PZ", "Box_NZ"], &["Box_E0"]);
assert_eq!(sel.all_names(), ["Box", "Box_PZ", "Box_NZ", "Box_E0"]);
assert!(sel_of(&[], &[], &[]).all_names().is_empty());
}
#[test]
fn names_for_filter_maps_kinds() {
let sel = sel_of(&["Box"], &["Box_PZ", "Box_NZ"], &["Box_E0"]);
assert_eq!(sel.names_for_filter(&["FACE".into()]), ["Box_PZ", "Box_NZ"]);
assert_eq!(sel.names_for_filter(&["EDGE".into()]), ["Box_E0"]);
assert_eq!(sel.names_for_filter(&["SOLID".into()]), ["Box"]);
assert_eq!(
sel.names_for_filter(&["FACE".into(), "EDGE".into()]),
["Box_PZ", "Box_NZ", "Box_E0"]
);
}
}