1use brep_render::engine_state::EngineState;
14
15#[derive(Clone, Copy, PartialEq, Eq, Debug)]
17pub enum ComponentAction {
18 Move,
21 OpenPart,
27 ToggleFixed,
29 Delete,
32}
33
34impl ComponentAction {
35 pub const ALL: [ComponentAction; 4] = [
37 ComponentAction::Move,
38 ComponentAction::OpenPart,
39 ComponentAction::ToggleFixed,
40 ComponentAction::Delete,
41 ];
42
43 pub fn id(self) -> &'static str {
45 match self {
46 ComponentAction::Move => "move",
47 ComponentAction::OpenPart => "open-part",
48 ComponentAction::ToggleFixed => "toggle-fixed",
49 ComponentAction::Delete => "delete",
50 }
51 }
52
53 pub fn from_id(id: &str) -> Option<Self> {
55 Self::ALL.into_iter().find(|action| action.id() == id)
56 }
57
58 pub fn label(self, fixed: bool) -> &'static str {
60 match self {
61 ComponentAction::Move => "\u{2725} Move",
62 ComponentAction::OpenPart => "\u{270E} Edit Part",
63 ComponentAction::ToggleFixed => {
64 if fixed {
65 "\u{1F513} Unfix"
66 } else {
67 "\u{1F512} Fix"
68 }
69 }
70 ComponentAction::Delete => "\u{2716} Delete",
71 }
72 }
73
74 pub fn tooltip(self) -> &'static str {
76 match self {
77 ComponentAction::Move => "Move/rotate gizmo on-off (arrows + arcs together)",
78 ComponentAction::OpenPart => "Open the part's source document in its own tab",
79 ComponentAction::ToggleFixed => "Ground / free this instance for the solver",
80 ComponentAction::Delete => "Delete this component instance",
81 }
82 }
83}
84
85#[derive(Clone, PartialEq, Eq, Debug)]
88pub enum ComponentActionRequest {
89 OpenPart { component_id: String },
90}
91
92pub fn run_component_action(
96 state: &mut EngineState,
97 action: ComponentAction,
98 component_id: &str,
99) -> Option<ComponentActionRequest> {
100 match action {
101 ComponentAction::Move => {
102 state.component_move_toggle(component_id);
105 None
106 }
107 ComponentAction::ToggleFixed => {
108 let Some(info) = state.component_info(component_id) else {
109 state.push_notice(format!("'{component_id}' is not an assembly component"));
110 return None;
111 };
112 let mut params = serde_json::from_str::<serde_json::Value>(
113 &state.feature_params_json(feature_index(state, component_id)?),
114 )
115 .unwrap_or_else(|_| serde_json::json!({}));
116 if let Some(object) = params.as_object_mut() {
117 object.insert("isFixed".into(), serde_json::Value::Bool(!info.fixed));
121 }
122 let _ = state.update_feature_params(component_id, ¶ms.to_string());
123 None
124 }
125 ComponentAction::Delete => {
126 let _ = state.delete_feature(component_id);
130 None
131 }
132 ComponentAction::OpenPart => Some(ComponentActionRequest::OpenPart {
133 component_id: component_id.to_string(),
134 }),
135 }
136}
137
138fn feature_index(state: &EngineState, id: &str) -> Option<usize> {
140 (0..state.history_len()).find(|&i| state.feature_id_at(i).as_deref() == Some(id))
141}
142
143pub fn part_source_key(state: &EngineState, component_id: &str) -> Option<String> {
149 let info = state.component_info(component_id)?;
150 let document: serde_json::Value =
151 serde_json::from_str(&state.history_request_json()).ok()?;
152 document["partsLibrary"][&info.part_name]["sourceKey"]
153 .as_str()
154 .filter(|key| !key.is_empty())
155 .map(str::to_string)
156}
157
158#[cfg(test)]
159pub(crate) mod tests {
160 use super::*;
161
162 pub(crate) fn two_instance_assembly_json() -> String {
166 serde_json::json!({
167 "expressions": "",
168 "configurator": {},
169 "features": [
170 {
171 "type": "ACOMP",
172 "inputParams": {
173 "id": "ACOMP1",
174 "partName": "widget",
175 "transform": { "translate": [0.0, 0.0, 0.0], "rotateEulerDeg": [0.0, 0.0, 0.0] },
176 "isFixed": true
177 },
178 "persistentData": {}
179 },
180 {
181 "type": "ACOMP",
182 "inputParams": {
183 "id": "ACOMP2",
184 "partName": "widget",
185 "transform": { "translate": [20.0, 0.0, 0.0], "rotateEulerDeg": [0.0, 0.0, 0.0] }
186 },
187 "persistentData": {}
188 }
189 ],
190 "partsLibrary": {
191 "widget": {
192 "sourceKey": "widget",
193 "sourceSignature": "sig-1",
194 "document": {
195 "expressions": "",
196 "configurator": {},
197 "features": [{
198 "type": "P.CU",
199 "inputParams": {
200 "id": "Part",
201 "sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
202 "transform": {
203 "position": [0.0, 0.0, 0.0],
204 "rotationEuler": [0.0, 0.0, 0.0],
205 "scale": [1.0, 1.0, 1.0]
206 },
207 "boolean": { "targets": [], "operation": "NONE" }
208 },
209 "persistentData": {}
210 }]
211 },
212 "snapshot": ""
213 }
214 }
215 })
216 .to_string()
217 }
218
219 pub(crate) fn assembly_engine() -> EngineState {
220 let mut engine = EngineState::new();
221 engine
222 .set_history_json(&two_instance_assembly_json())
223 .expect("assembly loads");
224 engine
225 }
226
227 #[test]
228 fn toggle_fixed_writes_an_explicit_boolean_both_ways() {
229 let mut engine = assembly_engine();
230 assert!(!engine.component_info("ACOMP2").unwrap().fixed);
231
232 run_component_action(&mut engine, ComponentAction::ToggleFixed, "ACOMP2");
234 assert!(engine.component_info("ACOMP2").unwrap().fixed);
235
236 run_component_action(&mut engine, ComponentAction::ToggleFixed, "ACOMP1");
239 assert!(!engine.component_info("ACOMP1").unwrap().fixed);
240 }
241
242 #[test]
243 fn delete_removes_the_instance_and_its_members() {
244 let mut engine = assembly_engine();
245 assert_eq!(engine.scene.solids().len(), 2);
246 run_component_action(&mut engine, ComponentAction::Delete, "ACOMP2");
247 assert_eq!(engine.history_len(), 1, "the ACOMP feature is gone");
248 let names: Vec<&str> = engine.scene.solids().iter().map(|s| s.name.as_str()).collect();
249 assert_eq!(names, ["ACOMP1:Part"], "only the surviving instance renders");
250 }
251
252 #[test]
253 fn move_action_arms_free_and_toasts_fixed() {
254 let mut engine = assembly_engine();
255 run_component_action(&mut engine, ComponentAction::Move, "ACOMP2");
256 assert!(engine.component_move_armed());
257 assert_eq!(engine.component_move_armed_feature(), "ACOMP2");
258
259 run_component_action(&mut engine, ComponentAction::Move, "ACOMP1");
260 assert_eq!(
261 engine.component_move_armed_feature(),
262 "ACOMP2",
263 "the fixed instance never arms (the free one stays armed)"
264 );
265 let notices = engine.take_notices();
266 assert!(notices.iter().any(|n| n.contains("fixed")), "{notices:?}");
267 }
268
269 #[test]
270 fn document_flows_return_shell_requests() {
271 let mut engine = assembly_engine();
272 assert_eq!(
273 run_component_action(&mut engine, ComponentAction::OpenPart, "ACOMP1"),
274 Some(ComponentActionRequest::OpenPart { component_id: "ACOMP1".into() })
275 );
276 }
277
278 #[test]
279 fn part_source_key_reads_the_library_entry() {
280 let engine = assembly_engine();
281 assert_eq!(
282 part_source_key(&engine, "ACOMP1").as_deref(),
283 Some("widget"),
284 "the entry's sourceKey"
285 );
286 assert_eq!(part_source_key(&engine, "ACOMP9"), None, "unknown component");
287
288 let mut doc: serde_json::Value =
291 serde_json::from_str(&two_instance_assembly_json()).unwrap();
292 doc["partsLibrary"]["widget"]["sourceKey"] = serde_json::json!("");
293 let mut engine = EngineState::new();
294 engine.set_history_json(&doc.to_string()).unwrap();
295 assert_eq!(part_source_key(&engine, "ACOMP1"), None);
296 }
297
298 #[test]
299 fn action_ids_round_trip() {
300 for action in ComponentAction::ALL {
301 assert_eq!(ComponentAction::from_id(action.id()), Some(action));
302 }
303 assert_eq!(ComponentAction::from_id("bogus"), None);
304 assert!(ComponentAction::ToggleFixed.label(false).contains("Fix"));
306 assert!(ComponentAction::ToggleFixed.label(true).contains("Unfix"));
307 }
308}