use crate::panels::update_components::UpdateComponents;
use brep_render::assembly_status;
use brep_render::engine_state::EngineState;
use eframe::egui;
use serde_json::Value;
use std::collections::HashMap;
pub(crate) const FIXED_GLYPH: &str = "\u{23DA}";
pub(crate) const OUTDATED_GLYPH: &str = "\u{21BB}";
pub(crate) const OUTDATED_AMBER: egui::Color32 = egui::Color32::from_rgb(0xff, 0x9f, 0x0a);
pub(crate) struct ComponentRow {
pub id: String,
pub label: String,
pub part_name: String,
pub fixed: bool,
pub outdated: bool,
pub rollup_status: Option<String>,
pub visible: bool,
pub selected: bool,
pub solids: Vec<String>,
pub children: Vec<ChainNode>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ChainNode {
pub label: String,
pub children: Vec<ChainNode>,
}
pub(crate) fn is_acomp_segment(segment: &str) -> bool {
segment
.strip_prefix("ACOMP")
.map(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()))
.unwrap_or(false)
}
fn chain_key(parent_key: &str, label: &str) -> String {
format!("{parent_key}:{label}")
}
pub(crate) fn chain_groups(member_locals: &[&str]) -> Vec<ChainNode> {
let mut order: Vec<String> = Vec::new();
let mut grouped: HashMap<String, Vec<&str>> = HashMap::new();
let mut leaves: Vec<ChainNode> = Vec::new();
for local in member_locals {
match local.split_once(':') {
Some((head, rest)) if is_acomp_segment(head) => {
if !grouped.contains_key(head) {
order.push(head.to_string());
}
grouped.entry(head.to_string()).or_default().push(rest);
}
_ => leaves.push(ChainNode {
label: (*local).to_string(),
children: Vec::new(),
}),
}
}
let mut out: Vec<ChainNode> = order
.into_iter()
.map(|head| {
let members = grouped.remove(&head).unwrap_or_default();
ChainNode {
children: chain_groups(&members),
label: head,
}
})
.collect();
out.append(&mut leaves);
out
}
fn owning_component_of_ref(element: &str) -> Option<&str> {
let name = element.split('@').next().unwrap_or(element);
let head = name.split(':').next().unwrap_or(name);
is_acomp_segment(head).then_some(head)
}
pub(crate) fn snapshot(state: &mut EngineState, updates: &UpdateComponents) -> Vec<ComponentRow> {
let mut rollup: HashMap<String, String> = HashMap::new();
let constraint_state = state.assembly_state_value();
if let Some(constraints) = constraint_state
.get("constraints")
.and_then(Value::as_array)
{
for entry in constraints {
let status = entry
.get("persistentData")
.and_then(|data| data.get("status"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let elements = entry
.get("inputParams")
.and_then(|params| params.get("elements"))
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
for element in elements.iter().filter_map(Value::as_str) {
let Some(component) = owning_component_of_ref(element) else {
continue;
};
let worse = rollup
.get(component)
.map(|current| {
assembly_status::status_severity(&status)
> assembly_status::status_severity(current)
})
.unwrap_or(true);
if worse {
rollup.insert(component.to_string(), status.clone());
}
}
}
}
let selected_solids = state.emphasis.selected_solids.clone();
let solid_visible: HashMap<String, bool> = state
.scene
.solids()
.iter()
.map(|solid| (solid.name.clone(), solid.visible))
.collect();
state
.assembly_components()
.iter()
.map(|record| {
let prefix = format!("{}:", record.id);
let locals: Vec<&str> = record
.solids
.iter()
.map(|name| name.strip_prefix(&prefix).unwrap_or(name))
.collect();
ComponentRow {
label: format!("{} ({})", record.part_name, record.id),
part_name: record.part_name.clone(),
fixed: record.fixed,
outdated: updates.is_outdated(&record.part_name),
rollup_status: rollup.get(&record.id).cloned(),
visible: record
.solids
.iter()
.all(|name| solid_visible.get(name).copied().unwrap_or(true)),
selected: record
.solids
.iter()
.any(|name| selected_solids.contains(name)),
solids: record.solids.clone(),
children: chain_groups(&locals),
id: record.id.clone(),
}
})
.collect()
}
#[cfg(target_arch = "wasm32")]
fn publish(name: &str, json: &str) {
if let Some(win) = web_sys::window() {
let _ = js_sys::Reflect::set(
&win,
&wasm_bindgen::JsValue::from_str(name),
&wasm_bindgen::JsValue::from_str(json),
);
}
}
#[allow(unused_variables)]
pub(crate) fn publish_tree(rows: &[ComponentRow]) {
#[cfg(target_arch = "wasm32")]
{
let listing: Vec<Value> = rows
.iter()
.map(|row| {
serde_json::json!({
"id": row.id,
"label": row.label,
"fixed": row.fixed,
"outdated": row.outdated,
"visible": row.visible,
"selected": row.selected,
"status": row.rollup_status,
"solids": row.solids,
})
})
.collect();
publish("__brepAssemblyTree", &Value::Array(listing).to_string());
}
}
#[cfg(test)]
mod tests {
use super::*;
use brep_render::engine_state::ComponentInsert;
fn part_document() -> String {
serde_json::json!({
"expressions": "",
"configurator": {},
"features": [{
"type": "P.CU",
"inputParams": {
"id": "Part",
"sizeX": 2.0, "sizeY": 3.0, "sizeZ": 4.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": {}
}]
})
.to_string()
}
fn two_instance_state() -> EngineState {
brep_render::brep_kernel::clear_history_cache();
let mut state = EngineState::new();
state
.insert_component(ComponentInsert::New {
name: "bracket",
source_key: "bracket",
source_signature: "sig-1",
document_json: &part_document(),
})
.expect("insert 1");
state
.insert_component(ComponentInsert::Existing { part_name: "bracket" })
.expect("insert 2");
state
}
fn no_updates() -> UpdateComponents {
UpdateComponents::new()
}
#[test]
fn chain_groups_parse_nested_prefixes() {
let nodes = chain_groups(&["ACOMP1:Part", "ACOMP1:Cap", "Plate"]);
assert_eq!(nodes.len(), 2);
assert_eq!(nodes[0].label, "ACOMP1");
assert_eq!(
nodes[0].children,
vec![
ChainNode { label: "Part".into(), children: vec![] },
ChainNode { label: "Cap".into(), children: vec![] },
]
);
assert_eq!(nodes[1].label, "Plate");
let nodes = chain_groups(&["ACOMP5:ACOMP1:Part", "ACOMP5:Base"]);
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].label, "ACOMP5");
assert_eq!(nodes[0].children[0].label, "ACOMP1");
assert_eq!(nodes[0].children[0].children[0].label, "Part");
assert_eq!(nodes[0].children[1].label, "Base");
let nodes = chain_groups(&["S1:PROFILE"]);
assert_eq!(nodes, vec![ChainNode { label: "S1:PROFILE".into(), children: vec![] }]);
}
#[test]
fn nested_sub_assembly_projects_a_chain_grouping() {
brep_render::brep_kernel::clear_history_cache();
let mut inner = EngineState::new();
inner
.insert_component(ComponentInsert::New {
name: "bracket",
source_key: "bracket",
source_signature: "sig-1",
document_json: &part_document(),
})
.expect("inner insert");
let assembly_doc = inner.history.request_json();
drop(inner);
brep_render::brep_kernel::clear_history_cache();
let mut state = EngineState::new();
state
.insert_component(ComponentInsert::New {
name: "subasm",
source_key: "subasm",
source_signature: "sig-2",
document_json: &assembly_doc,
})
.expect("outer insert");
let rows = snapshot(&mut state, &no_updates());
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].label, "subasm (ACOMP1)");
assert_eq!(rows[0].solids, vec!["ACOMP1:ACOMP1:Part".to_string()]);
assert_eq!(rows[0].children.len(), 1);
assert_eq!(rows[0].children[0].label, "ACOMP1", "read-only child group");
assert_eq!(rows[0].children[0].children[0].label, "Part");
}
#[test]
fn rollup_owner_parses_element_refs() {
assert_eq!(owning_component_of_ref("ACOMP2"), Some("ACOMP2"));
assert_eq!(owning_component_of_ref("ACOMP2:Part_PZ"), Some("ACOMP2"));
assert_eq!(owning_component_of_ref("ACOMP3:ACOMP1:Part_PZ"), Some("ACOMP3"));
assert_eq!(owning_component_of_ref("ACOMP1:Part@2,3,4"), Some("ACOMP1"));
assert_eq!(owning_component_of_ref("Box_PZ"), None);
assert_eq!(owning_component_of_ref("S1:PROFILE"), None);
}
#[test]
fn constraint_status_rolls_up_onto_components() {
let mut state = two_instance_state();
state
.assembly_add_constraint(
"fixed",
&serde_json::json!({ "elements": ["ACOMP2"] }).to_string(),
)
.expect("add fixed constraint");
let rows = snapshot(&mut state, &no_updates());
assert!(rows[0].rollup_status.is_none(), "ACOMP1 has no constraints");
let status = rows[1].rollup_status.as_deref().expect("ACOMP2 rolled up");
assert!(!status.is_empty());
}
#[test]
fn the_outdated_flag_lights_every_instance_of_the_part() {
use crate::panels::assembly_edit::document_signature;
use crate::store::MemModelStore;
brep_render::brep_kernel::clear_history_cache();
let store = MemModelStore::new();
let content = part_document();
store.put("bracket", &content);
let mut state = EngineState::new();
state
.insert_component(ComponentInsert::New {
name: "bracket",
source_key: "bracket",
source_signature: &document_signature(&content),
document_json: &content,
})
.unwrap();
state
.insert_component(ComponentInsert::Existing { part_name: "bracket" })
.unwrap();
let mut updates = UpdateComponents::new();
updates.ensure_current(&mut state, &store, 0);
let rows = snapshot(&mut state, &updates);
assert!(
!rows[0].outdated && !rows[1].outdated,
"nothing is outdated against its own source"
);
let mut edited: Value = serde_json::from_str(&content).unwrap();
edited["features"][0]["inputParams"]["sizeX"] = serde_json::json!(9.0);
store.put("bracket", &edited.to_string());
updates.ensure_current(&mut state, &store, 1);
let rows = snapshot(&mut state, &updates);
assert!(
rows[0].outdated && rows[1].outdated,
"every instance of the entry lights, not just the first"
);
}
}