Skip to main content

brep_render/engine_state/
components.rs

1use super::*;
2
3// ============================================================================
4// Assembly COMPONENT read surface (Wave 3, lane H) — the app-side view of the
5// scene's component instances, DERIVED from what already crosses the runner
6// seam: the engine-owned history (the ACOMP features are the pose/fixed truth
7// per the pose-authority contract) plus the display scene's NAMESPACED solid
8// names (`ACOMP2:Extrude1_top` — build-spec §3). No kernel `ComponentRecord`
9// crosses the boundary: the kernel scene lives on the runner thread/worker, so
10// the app derives the same projection locally from the two sources it owns.
11// The namespace parse is the kernel's own (`brep_kernel::split_component_namespace`),
12// so the app and the resolver can never disagree on what a component prefix is.
13// ============================================================================
14
15/// One assembly component instance as the APP sees it: the owning ACOMP feature
16/// id, the parts-library part name, the authored rigid pose (`inputParams.
17/// transform` — `{translate, rotateEulerDeg}`, intrinsic-XYZ degrees), the
18/// grounded flag, and the member solid names currently in the display scene.
19#[derive(Debug, Clone, PartialEq)]
20pub struct ComponentInfo {
21    /// The owning ACOMP feature id (`ACOMP<digits>`) — also the namespace prefix.
22    pub id: String,
23    /// The parts-library entry this instance places (`inputParams.partName`).
24    pub part_name: String,
25    /// `inputParams.transform.translate` (numbers-or-zero: an expression-valued
26    /// slot reads 0 here, matching the transform gizmo's numeric read).
27    pub translate: [f64; 3],
28    /// `inputParams.transform.rotateEulerDeg` (degrees, intrinsic XYZ).
29    pub rotate_deg: [f64; 3],
30    /// Grounded: an explicit `isFixed` wins; ABSENT mirrors the kernel's
31    /// auto-ground rule (grounded iff no ACOMP precedes it in the history).
32    pub fixed: bool,
33    /// Member solid names in display-scene order (`{id}:{part solid name}`).
34    /// Empty when the instance is rolled back / failed to build.
35    pub members: Vec<String>,
36}
37
38/// The ACOMP dispatch predicate, mirroring the kernel's `is_acomp_type` (the
39/// two literals `execute_feature` matches on).
40pub(crate) fn is_acomp_feature_type(feature_type: &str) -> bool {
41    matches!(feature_type, "ACOMP" | "ASSEMBLY COMPONENT")
42}
43
44/// Read a `[x, y, z]` number array off a JSON value (missing / short / non-
45/// numeric slots keep the per-index default) — the same lenient numeric read
46/// the transform gizmo uses for pose vectors.
47pub(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    /// The OWNING component feature id of a scene solid — the OUTERMOST
61    /// `ACOMP<digits>:` namespace segment of its name, verified against the
62    /// history (the segment must be an ACOMP feature of THIS document; a nested
63    /// chain's inner segments belong to the sub-assembly's own document).
64    /// `None` for ordinary modeling solids (no prefix) and for sketch-child
65    /// names (`S1:G20` — `S1` is not an ACOMP segment).
66    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    /// Every ACOMP feature id in history order (the structure tree's row order).
77    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    /// The derived [`ComponentInfo`] for an ACOMP feature id (`None` when the id
89    /// is missing or not an ACOMP feature).
90    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        // Grounded: explicit boolean wins; ABSENT (or null) mirrors the kernel's
100        // auto-ground — the FIRST component of the document is grounded.
101        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    /// The world bbox CENTER of a component's member solids — the Move gizmo's
130    /// attach point (build-spec §8.5). `None` when the component has no resident
131    /// members (rolled back / failed build).
132    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    /// A two-instance assembly document over ONE parts-library entry `widget`
146    /// (a 10 mm cube part named `Part`): `ACOMP1` fixed at the origin, `ACOMP2`
147    /// (isFixed ABSENT) at `translate [20,0,0]`. The entry ships with an EMPTY
148    /// snapshot, so building it exercises the kernel's self-heal lane (document
149    /// re-execution → snapshot rewrite) — the same lane the app-side
150    /// edit-in-place refresh relies on.
151    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    /// A standalone cube part document (`P.CU` id `Part`, side `size`).
189    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        // The fixture's library entry has an EMPTY snapshot: building it proves
232        // the self-heal lane (document re-execution) feeds the instances.
233        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        // A NESTED chain resolves to the OUTERMOST segment (the instance of
252        // THIS document); the inner ids belong to the sub-assembly's document.
253        assert_eq!(
254            engine.component_of_solid("ACOMP2:ACOMP7:Extrude1_top").as_deref(),
255            Some("ACOMP2")
256        );
257        // Ordinary solids, sketch-child names, and whole-component ids are not
258        // component members.
259        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        // An ACOMP-shaped prefix with NO matching history feature is rejected.
263        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        // Not a component: the id exists nowhere / not an ACOMP.
286        assert!(engine.component_info("Box").is_none());
287    }
288
289    #[test]
290    fn absent_is_fixed_grounds_only_the_first_component() {
291        // A single-instance document with NO isFixed at all: the sole (first)
292        // component reads grounded, mirroring the kernel's auto-ground.
293        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    /// The ENGINE-ALTITUDE pose-authority fold: loading a constrained assembly
308    /// solves at the run tail and the solved pose lands in the owning ACOMP's
309    /// `inputParams.transform` (by id) WITHOUT minting an undo entry — so the
310    /// document the app persists / re-runs already carries the solved pose, and
311    /// a follow-up run is a settled no-motion solve (request stable).
312    #[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        // The fold adopted the solved pose: ACOMP2's authored [20,0,0] moved
330        // onto ACOMP1 (whole-component coincident pulls the anchors together).
331        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        // The fold is NOT an undo entry (a solve write-back is not a user edit).
343        assert!(!engine.history.can_undo(), "no undo entry from the fold");
344
345        // Settled: re-running the folded document changes nothing (no pose
346        // churn — the no-motion solve emits no write-backs).
347        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        // ACOMP2 is the 10 mm cube translated +20 X → bbox [20..30, 0..10, 0..10].
356        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        // No members → no center.
361        assert!(engine.component_bbox_center("ACOMP9").is_none());
362    }
363}