brep_render/engine_state/
components.rs1use super::*;
2
3#[derive(Debug, Clone, PartialEq)]
20pub struct ComponentInfo {
21 pub id: String,
23 pub part_name: String,
25 pub translate: [f64; 3],
28 pub rotate_deg: [f64; 3],
30 pub fixed: bool,
33 pub members: Vec<String>,
36}
37
38pub(crate) fn is_acomp_feature_type(feature_type: &str) -> bool {
41 matches!(feature_type, "ACOMP" | "ASSEMBLY COMPONENT")
42}
43
44pub(super) fn vec3_or(value: Option<&serde_json::Value>, default: [f64; 3]) -> [f64; 3] {
48 let mut out = default;
49 if let Some(array) = value.and_then(|v| v.as_array()) {
50 for (index, slot) in out.iter_mut().enumerate() {
51 if let Some(number) = array.get(index).and_then(|v| v.as_f64()) {
52 *slot = number;
53 }
54 }
55 }
56 out
57}
58
59impl EngineState {
60 pub fn component_of_solid(&self, solid_name: &str) -> Option<String> {
67 let (chain, _local) = brep_kernel::split_component_namespace(solid_name);
68 let head = *chain.first()?;
69 let index = self.history.index_of(head)?;
70 self.history
71 .feature_type(index)
72 .filter(|ty| is_acomp_feature_type(ty))
73 .map(|_| head.to_string())
74 }
75
76 pub fn component_ids(&self) -> Vec<String> {
78 (0..self.history.len())
79 .filter(|&i| {
80 self.history
81 .feature_type(i)
82 .is_some_and(|ty| is_acomp_feature_type(&ty))
83 })
84 .filter_map(|i| self.history.feature_id(i))
85 .collect()
86 }
87
88 pub fn component_info(&self, feature_id: &str) -> Option<ComponentInfo> {
91 let index = self.history.index_of(feature_id)?;
92 self.history
93 .feature_type(index)
94 .filter(|ty| is_acomp_feature_type(ty))?;
95 let params = self.history.feature_params(index).unwrap_or_default();
96 let transform = params.get("transform");
97 let translate = vec3_or(transform.and_then(|t| t.get("translate")), [0.0; 3]);
98 let rotate_deg = vec3_or(transform.and_then(|t| t.get("rotateEulerDeg")), [0.0; 3]);
99 let fixed = match params.get("isFixed") {
102 Some(serde_json::Value::Bool(flag)) => *flag,
103 _ => self
104 .component_ids()
105 .first()
106 .is_some_and(|first| first == feature_id),
107 };
108 let members: Vec<String> = self
109 .scene
110 .solids()
111 .iter()
112 .filter(|solid| self.component_of_solid(&solid.name).as_deref() == Some(feature_id))
113 .map(|solid| solid.name.clone())
114 .collect();
115 Some(ComponentInfo {
116 id: feature_id.to_string(),
117 part_name: params
118 .get("partName")
119 .and_then(|v| v.as_str())
120 .unwrap_or_default()
121 .to_string(),
122 translate,
123 rotate_deg,
124 fixed,
125 members,
126 })
127 }
128
129 pub fn component_bbox_center(&self, feature_id: &str) -> Option<[f64; 3]> {
133 let mut bbox = crate::camera::Aabb::empty();
134 for solid in self.scene.solids() {
135 if self.component_of_solid(&solid.name).as_deref() == Some(feature_id) {
136 bbox.union(&solid.bbox);
137 }
138 }
139 (!bbox.is_empty()).then(|| bbox.center())
140 }
141}
142
143#[cfg(test)]
144pub(crate) mod component_fixtures {
145 pub fn two_instance_assembly_json() -> String {
152 serde_json::json!({
153 "expressions": "",
154 "configurator": {},
155 "features": [
156 {
157 "type": "ACOMP",
158 "inputParams": {
159 "id": "ACOMP1",
160 "partName": "widget",
161 "transform": { "translate": [0.0, 0.0, 0.0], "rotateEulerDeg": [0.0, 0.0, 0.0] },
162 "isFixed": true
163 },
164 "persistentData": {}
165 },
166 {
167 "type": "ACOMP",
168 "inputParams": {
169 "id": "ACOMP2",
170 "partName": "widget",
171 "transform": { "translate": [20.0, 0.0, 0.0], "rotateEulerDeg": [0.0, 0.0, 0.0] }
172 },
173 "persistentData": {}
174 }
175 ],
176 "partsLibrary": {
177 "widget": {
178 "sourceKey": "widget",
179 "sourceSignature": "sig-1",
180 "document": cube_part_document(10.0),
181 "snapshot": ""
182 }
183 }
184 })
185 .to_string()
186 }
187
188 pub fn cube_part_document(size: f64) -> serde_json::Value {
190 serde_json::json!({
191 "expressions": "",
192 "configurator": {},
193 "features": [{
194 "type": "P.CU",
195 "inputParams": {
196 "id": "Part",
197 "sizeX": size, "sizeY": size, "sizeZ": size,
198 "transform": {
199 "position": [0.0, 0.0, 0.0],
200 "rotationEuler": [0.0, 0.0, 0.0],
201 "scale": [1.0, 1.0, 1.0]
202 },
203 "boolean": { "targets": [], "operation": "NONE" }
204 },
205 "persistentData": {}
206 }]
207 })
208 }
209}
210
211#[cfg(test)]
212mod component_read_tests {
213 use super::component_fixtures::two_instance_assembly_json;
214 use super::*;
215
216 fn assembly_engine() -> EngineState {
217 let mut engine = EngineState::new();
218 let report = engine
219 .set_history_json(&two_instance_assembly_json())
220 .expect("assembly document loads");
221 let report: serde_json::Value = serde_json::from_str(&report).unwrap();
222 assert!(
223 report["featureErrors"].as_array().is_none_or(|e| e.is_empty()),
224 "assembly built clean: {report}"
225 );
226 engine
227 }
228
229 #[test]
230 fn acomp_instances_build_namespaced_members_via_self_heal() {
231 let engine = assembly_engine();
234 let names: Vec<&str> = engine
235 .scene
236 .solids()
237 .iter()
238 .map(|s| s.name.as_str())
239 .collect();
240 assert!(names.contains(&"ACOMP1:Part"), "{names:?}");
241 assert!(names.contains(&"ACOMP2:Part"), "{names:?}");
242 }
243
244 #[test]
245 fn component_of_solid_parses_the_outermost_acomp_segment() {
246 let engine = assembly_engine();
247 assert_eq!(
248 engine.component_of_solid("ACOMP1:Part").as_deref(),
249 Some("ACOMP1")
250 );
251 assert_eq!(
254 engine.component_of_solid("ACOMP2:ACOMP7:Extrude1_top").as_deref(),
255 Some("ACOMP2")
256 );
257 assert_eq!(engine.component_of_solid("Box"), None);
260 assert_eq!(engine.component_of_solid("S1:G20"), None);
261 assert_eq!(engine.component_of_solid("ACOMP1"), None);
262 assert_eq!(engine.component_of_solid("ACOMP9:Part"), None);
264 }
265
266 #[test]
267 fn component_info_reads_pose_fixed_and_members() {
268 let engine = assembly_engine();
269 assert_eq!(engine.component_ids(), ["ACOMP1", "ACOMP2"]);
270
271 let first = engine.component_info("ACOMP1").expect("ACOMP1 info");
272 assert_eq!(first.part_name, "widget");
273 assert_eq!(first.translate, [0.0, 0.0, 0.0]);
274 assert!(first.fixed, "explicit isFixed:true is honored");
275 assert_eq!(first.members, ["ACOMP1:Part"]);
276
277 let second = engine.component_info("ACOMP2").expect("ACOMP2 info");
278 assert_eq!(second.translate, [20.0, 0.0, 0.0]);
279 assert!(
280 !second.fixed,
281 "isFixed ABSENT on a NON-first component mirrors the kernel auto-ground: free"
282 );
283 assert_eq!(second.members, ["ACOMP2:Part"]);
284
285 assert!(engine.component_info("Box").is_none());
287 }
288
289 #[test]
290 fn absent_is_fixed_grounds_only_the_first_component() {
291 let mut doc: serde_json::Value =
294 serde_json::from_str(&two_instance_assembly_json()).unwrap();
295 let features = doc["features"].as_array_mut().unwrap();
296 features.truncate(1);
297 features[0]["inputParams"]
298 .as_object_mut()
299 .unwrap()
300 .remove("isFixed");
301 let mut engine = EngineState::new();
302 engine.set_history_json(&doc.to_string()).unwrap();
303 let info = engine.component_info("ACOMP1").expect("sole component");
304 assert!(info.fixed, "first component auto-grounds when isFixed is absent");
305 }
306
307 #[test]
313 fn solved_poses_fold_into_acomp_params_without_undo() {
314 let mut doc: serde_json::Value =
315 serde_json::from_str(&two_instance_assembly_json()).unwrap();
316 doc["assembly"] = serde_json::json!({
317 "constraints": [{
318 "type": "coincident",
319 "inputParams": { "id": "COIN1", "elements": ["ACOMP1", "ACOMP2"] },
320 "persistentData": {},
321 "enabled": true,
322 "open": false
323 }],
324 "idCounter": 2
325 });
326 let mut engine = EngineState::new();
327 engine.set_history_json(&doc.to_string()).unwrap();
328
329 let info = engine.component_info("ACOMP2").expect("ACOMP2");
332 assert!(
333 info.translate[0] < 15.0,
334 "solved translate folded into inputParams: {:?}",
335 info.translate
336 );
337 let c1 = engine.component_bbox_center("ACOMP1").unwrap();
338 let c2 = engine.component_bbox_center("ACOMP2").unwrap();
339 let gap = ((c1[0] - c2[0]).powi(2) + (c1[1] - c2[1]).powi(2) + (c1[2] - c2[2]).powi(2)).sqrt();
340 assert!(gap < 1e-4, "displayed members coincide: {c1:?} vs {c2:?}");
341
342 assert!(!engine.history.can_undo(), "no undo entry from the fold");
344
345 let before = engine.history_request_json();
348 engine.roll_to(engine.history_len() - 1);
349 assert_eq!(before, engine.history_request_json(), "request stable across a settled re-run");
350 }
351
352 #[test]
353 fn bbox_center_unions_the_member_solids() {
354 let engine = assembly_engine();
355 let center = engine.component_bbox_center("ACOMP2").expect("center");
357 assert!((center[0] - 25.0).abs() < 1e-6, "{center:?}");
358 assert!((center[1] - 5.0).abs() < 1e-6, "{center:?}");
359 assert!((center[2] - 5.0).abs() < 1e-6, "{center:?}");
360 assert!(engine.component_bbox_center("ACOMP9").is_none());
362 }
363}