use brep_render::engine_state::EngineState;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ComponentAction {
Move,
OpenPart,
ToggleFixed,
Delete,
}
impl ComponentAction {
pub const ALL: [ComponentAction; 4] = [
ComponentAction::Move,
ComponentAction::OpenPart,
ComponentAction::ToggleFixed,
ComponentAction::Delete,
];
pub fn id(self) -> &'static str {
match self {
ComponentAction::Move => "move",
ComponentAction::OpenPart => "open-part",
ComponentAction::ToggleFixed => "toggle-fixed",
ComponentAction::Delete => "delete",
}
}
pub fn from_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|action| action.id() == id)
}
pub fn label(self, fixed: bool) -> &'static str {
match self {
ComponentAction::Move => "\u{2725} Move",
ComponentAction::OpenPart => "\u{270E} Edit Part",
ComponentAction::ToggleFixed => {
if fixed {
"\u{1F513} Unfix"
} else {
"\u{1F512} Fix"
}
}
ComponentAction::Delete => "\u{2716} Delete",
}
}
pub fn tooltip(self) -> &'static str {
match self {
ComponentAction::Move => "Move/rotate gizmo on-off (arrows + arcs together)",
ComponentAction::OpenPart => "Open the part's source document in its own tab",
ComponentAction::ToggleFixed => "Ground / free this instance for the solver",
ComponentAction::Delete => "Delete this component instance",
}
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum ComponentActionRequest {
OpenPart { component_id: String },
}
pub fn run_component_action(
state: &mut EngineState,
action: ComponentAction,
component_id: &str,
) -> Option<ComponentActionRequest> {
match action {
ComponentAction::Move => {
state.component_move_toggle(component_id);
None
}
ComponentAction::ToggleFixed => {
let Some(info) = state.component_info(component_id) else {
state.push_notice(format!("'{component_id}' is not an assembly component"));
return None;
};
let mut params = serde_json::from_str::<serde_json::Value>(
&state.feature_params_json(feature_index(state, component_id)?),
)
.unwrap_or_else(|_| serde_json::json!({}));
if let Some(object) = params.as_object_mut() {
object.insert("isFixed".into(), serde_json::Value::Bool(!info.fixed));
}
let _ = state.update_feature_params(component_id, ¶ms.to_string());
None
}
ComponentAction::Delete => {
let _ = state.delete_feature(component_id);
None
}
ComponentAction::OpenPart => Some(ComponentActionRequest::OpenPart {
component_id: component_id.to_string(),
}),
}
}
fn feature_index(state: &EngineState, id: &str) -> Option<usize> {
(0..state.history_len()).find(|&i| state.feature_id_at(i).as_deref() == Some(id))
}
pub fn part_source_key(state: &EngineState, component_id: &str) -> Option<String> {
let info = state.component_info(component_id)?;
let document: serde_json::Value =
serde_json::from_str(&state.history_request_json()).ok()?;
document["partsLibrary"][&info.part_name]["sourceKey"]
.as_str()
.filter(|key| !key.is_empty())
.map(str::to_string)
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
pub(crate) fn two_instance_assembly_json() -> String {
serde_json::json!({
"expressions": "",
"configurator": {},
"features": [
{
"type": "ACOMP",
"inputParams": {
"id": "ACOMP1",
"partName": "widget",
"transform": { "translate": [0.0, 0.0, 0.0], "rotateEulerDeg": [0.0, 0.0, 0.0] },
"isFixed": true
},
"persistentData": {}
},
{
"type": "ACOMP",
"inputParams": {
"id": "ACOMP2",
"partName": "widget",
"transform": { "translate": [20.0, 0.0, 0.0], "rotateEulerDeg": [0.0, 0.0, 0.0] }
},
"persistentData": {}
}
],
"partsLibrary": {
"widget": {
"sourceKey": "widget",
"sourceSignature": "sig-1",
"document": {
"expressions": "",
"configurator": {},
"features": [{
"type": "P.CU",
"inputParams": {
"id": "Part",
"sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
"transform": {
"position": [0.0, 0.0, 0.0],
"rotationEuler": [0.0, 0.0, 0.0],
"scale": [1.0, 1.0, 1.0]
},
"boolean": { "targets": [], "operation": "NONE" }
},
"persistentData": {}
}]
},
"snapshot": ""
}
}
})
.to_string()
}
pub(crate) fn assembly_engine() -> EngineState {
let mut engine = EngineState::new();
engine
.set_history_json(&two_instance_assembly_json())
.expect("assembly loads");
engine
}
#[test]
fn toggle_fixed_writes_an_explicit_boolean_both_ways() {
let mut engine = assembly_engine();
assert!(!engine.component_info("ACOMP2").unwrap().fixed);
run_component_action(&mut engine, ComponentAction::ToggleFixed, "ACOMP2");
assert!(engine.component_info("ACOMP2").unwrap().fixed);
run_component_action(&mut engine, ComponentAction::ToggleFixed, "ACOMP1");
assert!(!engine.component_info("ACOMP1").unwrap().fixed);
}
#[test]
fn delete_removes_the_instance_and_its_members() {
let mut engine = assembly_engine();
assert_eq!(engine.scene.solids().len(), 2);
run_component_action(&mut engine, ComponentAction::Delete, "ACOMP2");
assert_eq!(engine.history_len(), 1, "the ACOMP feature is gone");
let names: Vec<&str> = engine.scene.solids().iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, ["ACOMP1:Part"], "only the surviving instance renders");
}
#[test]
fn move_action_arms_free_and_toasts_fixed() {
let mut engine = assembly_engine();
run_component_action(&mut engine, ComponentAction::Move, "ACOMP2");
assert!(engine.component_move_armed());
assert_eq!(engine.component_move_armed_feature(), "ACOMP2");
run_component_action(&mut engine, ComponentAction::Move, "ACOMP1");
assert_eq!(
engine.component_move_armed_feature(),
"ACOMP2",
"the fixed instance never arms (the free one stays armed)"
);
let notices = engine.take_notices();
assert!(notices.iter().any(|n| n.contains("fixed")), "{notices:?}");
}
#[test]
fn document_flows_return_shell_requests() {
let mut engine = assembly_engine();
assert_eq!(
run_component_action(&mut engine, ComponentAction::OpenPart, "ACOMP1"),
Some(ComponentActionRequest::OpenPart { component_id: "ACOMP1".into() })
);
}
#[test]
fn part_source_key_reads_the_library_entry() {
let engine = assembly_engine();
assert_eq!(
part_source_key(&engine, "ACOMP1").as_deref(),
Some("widget"),
"the entry's sourceKey"
);
assert_eq!(part_source_key(&engine, "ACOMP9"), None, "unknown component");
let mut doc: serde_json::Value =
serde_json::from_str(&two_instance_assembly_json()).unwrap();
doc["partsLibrary"]["widget"]["sourceKey"] = serde_json::json!("");
let mut engine = EngineState::new();
engine.set_history_json(&doc.to_string()).unwrap();
assert_eq!(part_source_key(&engine, "ACOMP1"), None);
}
#[test]
fn action_ids_round_trip() {
for action in ComponentAction::ALL {
assert_eq!(ComponentAction::from_id(action.id()), Some(action));
}
assert_eq!(ComponentAction::from_id("bogus"), None);
assert!(ComponentAction::ToggleFixed.label(false).contains("Fix"));
assert!(ComponentAction::ToggleFixed.label(true).contains("Unfix"));
}
}