use super::*;
#[derive(Debug, Clone, PartialEq)]
pub struct ComponentInfo {
pub id: String,
pub part_name: String,
pub translate: [f64; 3],
pub rotate_deg: [f64; 3],
pub fixed: bool,
pub members: Vec<String>,
}
pub(crate) fn is_acomp_feature_type(feature_type: &str) -> bool {
matches!(feature_type, "ACOMP" | "ASSEMBLY COMPONENT")
}
pub(super) fn vec3_or(value: Option<&serde_json::Value>, default: [f64; 3]) -> [f64; 3] {
let mut out = default;
if let Some(array) = value.and_then(|v| v.as_array()) {
for (index, slot) in out.iter_mut().enumerate() {
if let Some(number) = array.get(index).and_then(|v| v.as_f64()) {
*slot = number;
}
}
}
out
}
impl EngineState {
pub fn component_of_solid(&self, solid_name: &str) -> Option<String> {
let (chain, _local) = brep_kernel::split_component_namespace(solid_name);
let head = *chain.first()?;
let index = self.history.index_of(head)?;
self.history
.feature_type(index)
.filter(|ty| is_acomp_feature_type(ty))
.map(|_| head.to_string())
}
pub fn component_ids(&self) -> Vec<String> {
(0..self.history.len())
.filter(|&i| {
self.history
.feature_type(i)
.is_some_and(|ty| is_acomp_feature_type(&ty))
})
.filter_map(|i| self.history.feature_id(i))
.collect()
}
pub fn component_info(&self, feature_id: &str) -> Option<ComponentInfo> {
let index = self.history.index_of(feature_id)?;
self.history
.feature_type(index)
.filter(|ty| is_acomp_feature_type(ty))?;
let params = self.history.feature_params(index).unwrap_or_default();
let transform = params.get("transform");
let translate = vec3_or(transform.and_then(|t| t.get("translate")), [0.0; 3]);
let rotate_deg = vec3_or(transform.and_then(|t| t.get("rotateEulerDeg")), [0.0; 3]);
let fixed = match params.get("isFixed") {
Some(serde_json::Value::Bool(flag)) => *flag,
_ => self
.component_ids()
.first()
.is_some_and(|first| first == feature_id),
};
let members: Vec<String> = self
.scene
.solids()
.iter()
.filter(|solid| self.component_of_solid(&solid.name).as_deref() == Some(feature_id))
.map(|solid| solid.name.clone())
.collect();
Some(ComponentInfo {
id: feature_id.to_string(),
part_name: params
.get("partName")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
translate,
rotate_deg,
fixed,
members,
})
}
pub fn component_bbox_center(&self, feature_id: &str) -> Option<[f64; 3]> {
let mut bbox = crate::camera::Aabb::empty();
for solid in self.scene.solids() {
if self.component_of_solid(&solid.name).as_deref() == Some(feature_id) {
bbox.union(&solid.bbox);
}
}
(!bbox.is_empty()).then(|| bbox.center())
}
}
#[cfg(test)]
pub(crate) mod component_fixtures {
pub 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": cube_part_document(10.0),
"snapshot": ""
}
}
})
.to_string()
}
pub fn cube_part_document(size: f64) -> serde_json::Value {
serde_json::json!({
"expressions": "",
"configurator": {},
"features": [{
"type": "P.CU",
"inputParams": {
"id": "Part",
"sizeX": size, "sizeY": size, "sizeZ": size,
"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": {}
}]
})
}
}
#[cfg(test)]
mod component_read_tests {
use super::component_fixtures::two_instance_assembly_json;
use super::*;
fn assembly_engine() -> EngineState {
let mut engine = EngineState::new();
let report = engine
.set_history_json(&two_instance_assembly_json())
.expect("assembly document loads");
let report: serde_json::Value = serde_json::from_str(&report).unwrap();
assert!(
report["featureErrors"].as_array().is_none_or(|e| e.is_empty()),
"assembly built clean: {report}"
);
engine
}
#[test]
fn acomp_instances_build_namespaced_members_via_self_heal() {
let engine = assembly_engine();
let names: Vec<&str> = engine
.scene
.solids()
.iter()
.map(|s| s.name.as_str())
.collect();
assert!(names.contains(&"ACOMP1:Part"), "{names:?}");
assert!(names.contains(&"ACOMP2:Part"), "{names:?}");
}
#[test]
fn component_of_solid_parses_the_outermost_acomp_segment() {
let engine = assembly_engine();
assert_eq!(
engine.component_of_solid("ACOMP1:Part").as_deref(),
Some("ACOMP1")
);
assert_eq!(
engine.component_of_solid("ACOMP2:ACOMP7:Extrude1_top").as_deref(),
Some("ACOMP2")
);
assert_eq!(engine.component_of_solid("Box"), None);
assert_eq!(engine.component_of_solid("S1:G20"), None);
assert_eq!(engine.component_of_solid("ACOMP1"), None);
assert_eq!(engine.component_of_solid("ACOMP9:Part"), None);
}
#[test]
fn component_info_reads_pose_fixed_and_members() {
let engine = assembly_engine();
assert_eq!(engine.component_ids(), ["ACOMP1", "ACOMP2"]);
let first = engine.component_info("ACOMP1").expect("ACOMP1 info");
assert_eq!(first.part_name, "widget");
assert_eq!(first.translate, [0.0, 0.0, 0.0]);
assert!(first.fixed, "explicit isFixed:true is honored");
assert_eq!(first.members, ["ACOMP1:Part"]);
let second = engine.component_info("ACOMP2").expect("ACOMP2 info");
assert_eq!(second.translate, [20.0, 0.0, 0.0]);
assert!(
!second.fixed,
"isFixed ABSENT on a NON-first component mirrors the kernel auto-ground: free"
);
assert_eq!(second.members, ["ACOMP2:Part"]);
assert!(engine.component_info("Box").is_none());
}
#[test]
fn absent_is_fixed_grounds_only_the_first_component() {
let mut doc: serde_json::Value =
serde_json::from_str(&two_instance_assembly_json()).unwrap();
let features = doc["features"].as_array_mut().unwrap();
features.truncate(1);
features[0]["inputParams"]
.as_object_mut()
.unwrap()
.remove("isFixed");
let mut engine = EngineState::new();
engine.set_history_json(&doc.to_string()).unwrap();
let info = engine.component_info("ACOMP1").expect("sole component");
assert!(info.fixed, "first component auto-grounds when isFixed is absent");
}
#[test]
fn solved_poses_fold_into_acomp_params_without_undo() {
let mut doc: serde_json::Value =
serde_json::from_str(&two_instance_assembly_json()).unwrap();
doc["assembly"] = serde_json::json!({
"constraints": [{
"type": "coincident",
"inputParams": { "id": "COIN1", "elements": ["ACOMP1", "ACOMP2"] },
"persistentData": {},
"enabled": true,
"open": false
}],
"idCounter": 2
});
let mut engine = EngineState::new();
engine.set_history_json(&doc.to_string()).unwrap();
let info = engine.component_info("ACOMP2").expect("ACOMP2");
assert!(
info.translate[0] < 15.0,
"solved translate folded into inputParams: {:?}",
info.translate
);
let c1 = engine.component_bbox_center("ACOMP1").unwrap();
let c2 = engine.component_bbox_center("ACOMP2").unwrap();
let gap = ((c1[0] - c2[0]).powi(2) + (c1[1] - c2[1]).powi(2) + (c1[2] - c2[2]).powi(2)).sqrt();
assert!(gap < 1e-4, "displayed members coincide: {c1:?} vs {c2:?}");
assert!(!engine.history.can_undo(), "no undo entry from the fold");
let before = engine.history_request_json();
engine.roll_to(engine.history_len() - 1);
assert_eq!(before, engine.history_request_json(), "request stable across a settled re-run");
}
#[test]
fn bbox_center_unions_the_member_solids() {
let engine = assembly_engine();
let center = engine.component_bbox_center("ACOMP2").expect("center");
assert!((center[0] - 25.0).abs() < 1e-6, "{center:?}");
assert!((center[1] - 5.0).abs() < 1e-6, "{center:?}");
assert!((center[2] - 5.0).abs() < 1e-6, "{center:?}");
assert!(engine.component_bbox_center("ACOMP9").is_none());
}
}