use super::action_rail::{action_rail, ActionItem};
use super::component_actions::{run_component_action, ComponentAction, ComponentActionRequest};
use crate::form;
use brep_render::brep_kernel::{self, SelectionProbe};
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, HashSet};
pub type FocusRequest = Option<String>;
#[derive(Default)]
pub struct ContextOutcome {
pub focus: FocusRequest,
pub info_targets: Vec<String>,
pub component: Option<ComponentActionRequest>,
}
#[derive(Default)]
pub struct ContextBarPanel {
hits: HashMap<String, egui::Rect>,
shown_actions: Vec<String>,
shown_features: Vec<String>,
shown_constraints: Vec<String>,
shown_component_actions: Vec<String>,
shown_component_target: Option<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();
self.shown_constraints.clear();
self.shown_component_actions.clear();
self.shown_component_target = None;
let has_geometry = state.has_selection();
let constraint_target = state.selected_constraint().filter(|_| {
crate::workbench::panel_visible(
&state.settings.workbench,
crate::workbench::assembly::CONSTRAINTS_PANEL_ID,
)
});
if (!has_geometry && constraint_target.is_none())
|| state.ref_select_active()
|| state.sketch_mode()
{
return ContextOutcome::default();
}
let sel = Selection::read(state);
let comp = component_selection(&sel, state);
let probe = selection_probe(&sel, &comp, all_on_sheet_metal(&sel, state));
let offers = if comp.suppress_features() {
Vec::new()
} else {
feature_offers(&probe, &sel, &state.settings.workbench)
};
let constraint_types = constraint_offers(&probe, &state.settings.workbench);
let component_target = component_action_target(&comp, &state.settings.workbench).map(|id| {
let fixed = state.component_info(id).map(|info| info.fixed).unwrap_or(false);
(id.to_string(), fixed)
});
let mut items = vec![ActionItem::new(
"action:clear",
"\u{2716} Clear",
"Clear the selection",
)];
self.shown_actions.push("clear".into());
if has_geometry {
items.push(ActionItem::new("action:hide", "\u{1f441} Hide", "Hide/Show selection"));
items.push(ActionItem::new(
"action:info",
"\u{1f575} Info",
"Open a pinned Info window per selected entity",
));
self.shown_actions.push("hide".into());
self.shown_actions.push("info".into());
}
if let Some(cid) = &constraint_target {
items.push(ActionItem::new(
"action:delete-constraint",
"\u{2715} Delete constraint",
format!("Delete constraint {cid}"),
));
self.shown_actions.push("delete-constraint".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());
}
if let Some((target, fixed)) = &component_target {
for action in ComponentAction::ALL {
items.push(ActionItem::new(
format!("component:{}", action.id()),
action.label(*fixed),
action.tooltip(),
));
self.shown_component_actions.push(action.id().to_string());
}
self.shown_component_target = Some(target.clone());
}
for def in &constraint_types {
items.push(ActionItem::new(
format!("constraint:{}", def.type_id),
def.long_name,
format!("Add a {} constraint from the selection", def.label),
));
self.shown_constraints.push(def.type_id.to_string());
}
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 = match (&constraint_target, has_geometry) {
(Some(cid), false) => format!("Selected: constraint {cid}"),
_ => 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:delete-constraint") => {
if let Some(cid) = &constraint_target {
let _ = state.assembly_remove_constraint(cid);
state.constraint_deselect();
}
}
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("component:") => {
if let Some((target, _)) = &component_target {
if let Some(action) = ComponentAction::from_id(&key["component:".len()..]) {
outcome.component = run_component_action(state, action, target);
}
}
}
Some(key) if key.starts_with("constraint:") => {
let type_id = &key["constraint:".len()..];
if constraint_types.iter().any(|def| def.type_id == type_id) {
add_constraint_from_selection(state, type_id);
}
}
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,
"constraints": self.shown_constraints,
"componentActions": self.shown_component_actions,
"componentTarget": self.shown_component_target,
})
.to_string()
}
}
struct ComponentSelection {
ids: Vec<String>,
all_component: bool,
solids_only: bool,
}
impl ComponentSelection {
fn suppress_features(&self) -> bool {
self.all_component && !self.ids.is_empty()
}
fn sole_target(&self) -> Option<&str> {
(self.suppress_features() && self.solids_only && self.ids.len() == 1)
.then(|| self.ids[0].as_str())
}
}
fn component_selection(sel: &Selection, state: &EngineState) -> ComponentSelection {
let mut ids: Vec<String> = Vec::new();
let mut all = true;
let mut any = false;
for name in sel.all_names() {
any = true;
match state.component_of_solid(&name) {
Some(id) => {
if !ids.contains(&id) {
ids.push(id);
}
}
None => all = false,
}
}
if sel.vertices > 0 {
all = false;
}
ComponentSelection {
ids,
all_component: all && any,
solids_only: !sel.solids.is_empty()
&& sel.sketches.is_empty()
&& sel.faces.is_empty()
&& sel.edges.is_empty()
&& sel.vertices == 0,
}
}
struct Selection {
solids: Vec<String>,
sketches: Vec<String>,
faces: Vec<String>,
edges: Vec<String>,
planes: 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 sketch_ids: std::collections::HashSet<String> = state
.committed_sketches()
.into_iter()
.map(|(id, _visible)| id)
.collect();
let (sketches, solids): (Vec<String>, Vec<String>) = names("solids")
.into_iter()
.partition(|name| sketch_ids.contains(name));
let faces = names("faces");
let edges = names("edges");
let planes = names("datums");
let vertices = v["vertices"].as_u64().unwrap_or(0) as usize;
let total = solids.len() + sketches.len() + faces.len() + edges.len() + planes.len();
let single = if total == 1 && vertices == 0 {
faces
.first()
.or_else(|| edges.first())
.or_else(|| solids.first())
.or_else(|| sketches.first())
.or_else(|| planes.first())
.cloned()
} else {
None
};
let owning_feature = single
.as_deref()
.and_then(|name| state.creating_feature(name))
.map(|(id, _ty)| id);
Self {
solids,
sketches,
faces,
edges,
planes,
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.sketches.is_empty() {
kinds.push("SKETCH");
}
if !self.faces.is_empty() {
kinds.push("FACE");
}
if !self.edges.is_empty() {
kinds.push("EDGE");
}
if !self.planes.is_empty() {
kinds.push("PLANE");
}
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),
"SKETCH" => push(&self.sketches, &mut out),
"FACE" => push(&self.faces, &mut out),
"PLANE" | "DATUM" => push(&self.planes, &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.sketches, &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, {} sketch, {} face, {} edge, {} plane, {} vertex",
self.solids.len(),
self.sketches.len(),
self.faces.len(),
self.edges.len(),
self.planes.len(),
self.vertices,
)
}
}
struct OfferField {
path: Vec<String>,
filter: Vec<String>,
multiple: bool,
}
struct Offer {
type_code: String,
label: String,
fields: Vec<OfferField>,
}
fn selection_probe(
sel: &Selection,
comp: &ComponentSelection,
all_sheet_metal: bool,
) -> SelectionProbe {
SelectionProbe {
solids: sel.solids.len(),
sketches: sel.sketches.len(),
faces: sel.faces.len(),
edges: sel.edges.len(),
planes: sel.planes.len(),
vertices: sel.vertices,
components: comp.ids.len(),
all_component: comp.all_component,
all_sheet_metal,
}
}
fn all_on_sheet_metal(sel: &Selection, state: &EngineState) -> bool {
let names = sel.all_names();
!names.is_empty()
&& sel.vertices == 0
&& names.iter().all(|name| state.is_sheet_metal_object(name))
}
fn feature_offers(probe: &SelectionProbe, sel: &Selection, workbench: &str) -> 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;
}
if !crate::workbench::includes_feature(workbench, ty) {
continue;
}
if !brep_kernel::feature_context_applicable(ty, probe) {
continue;
}
let fields: Vec<OfferField> = features::feature_form_fields(ty)
.iter()
.filter(|field| field.group == "References")
.filter_map(|field| {
let FieldKind::Reference { filter, multiple } = &field.kind else {
return None;
};
filter
.iter()
.any(|f| kinds.iter().any(|k| *k == f.as_str()))
.then(|| OfferField {
path: field.path.clone(),
filter: filter.clone(),
multiple: *multiple,
})
})
.collect();
out.push(Offer {
type_code: ty.to_string(),
label: features::feature_long_name(ty),
fields,
});
}
}
out
}
fn component_action_target<'a>(comp: &'a ComponentSelection, workbench: &str) -> Option<&'a str> {
let list_shown = crate::workbench::panel_visible(
workbench,
crate::workbench::assembly::BOM_PANEL_ID,
);
list_shown.then(|| comp.sole_target()).flatten()
}
fn constraint_offers(
probe: &SelectionProbe,
workbench: &str,
) -> Vec<&'static brep_kernel::ConstraintTypeDef> {
if !crate::workbench::panel_visible(workbench, crate::workbench::assembly::CONSTRAINTS_PANEL_ID)
{
return Vec::new();
}
brep_kernel::CONSTRAINT_TYPES
.iter()
.filter(|def| (def.applicable)(probe))
.collect()
}
fn add_constraint_from_selection(state: &mut EngineState, type_id: &str) {
let catalogue = brep_kernel::constraint_schema_catalogue();
let schemas: Vec<Value> = catalogue.as_array().cloned().unwrap_or_default();
let seed = super::assembly_constraints::seeded_elements(state, &schemas, type_id);
if let Ok(id) = state.assembly_add_constraint(type_id, &seed.to_string()) {
let _ = state.assembly_set_constraint_open(&id, true);
}
}
fn prefill_references(fields: &[OfferField], sel: &Selection) -> Vec<(Vec<String>, Value)> {
let mut consumed: HashSet<String> = HashSet::new();
let mut writes = Vec::new();
for field in fields {
let names: Vec<String> = sel
.names_for_filter(&field.filter)
.into_iter()
.filter(|name| !consumed.contains(name))
.collect();
if names.is_empty() {
continue;
}
let value = if field.multiple {
consumed.extend(names.iter().cloned());
Value::Array(names.into_iter().map(Value::String).collect())
} else {
let name = names.into_iter().next().unwrap_or_default();
consumed.insert(name.clone());
Value::String(name)
};
writes.push((field.path.clone(), value));
}
writes
}
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()));
}
for (path, value) in prefill_references(&offer.fields, sel) {
form::set_at(&mut params, &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_full(solids: &[&str], sketches: &[&str], faces: &[&str], edges: &[&str]) -> Selection {
Selection {
solids: solids.iter().map(|s| s.to_string()).collect(),
sketches: sketches.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(),
planes: Vec::new(),
vertices: 0,
owning_feature: None,
}
}
fn sel_planes(planes: &[&str]) -> Selection {
Selection {
solids: Vec::new(),
sketches: Vec::new(),
faces: Vec::new(),
edges: Vec::new(),
planes: planes.iter().map(|s| s.to_string()).collect(),
vertices: 0,
owning_feature: None,
}
}
fn sel_of(solids: &[&str], faces: &[&str], edges: &[&str]) -> Selection {
sel_full(solids, &[], faces, edges)
}
fn offers_for(sel: &Selection, workbench: &str) -> Vec<Offer> {
let comp = ComponentSelection {
ids: Vec::new(),
all_component: false,
solids_only: false,
};
feature_offers(&selection_probe(sel, &comp, false), sel, workbench)
}
#[test]
fn face_selection_offers_face_features_not_solid_ones() {
let offers = offers_for(&sel_of(&[], &["Box_PZ"], &[]), "all");
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", "XFORM", "RIB"] {
assert!(!codes.contains(&nope), "FACE must not offer {nope}: {codes:?}");
}
for want in ["M", "PATTERN", "SPL"] {
assert!(
codes.contains(&want),
"FACE should offer {want} via its plane/face field: {codes:?}"
);
}
assert!(!codes.contains(&"P.CU"));
assert!(!codes.contains(&"R"), "FACE alone must not offer Revolve: {codes:?}");
}
#[test]
fn edge_selection_offers_fillet_chamfer_tube() {
let offers = offers_for(&sel_of(&[], &[], &["Box_E0"]), "all");
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:?}");
assert!(!codes.contains(&"R"), "EDGE alone must not offer Revolve: {codes:?}");
}
#[test]
fn sheet_metal_edits_offer_only_on_a_sheet_metal_selection() {
let sel = sel_of(&[], &[], &["Wall_E0"]);
let comp = ComponentSelection {
ids: Vec::new(),
all_component: false,
solids_only: false,
};
let codes = |all_sheet_metal: bool| -> Vec<String> {
feature_offers(&selection_probe(&sel, &comp, all_sheet_metal), &sel, "sheetMetal")
.iter()
.map(|o| o.type_code.clone())
.collect()
};
let on_sm = codes(true);
for want in ["SM.F", "SM.FILLET", "SM.CHAMFER"] {
assert!(on_sm.iter().any(|c| c == want), "sheet-metal edge offers {want}: {on_sm:?}");
}
let plain = codes(false);
for nope in ["SM.F", "SM.FILLET", "SM.CHAMFER"] {
assert!(!plain.iter().any(|c| c == nope), "plain edge must not offer {nope}: {plain:?}");
}
}
#[test]
fn solid_selection_offers_solid_features() {
let offers = offers_for(&sel_of(&["Box"], &[], &[]), "all");
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!(offers_for(&sel_of(&[], &[], &[]), "all").is_empty());
}
#[test]
fn revolve_offer_needs_profile_and_axis_and_prefills_both() {
let sel = sel_of(&[], &["Box_PZ"], &["Box_E0"]);
let offers = offers_for(&sel, "all");
let revolve = offers
.iter()
.find(|o| o.type_code == "R")
.expect("face+edge offers Revolve");
let writes = prefill_references(&revolve.fields, &sel);
assert_eq!(
writes,
vec![
(vec!["profile".to_string()], Value::String("Box_PZ".into())),
(vec!["axis".to_string()], Value::String("Box_E0".into())),
]
);
let sel = sel_full(&[], &["Sk"], &[], &["Box_E0"]);
let offers = offers_for(&sel, "all");
assert!(
offers.iter().any(|o| o.type_code == "R"),
"sketch+edge offers Revolve"
);
}
#[test]
fn prefill_consumes_each_name_once() {
let sel = sel_of(&["Box"], &[], &["Box_E0"]);
let offers = offers_for(&sel, "all");
let pattern = offers.iter().find(|o| o.type_code == "PATTERN").expect("pattern");
let writes = prefill_references(&pattern.fields, &sel);
assert_eq!(
writes,
vec![
(vec!["solids".to_string()], serde_json::json!(["Box"])),
(vec!["directionRef".to_string()], Value::String("Box_E0".into())),
]
);
let sel = sel_of(&[], &["Box_PZ"], &["Box_E0"]);
let offers = offers_for(&sel, "all");
let fillet = offers.iter().find(|o| o.type_code == "F").expect("fillet");
let writes = prefill_references(&fillet.fields, &sel);
assert_eq!(
writes,
vec![(vec!["edges".to_string()], serde_json::json!(["Box_PZ", "Box_E0"]))]
);
}
#[test]
fn workbench_filters_the_context_offers() {
let sel = sel_of(&[], &["Box_PZ"], &[]);
let codes = |wb: &str| -> Vec<String> {
offers_for(&sel, wb).iter().map(|o| o.type_code.clone()).collect()
};
let all = codes("all");
let modeling = codes("modeling");
let sheet = codes("sheetMetal");
assert!(modeling.iter().any(|c| c == "E"), "modeling should offer Extrude: {modeling:?}");
assert!(
!modeling.iter().any(|c| c.starts_with("SM.")),
"modeling must not offer any SM.* feature: {modeling:?}"
);
assert!(
!sheet.iter().any(|c| c == "E"),
"sheet metal must not offer Extrude: {sheet:?}"
);
for c in &modeling {
assert!(all.contains(c), "All should contain modeling offer {c}: {all:?}");
}
}
#[test]
fn extrude_primary_reference_is_single_profile() {
let offers = offers_for(&sel_of(&[], &["F1"], &[]), "all");
let extrude = offers.iter().find(|o| o.type_code == "E").expect("extrude offered");
assert_eq!(extrude.fields.len(), 1, "one matched reference field");
assert_eq!(extrude.fields[0].path, vec!["profile".to_string()]);
assert!(!extrude.fields[0].multiple, "extrude profile is a single reference");
assert!(extrude.fields[0].filter.iter().any(|f| f == "FACE"));
}
#[test]
fn sketch_kind_is_distinct_from_solid() {
assert_eq!(sel_full(&[], &["Sk"], &[], &[]).kinds_present(), ["SKETCH"]);
assert_eq!(sel_full(&["Box"], &[], &[], &[]).kinds_present(), ["SOLID"]);
let mixed = sel_full(&["Box"], &["Sk"], &[], &[]).kinds_present();
assert!(mixed.contains(&"SOLID") && mixed.contains(&"SKETCH"), "mixed: {mixed:?}");
}
#[test]
fn sketch_selection_offers_cutout_and_profile_features() {
let offers = offers_for(&sel_full(&[], &["Sk"], &[], &[]), "all");
let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
for want in ["E", "SM.CUTOUT"] {
assert!(codes.contains(&want), "SKETCH should offer {want}: {codes:?}");
}
assert!(!codes.contains(&"R"), "SKETCH alone must not offer Revolve: {codes:?}");
let cutout = offers
.iter()
.find(|o| o.type_code == "SM.CUTOUT")
.expect("cutout offered for a sketch");
assert_eq!(cutout.fields.len(), 1);
assert_eq!(cutout.fields[0].path, vec!["profile".to_string()]);
assert!(
cutout.fields[0].filter.iter().any(|f| f == "SKETCH"),
"profile filter: {:?}",
cutout.fields[0].filter
);
assert!(!cutout.fields[0].multiple, "cutout profile is a single reference");
}
#[test]
fn solid_selection_offers_cutout_via_sheet() {
let offers = offers_for(&sel_of(&["Plate"], &[], &[]), "all");
let cutout = offers
.iter()
.find(|o| o.type_code == "SM.CUTOUT")
.expect("cutout offered for a solid");
assert_eq!(cutout.fields.len(), 1);
assert_eq!(cutout.fields[0].path, vec!["sheet".to_string()]);
assert!(
cutout.fields[0].filter.iter().any(|f| f == "SOLID"),
"sheet filter: {:?}",
cutout.fields[0].filter
);
let sel = sel_full(&["Plate"], &["Sk"], &[], &[]);
let offers = offers_for(&sel, "all");
let cutout = offers
.iter()
.find(|o| o.type_code == "SM.CUTOUT")
.expect("cutout offered for solid+sketch");
let writes = prefill_references(&cutout.fields, &sel);
assert_eq!(
writes,
vec![
(vec!["sheet".to_string()], Value::String("Plate".into())),
(vec!["profile".to_string()], Value::String("Sk".into())),
]
);
}
#[test]
fn add_constraint_from_selection_seeds_adds_and_opens() {
use crate::panels::component_actions::tests::assembly_engine;
let mut state = assembly_engine();
state.select_component("ACOMP2");
add_constraint_from_selection(&mut state, "fixed");
let constraints = state.assembly_state_value();
let entry = constraints["constraints"]
.as_array()
.and_then(|list| list.last())
.cloned()
.expect("constraint added");
assert_eq!(entry["type"], "fixed");
assert_eq!(entry["inputParams"]["elements"], serde_json::json!(["ACOMP2"]));
assert_eq!(entry["open"], serde_json::json!(true), "row opens for editing");
}
#[test]
fn constraint_offers_follow_predicates_and_workbench() {
let one_component = SelectionProbe {
solids: 1,
components: 1,
all_component: true,
..Default::default()
};
let pair = SelectionProbe {
faces: 2,
components: 2,
all_component: true,
..Default::default()
};
let ids = |probe: &SelectionProbe, wb: &str| -> Vec<&str> {
constraint_offers(probe, wb).iter().map(|d| d.type_id).collect()
};
assert_eq!(ids(&one_component, "assembly"), ["fixed"]);
let pair_ids = ids(&pair, "assembly");
for want in [
"coincident",
"touch_align",
"parallel",
"distance",
"angle",
"concentric",
"perpendicular",
"tangent",
] {
assert!(pair_ids.contains(&want), "pair should offer {want}: {pair_ids:?}");
}
assert!(!pair_ids.contains(&"fixed"), "pair must not offer fixed");
assert!(!ids(&pair, "all").is_empty());
assert!(ids(&pair, "modeling").is_empty());
let plain = SelectionProbe { faces: 2, ..Default::default() };
assert!(ids(&plain, "assembly").is_empty());
}
#[test]
fn names_for_filter_maps_sketch_kind() {
let sel = sel_full(&["Box"], &["Sk1", "Sk2"], &["Box_PZ"], &[]);
assert_eq!(
sel.names_for_filter(&["FACE".into(), "SKETCH".into()]),
["Box_PZ", "Sk1", "Sk2"]
);
assert_eq!(sel.names_for_filter(&["SKETCH".into()]), ["Sk1", "Sk2"]);
assert_eq!(sel.names_for_filter(&["SOLID".into()]), ["Box"]);
}
#[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 component_selection_detects_single_component_and_fences_features() {
use crate::panels::component_actions::tests::assembly_engine;
let engine = assembly_engine();
let sel = sel_of(&["ACOMP2:Part"], &[], &[]);
let comp = component_selection(&sel, &engine);
assert!(comp.suppress_features());
assert_eq!(comp.sole_target(), Some("ACOMP2"));
let sel = sel_of(&["ACOMP1:Part", "ACOMP2:Part"], &[], &[]);
let comp = component_selection(&sel, &engine);
assert!(comp.suppress_features());
assert_eq!(comp.sole_target(), None);
let sel = sel_of(&[], &["ACOMP1:Part_PZ"], &[]);
let comp = component_selection(&sel, &engine);
assert!(comp.suppress_features());
assert_eq!(comp.sole_target(), None);
let sel = sel_of(&["Box"], &[], &[]);
let comp = component_selection(&sel, &engine);
assert!(!comp.suppress_features());
assert_eq!(comp.sole_target(), None);
let sel = sel_of(&["ACOMP2:Part", "Box"], &[], &[]);
let comp = component_selection(&sel, &engine);
assert!(!comp.suppress_features());
assert_eq!(comp.sole_target(), None);
let sel = sel_of(&["ACOMP9:Part"], &[], &[]);
assert!(!component_selection(&sel, &engine).suppress_features());
}
#[test]
fn component_actions_are_workbench_gated() {
use crate::panels::component_actions::tests::assembly_engine;
let engine = assembly_engine();
let sel = sel_of(&["ACOMP2:Part"], &[], &[]);
let comp = component_selection(&sel, &engine);
assert_eq!(comp.sole_target(), Some("ACOMP2"), "selection shape qualifies");
for wb in ["assembly", "all"] {
assert_eq!(
component_action_target(&comp, wb),
Some("ACOMP2"),
"component actions offered under `{wb}`"
);
}
for wb in ["modeling", "sheetMetal", "wireHarness", "pmi"] {
assert_eq!(
component_action_target(&comp, wb),
None,
"component actions must not bleed into `{wb}`"
);
assert!(comp.suppress_features(), "feature fence holds under `{wb}`");
}
}
#[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"]
);
}
#[test]
fn plane_selection_present_kind_is_plane() {
assert_eq!(sel_planes(&["Datum:XY"]).kinds_present(), ["PLANE"]);
assert_eq!(sel_of(&[], &["Box_PZ"], &[]).kinds_present(), ["FACE"]);
}
#[test]
fn names_for_filter_maps_planes_separately_from_faces() {
let planes = sel_planes(&["Datum:XY"]);
assert_eq!(
planes.names_for_filter(&["PLANE".into(), "FACE".into()]),
["Datum:XY"]
);
assert_eq!(planes.names_for_filter(&["DATUM".into()]), ["Datum:XY"]);
assert!(planes.names_for_filter(&["FACE".into()]).is_empty());
let faces = sel_of(&[], &["Box_PZ"], &[]);
assert_eq!(
faces.names_for_filter(&["PLANE".into(), "FACE".into()]),
["Box_PZ"]
);
assert!(faces.names_for_filter(&["PLANE".into()]).is_empty());
}
#[test]
fn plane_selection_offers_sketch_and_prefills_the_plane() {
let sel = sel_planes(&["Datum:XY"]);
let offers = offers_for(&sel, "all");
let sketch = offers
.iter()
.find(|o| o.type_code == "S")
.expect("a plane-only selection offers Sketch");
let writes = prefill_references(&sketch.fields, &sel);
assert!(
writes.contains(&(vec!["sketchPlane".to_string()], Value::String("Datum:XY".into()))),
"sketchPlane prefilled with the datum frame: {writes:?}"
);
let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
for nope in ["E", "F", "CH", "B", "XFORM"] {
assert!(!codes.contains(&nope), "plane alone must not offer {nope}: {codes:?}");
}
}
}