use crate::document::Document;
use brep_ecad_egui::Part;
use brep_render::engine_state::NoticeSeverity;
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
pub(crate) struct Device {
pub(crate) occurrence: String,
pub(crate) key: String,
pub(crate) signature: String,
pub(crate) reference: String,
pub(crate) blocks: Value,
pub(crate) known: bool,
pub(crate) local_index: Option<usize>,
}
fn is_component(feature: &Value) -> bool {
matches!(feature["type"].as_str(), Some("ACOMP" | "ASSEMBLY COMPONENT"))
}
fn features_of(document: &Value) -> &[Value] {
document["features"].as_array().map(Vec::as_slice).unwrap_or(&[])
}
fn blocks_of(document: &Value) -> Value {
let mut blocks = serde_json::Map::new();
for key in ["symbol", "pads", "ports"] {
if let Some(value) = document.get(key).filter(|value| !value.is_null()) {
blocks.insert(key.to_owned(), value.clone());
}
}
Value::Object(blocks)
}
pub(crate) fn devices(features: &[Value], library: &Value) -> Vec<Device> {
let mut found = Vec::new();
walk(features, library, "", "", &mut found);
found
}
fn walk(features: &[Value], library: &Value, occurrence: &str, label: &str, found: &mut Vec<Device>) {
for (index, feature) in features.iter().enumerate() {
if !is_component(feature) { continue; }
let params = &feature["inputParams"];
let (Some(id), Some(key)) = (params["id"].as_str(), params["partName"].as_str()) else { continue; };
let chain = if occurrence.is_empty() { id.to_owned() } else { format!("{occurrence}:{id}") };
let designator = params["bom"]["Reference_Designator"].as_str().unwrap_or("").trim();
let own = if designator.is_empty() { id } else { designator };
let reference = if label.is_empty() { own.to_owned() } else { format!("{label}/{own}") };
let entry = &library[key];
let document = &entry["document"];
let blocks = blocks_of(document);
let signature = entry["sourceSignature"].as_str().filter(|s| !s.is_empty())
.map(str::to_owned)
.unwrap_or_else(|| match blocks.get("symbol").or(blocks.get("pads")) {
Some(_) => super::parts_library::document_signature(&document.to_string()),
None => String::new(),
});
found.push(Device {
occurrence: chain.clone(), key: key.to_owned(), signature, reference: reference.clone(),
blocks, known: !entry.is_null(),
local_index: occurrence.is_empty().then_some(index),
});
if document.is_null() || brep_render::brep_kernel::document_is_boundary(document) { continue; }
walk(features_of(document), &document["partsLibrary"], &chain, &reference, found);
}
}
struct Offer { part: Part, symbol: bool }
fn named_pads(mut pads: brep_ecad_core::board::Footprint, key: &str) -> brep_ecad_core::board::Footprint {
if pads.name.trim().is_empty() { pads.name = key.to_owned(); }
pads
}
fn unplaceable(part: &Part) -> Option<String> {
let mut probe = brep_ecad_core::Document::default();
probe.place_assembly_component("probe", part.source.clone(), part.symbol.clone(), part.pads.clone(), None,
brep_ecad_core::Point::default(), 0).err()
.or_else(|| probe.validate().err())
}
fn offers(reached: Vec<Device>) -> Vec<Offer> {
offers_and_refusals(reached).0
}
fn offers_and_refusals(reached: Vec<Device>) -> (Vec<Offer>, Vec<String>) {
let mut used = BTreeSet::new();
let mut refused = Vec::new();
let offered = reached.into_iter().filter_map(|device| {
let mut reference = device.reference;
if used.contains(&reference) { reference = format!("{reference} ({})", device.occurrence); }
while !used.insert(reference.clone()) { reference.push('_'); }
let key = device.key;
let mut symbol: Option<brep_ecad_core::Symbol> = device.blocks.get("symbol").filter(|v| !v.is_null())
.map(|v| serde_json::from_value(v.clone())).transpose().ok()?;
if let Some(symbol) = &mut symbol { symbol.migrate_legacy_units(); }
let pads = match device.blocks.get("pads").filter(|v| !v.is_null()) {
Some(value) => Some(named_pads(serde_json::from_value(value.clone()).ok()?, &key)),
None => None,
};
if symbol.is_none() && pads.is_none() { return None; }
let signature = device.signature;
let offer = Offer {
symbol: symbol.is_some(),
part: Part {
source: brep_ecad_core::PartSource {
key: key.clone(), signature, instance: Some(device.occurrence),
},
name: key.clone(), symbol: symbol.unwrap_or_else(|| empty_symbol(&key)), pads,
reference: Some(reference.clone()),
},
};
if let Some(reason) = unplaceable(&offer.part) {
refused.push(format!(
"'{reference}' cannot go on a sheet: part '{key}' {reason}. Fix it in the part's \
Symbol or Pads workbench, then Update components."));
return None;
}
Some(offer)
}).collect();
(offered, refused)
}
fn empty_symbol(name: &str) -> brep_ecad_core::Symbol {
brep_ecad_core::Symbol { unit_count: 0, graphic_gates: vec![], hide_pin_names: false, hide_pin_numbers: false, properties: BTreeMap::new(), power_net: None,
library_id: name.into(), reference_prefix: "P".into(), description: String::new(),
graphics: Vec::new(), pins: Vec::new() }
}
#[derive(Debug, Default)]
pub(crate) struct OccurrenceMark {
revision: u64,
step: Option<u64>,
occurrences: BTreeMap<String, Occurrence>,
pending: BTreeSet<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Occurrence { key: String, reference: String,
transform: Value }
fn top_occurrences(history: &brep_render::history::History) -> BTreeMap<String, Occurrence> {
(0..history.len()).filter(|&index| history.feature_type(index)
.is_some_and(|kind| matches!(kind.as_str(), "ACOMP" | "ASSEMBLY COMPONENT")))
.filter_map(|index| {
let id = history.feature_id(index)?;
let params = history.feature_params(index)?;
let designator = params["bom"]["Reference_Designator"].as_str().unwrap_or("").trim();
let reference = if designator.is_empty() { id.clone() } else { designator.to_owned() };
Some((id, Occurrence { key: params["partName"].as_str().unwrap_or("").to_owned(), reference,
transform: params["transform"].clone() }))
})
.collect()
}
pub(crate) fn follow_occurrences(doc: &mut Document) {
let history = &doc.engine.history;
let revision = history.revision();
if doc.ecad_occurrences.as_ref().is_some_and(|mark| mark.revision == revision) { return; }
let now = top_occurrences(history);
let step = history.undo_top();
let forward = !history.can_redo();
let Some(mark) = doc.ecad_occurrences.take() else {
adopt_unbound_components(doc);
let history = &doc.engine.history;
doc.ecad_occurrences = Some(OccurrenceMark {
revision: history.revision(), step: history.undo_top(), occurrences: top_occurrences(history), pending: BTreeSet::new(),
});
return;
};
let moved = forward && step.is_some() && step > mark.step;
let posed: Vec<String> = if forward {
now.iter().filter(|(id, o)| mark.occurrences.get(*id).is_some_and(|m| m.transform != o.transform))
.map(|(id, _)| id.clone()).collect()
} else { Vec::new() };
let gone: Vec<(String, Occurrence)> = mark.occurrences.iter()
.filter(|(id, _)| !now.contains_key(*id)).map(|(id, o)| (id.clone(), o.clone())).collect();
let added: BTreeSet<String> = now.keys().filter(|id| !mark.occurrences.contains_key(*id)).cloned().collect();
let mut pending: BTreeSet<String> = if step == mark.step && forward {
mark.pending.iter().filter(|id| now.contains_key(*id)).cloned().collect()
} else {
BTreeSet::new()
};
if moved && gone.is_empty() { pending.extend(added.iter().cloned()); }
if !pending.is_empty() { pending = designate(&mut doc.engine, pending); }
if moved && !gone.is_empty() && added.is_empty() { retire(&mut doc.engine, &gone); }
if !posed.is_empty() { follow_poses(&mut doc.engine, &posed); }
doc.ecad_occurrences = Some(OccurrenceMark {
revision: doc.engine.history.revision(), step: doc.engine.history.undo_top(), occurrences: now, pending,
});
}
fn adopt_unbound_components(doc: &mut Document) {
if doc.engine.history.locked().is_some() { return; }
let Ok(mut draft) = serde_json::from_str::<Value>(&doc.engine.history.request_json()) else { return; };
let before = draft.clone();
let key = doc.edit_key();
let mut sheets: Vec<(&str, brep_ecad_core::Document)> = ["pcb", "diagram"].iter()
.filter_map(|block| Some((*block, brep_ecad_core::Document::from_value(draft.get(block)?.clone()).ok()?)))
.collect();
let unbound = |c: &brep_ecad_core::Component| c.is_valued_part() && !c.part.as_ref().is_some_and(|p| p.instance.is_some());
if sheets.iter().all(|(_, sheet)| !sheet.components.iter().any(unbound)) { return; }
let mut features = draft["features"].as_array().cloned().unwrap_or_default();
let stored: brep_render::brep_kernel::PartsLibraryMap =
serde_json::from_value(draft["partsLibrary"].clone()).unwrap_or_default();
brep_render::brep_kernel::install_parts_library(&stored);
let mut library = draft["partsLibrary"].as_object().cloned().unwrap_or_default();
let signature_of = |entry: &Value| entry["sourceSignature"].as_str().filter(|s| !s.is_empty()).map(str::to_owned)
.unwrap_or_else(|| super::parts_library::document_signature(&entry["document"].to_string()));
let mut by_label: BTreeMap<String, (String, String, String)> = devices(&features, &Value::Object(library.clone()))
.into_iter().filter(|device| device.local_index.is_some())
.map(|device| (device.reference.clone(), (device.occurrence, device.key, device.signature))).collect();
let mut by_signature: BTreeMap<String, String> = library.iter().map(|(name, entry)| (signature_of(entry), name.clone())).collect();
let mut first = !features.iter().any(is_component);
let mut changed = false;
for (_, sheet) in sheets.iter_mut() {
let placements: Vec<brep_ecad_core::board::Placement> = sheet.board.placements.clone();
let mut used: BTreeSet<String> = sheet.components.iter()
.filter_map(|c| c.part.as_ref()?.instance.clone()).collect();
for component in sheet.components.iter_mut() {
if !unbound(component) { continue; }
let legacy_key = component.part.as_ref().map(|p| p.key.clone());
let found = by_label.get(&component.reference).filter(|(id, key, _)| {
!used.contains(id) && legacy_key.as_ref().is_none_or(|legacy| legacy == key)
}).cloned();
let (id, part_key, signature) = match found {
Some(found) => found,
None => {
let (part_key, signature) = match legacy_key.filter(|key| library.contains_key(key)) {
Some(key) => { let signature = signature_of(&library[&key]); (key, signature) }
None => {
let mut document = serde_json::json!({
"features": [], "symbol": component.symbol, "ports": [pin_ports(&component.symbol, component.pads.as_ref())] });
if let Some(pads) = &component.pads { document["pads"] = serde_json::to_value(pads).unwrap_or_default(); }
let signature = super::parts_library::document_signature(&document.to_string());
let name = match by_signature.get(&signature) {
Some(name) => name.clone(),
None => {
let name = free_part_name(&library, &component.symbol, &component.value);
let added = brep_render::brep_kernel::add_part_to_library_impl(
&name, "", &signature, &document.to_string());
let name = match added {
Ok(name) => name,
Err(error) => {
doc.engine.push_notice(format!(
"'{}' could not be made an assembly component: {error}", component.reference));
continue;
}
};
if let Some(store) = serde_json::from_str::<Value>(&brep_render::brep_kernel::parts_library_json())
.ok().and_then(|store| store.as_object().cloned()) {
library = store;
}
by_signature.insert(signature.clone(), name.clone());
name
}
};
(name, signature)
}
};
let id = doc.engine.history.next_feature_id("ACOMP");
let transform = placements.iter().find(|p| p.component == component.id).map(board_transform)
.unwrap_or_else(|| serde_json::json!({ "translate": [0, 0, 0], "rotateEulerDeg": [0, 0, 0] }));
features.push(serde_json::json!({
"type": "ACOMP",
"inputParams": { "id": id, "partName": part_key, "transform": transform, "isFixed": first,
"bom": { "Reference_Designator": component.reference } },
"persistentData": {}
}));
first = false;
by_label.insert(component.reference.clone(), (id.clone(), part_key.clone(), signature.clone()));
(id, part_key, signature)
}
};
used.insert(id.clone());
component.part = Some(brep_ecad_core::PartSource { instance: Some(id), key: part_key, signature });
changed = true;
}
}
if !changed { return; }
draft["features"] = Value::Array(features);
draft["partsLibrary"] = Value::Object(library);
for (block, sheet) in &sheets {
let Ok(value) = sheet.to_value() else { return; };
draft[*block] = value;
}
if draft.get("assembly").is_none_or(Value::is_null) {
draft["assembly"] = serde_json::json!({ "constraints": [], "idCounter": 0 });
}
if let Err(error) = doc.engine.history.adopt_document(&draft.to_string()) {
doc.engine.push_notice(format!("This board's components could not be bound to assembly components: {error}"));
return;
}
doc.engine.rebuild_current_history();
let Ok(after) = serde_json::from_str::<Value>(&doc.engine.history.request_json()) else { return; };
doc.absorb_open_write(&before, &after, key);
}
fn pin_ports(symbol: &brep_ecad_core::Symbol, pads: Option<&brep_ecad_core::board::Footprint>) -> Value {
let points: Vec<Value> = symbol.pins.iter().map(|pin| {
let position = pads.and_then(|footprint| footprint.pads.iter().find(|pad| pad.number == pin.number))
.map(|pad| [f64::from(pad.at.x) / 1000., -f64::from(pad.at.y) / 1000., 0.])
.unwrap_or([0., 0., 0.]);
serde_json::json!({ "name": pin.number, "transform": { "position": position, "rotationEuler": [0., -90., 0.] } })
}).collect();
serde_json::json!({ "name": "Pins", "purpose": "pcb", "points": points })
}
fn free_part_name(library: &serde_json::Map<String, Value>, symbol: &brep_ecad_core::Symbol, value: &str) -> String {
let base = symbol.library_id.rsplit(':').next().unwrap_or("").trim();
let base = if base.is_empty() { value.trim() } else { base };
let base = if base.is_empty() { "part" } else { base };
let base = base.replace(['/', ':'], "-");
if !library.contains_key(&base) { return base; }
(2u32..).map(|n| format!("{base}-{n}")).find(|name| !library.contains_key(name)).unwrap_or(base)
}
fn designators_in_use(history: &brep_render::history::History, features: &[Value]) -> BTreeSet<String> {
let mut used: BTreeSet<String> = devices(features, history.parts_library()).into_iter()
.flat_map(|device| {
let own = device.reference.rsplit('/').next().unwrap_or_default().to_owned();
[device.reference, own]
})
.collect();
for block in [history.pcb_block(), history.diagram_block()].into_iter().flatten() {
for component in block["components"].as_array().into_iter().flatten() {
if let Some(reference) = component["reference"].as_str() { used.insert(reference.to_owned()); }
}
}
used
}
fn designate(engine: &mut brep_render::engine_state::EngineState, occurrences: BTreeSet<String>) -> BTreeSet<String> {
let history = &engine.history;
let features: Vec<Value> = (0..history.len()).filter_map(|index| {
let kind = history.feature_type(index)?;
if !matches!(kind.as_str(), "ACOMP" | "ASSEMBLY COMPONENT") { return None; }
Some(serde_json::json!({"type": kind, "inputParams": history.feature_params(index)?}))
}).collect();
let mut used = designators_in_use(history, &features);
let mut waiting = BTreeSet::new();
let mut writes: Vec<(String, Value)> = Vec::new();
for device in devices(&features, history.parts_library()) {
if !occurrences.contains(&device.occurrence) { continue; }
let Some(index) = device.local_index else { continue; };
let params = &features[index]["inputParams"];
if params["bom"]["Reference_Designator"].as_str().is_some_and(|d| !d.trim().is_empty()) { continue; }
if !device.known { waiting.insert(device.occurrence); continue; }
let prefix = device.blocks["symbol"]["reference_prefix"].as_str().unwrap_or_default().trim().to_owned();
if prefix.is_empty() || prefix.contains(['/', ':']) { continue; }
let designator = (1u32..).map(|n| format!("{prefix}{n}")).find(|d| !used.contains(d)).unwrap_or_default();
used.insert(designator.clone());
let mut bom = params["bom"].as_object().cloned().unwrap_or_default();
bom.insert("Reference_Designator".into(), Value::String(designator));
writes.push((device.occurrence, Value::Object(bom)));
}
for (id, bom) in writes { engine.history.fold_param_no_undo(&id, "bom", bom); }
waiting
}
fn under(chain: &str, occurrence: &str) -> bool {
chain == occurrence || chain.strip_prefix(occurrence).is_some_and(|rest| rest.starts_with(':'))
}
pub(crate) fn board_transform(placement: &brep_ecad_core::board::Placement) -> Value {
let angle = f64::from(placement.rotation % 4) * 90.;
let z = if placement.bottom { -f64::from(brep_ecad_core::board::SUBSTRATE_THICKNESS) / 1000. } else { 0. };
serde_json::json!({
"translate": [f64::from(placement.at.x) / 1000., -f64::from(placement.at.y) / 1000., z],
"rotateEulerDeg": [0., if placement.bottom {180.} else {0.}, if placement.bottom {angle} else {-angle}],
})
}
fn board_pose(transform: &Value) -> Option<(brep_ecad_core::Point, u8, bool)> {
let reals = |key: &str| -> Option<[f64; 3]> {
let values = transform[key].as_array()?;
Some([values.first()?.as_f64()?, values.get(1)?.as_f64()?, values.get(2)?.as_f64()?])
};
let [x, y, _z] = reals("translate")?;
let [rx, ry, rz] = reals("rotateEulerDeg")?;
let wrap = |angle: f64| angle.rem_euclid(360.);
let near = |a: f64, b: f64| (wrap(a) - wrap(b)).abs() < 1e-6 || (wrap(a) - wrap(b)).abs() > 360. - 1e-6;
if !near(rx, 0.) { return None; }
let bottom = if near(ry, 0.) { false } else if near(ry, 180.) { true } else { return None; };
let angle = wrap(if bottom { rz } else { -rz });
let quarters = (angle / 90.).round();
if (angle - quarters * 90.).abs() > 1e-6 { return None; }
let rotation = (quarters as i64).rem_euclid(4) as u8;
let at = brep_ecad_core::Point::new((x * 1000.).round() as i32, (-y * 1000.).round() as i32);
Some((at, rotation, bottom))
}
fn follow_poses(engine: &mut brep_render::engine_state::EngineState, posed: &[String]) {
let Ok(mut draft) = serde_json::from_str::<Value>(&engine.history.request_json()) else { return; };
let Some(value) = draft.get("pcb").filter(|v| !v.is_null()).cloned() else { return; };
let Ok(mut sheet) = brep_ecad_core::Document::from_value(value) else { return; };
let features = draft["features"].as_array().cloned().unwrap_or_default();
let mut changed = false;
let mut off_plane: Vec<String> = Vec::new();
for id in posed {
let Some(feature) = features.iter().find(|f| f["inputParams"]["id"].as_str() == Some(id)) else { continue; };
let Some((component, reference)) = sheet.components.iter()
.find(|c| c.part.as_ref().and_then(|p| p.instance.as_deref()) == Some(id))
.map(|c| (c.id, c.reference.clone())) else { continue; };
let Some(placement) = sheet.board.placements.iter_mut().find(|p| p.component == component) else { continue; };
match board_pose(&feature["inputParams"]["transform"]) {
Some((at, rotation, bottom)) => {
if (placement.at, placement.rotation, placement.bottom) != (at, rotation, bottom) {
placement.at = at;
placement.rotation = rotation;
placement.bottom = bottom;
changed = true;
}
}
None => off_plane.push(reference),
}
}
if changed {
if let Ok(stored) = sheet.to_value() {
draft["pcb"] = stored;
if let Err(error) = engine.history.adopt_document(&draft.to_string()) {
engine.push_notice(format!("The board placement did not follow the 3D move: {error}"));
return;
}
engine.refresh_board_geometry();
}
}
if !off_plane.is_empty() {
engine.push_notice_as(NoticeSeverity::Warning, format!(
"{} moved off the board plane in 3D. A footprint lies flat on the top or the bottom copper at a \
quarter turn, so the board placement stays where it was until the part is put back flat.",
off_plane.join(", ")));
}
}
fn retire(engine: &mut brep_render::engine_state::EngineState, gone: &[(String, Occurrence)]) {
let Ok(mut draft) = serde_json::from_str::<Value>(&engine.history.request_json()) else { return; };
let lost = |instance: Option<&str>| instance.is_some_and(|chain| gone.iter().any(|(g, _)| under(chain, g)));
let lost_component = |c: &brep_ecad_core::Component| match &c.part {
Some(source) if source.instance.is_some() => lost(source.instance.as_deref()),
Some(source) => gone.iter().any(|(_, o)| o.key == source.key && o.reference == c.reference),
None => false,
};
let mut lines: Vec<String> = Vec::new();
let mut retired: Vec<String> = Vec::new();
let mut changed = false;
for (block, sheet_name) in [("pcb", "the schematic and the board"), ("diagram", "the wiring diagram")] {
let Some(value) = draft.get(block).filter(|v| !v.is_null()).cloned() else { continue; };
let Ok(mut sheet) = brep_ecad_core::Document::from_value(value) else { continue; };
let doomed: Vec<(brep_ecad_core::Uuid, String)> = sheet.components.iter()
.filter(|c| lost_component(c))
.map(|c| (c.id, c.reference.clone())).collect();
if doomed.is_empty() { continue; }
let wired = sheet.wires.iter().filter(|w| [&w.start, &w.end].into_iter().flatten()
.any(|t| doomed.iter().any(|(id, _)| *id == t.component))).count();
let placed = doomed.iter().filter(|(id, _)| sheet.board.placement(*id).is_some()).count();
for (id, _) in &doomed { sheet.delete_component(*id); }
let Ok(stored) = sheet.to_value() else { continue; };
draft[block] = stored;
changed = true;
for (_, name) in &doomed { if !retired.contains(name) { retired.push(name.clone()); } }
let plural = |n: usize, one: &str| format!("{n} {one}{}", if n == 1 { "" } else { "s" });
let mut line = format!("off {sheet_name}");
if block == "pcb" {
let mut kept = Vec::new();
if wired > 0 { kept.push(format!("{} to it left unconnected", plural(wired, "wire"))); }
if placed > 0 { kept.push("any track to its pads kept as unconnected copper".to_owned()); }
if !kept.is_empty() { line.push_str(&format!(" ({})", kept.join("; "))); }
} else if wired > 0 {
line.push_str(&format!(" with its {}", plural(wired, "connection")));
}
lines.push(line);
}
if let Some(connections) = draft.get_mut("wireHarness").and_then(|h| h.get_mut("connections")).and_then(Value::as_array_mut) {
let before = connections.len();
connections.retain(|c| !["from", "to"].iter().any(|end| lost(c[end].as_str())));
let dropped = before - connections.len();
if dropped > 0 {
changed = true;
lines.push(format!("{dropped} harness connection{} dropped", if dropped == 1 { "" } else { "s" }));
}
}
if !changed { return; }
if let Err(error) = engine.history.adopt_document(&draft.to_string()) {
engine.push_notice(format!("The deleted part stays on the eCAD sheets: {error}"));
return;
}
engine.rebuild_current_history();
let what = if retired.is_empty() {
gone.iter().map(|(_, o)| o.reference.as_str()).collect::<Vec<_>>().join(", ")
} else {
retired.join(", ")
};
engine.push_notice_as(NoticeSeverity::Warning, format!("{what} left the assembly, so it left eCAD too: {}. Undo brings it all back.",
lines.join(", ")));
}
pub(crate) fn delete_gone_occurrences(
draft: &mut Value, stored: Option<&Value>, sheet: &brep_ecad_core::Document,
) -> Result<Vec<String>, String> {
let Some(stored) = stored.cloned().and_then(|value| brep_ecad_core::Document::from_value(value).ok()) else {
return Ok(Vec::new());
};
let kept: BTreeSet<&str> = sheet.components.iter()
.filter_map(|c| c.part.as_ref()?.instance.as_deref()).collect();
let kept_legacy: BTreeSet<(&str, &str)> = sheet.components.iter()
.filter_map(|c| { let part = c.part.as_ref()?; part.instance.is_none().then_some((part.key.as_str(), c.reference.as_str())) })
.collect();
let mut gone: Vec<String> = Vec::new();
for component in &stored.components {
let Some(part) = &component.part else { continue };
match &part.instance {
Some(chain) if !kept.contains(chain.as_str()) => {
if let Some((parent, _)) = chain.split_once(':') {
return Err(format!(
"'{}' is a device inside '{parent}', a sub-assembly of this document: it is deleted \
from the sub-assembly's own document, not from this sheet. The edit was taken back.",
component.reference
));
}
if remove_feature_by_id(draft, chain) { gone.push(component.reference.clone()); }
}
Some(_) => {}
None if !kept_legacy.contains(&(part.key.as_str(), component.reference.as_str())) => {
let features = draft["features"].as_array().cloned().unwrap_or_default();
let matched: Vec<&str> = features.iter().filter(|feature| is_component(feature)
&& feature["inputParams"]["partName"].as_str() == Some(part.key.as_str())
&& feature["inputParams"]["bom"]["Reference_Designator"].as_str().map(str::trim) == Some(component.reference.as_str()))
.filter_map(|feature| feature["inputParams"]["id"].as_str()).collect();
if let [id] = matched[..] {
if remove_feature_by_id(draft, id) { gone.push(component.reference.clone()); }
}
}
None => {}
}
}
Ok(gone)
}
fn remove_feature_by_id(draft: &mut Value, id: &str) -> bool {
let Some(features) = draft["features"].as_array_mut() else { return false };
let before = features.len();
features.retain(|feature| feature["inputParams"]["id"].as_str() != Some(id));
features.len() != before
}
pub(crate) fn answer_duplicate_requests(doc: &mut Document) {
let requests: Vec<String> = doc.ecad.diagram.duplicate_requests.drain(..)
.chain(doc.ecad.pcb.duplicate_requests.drain(..)).collect();
if requests.is_empty() { return; }
let mut answered = false;
for key in requests {
if !doc.engine.history.parts_library()[&key].is_object() {
doc.engine.push_notice(format!(
"Could not add another '{key}' to the assembly: this document's parts library does not carry it \
(a device inside a sub-assembly is added in the sub-assembly's own document)."));
continue;
}
match doc.engine.insert_component(brep_render::engine_state::ComponentInsert::Existing { part_name: &key }) {
Ok(_) => answered = true,
Err(error) => doc.engine.push_notice(format!("Could not add another '{key}' to the assembly: {error}")),
}
}
if answered { sync(doc); }
for editor in [&mut doc.ecad.diagram, &mut doc.ecad.pcb] {
editor.arm_pending_duplicate();
editor.cancel_pending_duplicate();
}
}
pub(crate) fn sync(doc: &mut Document) {
follow_occurrences(doc);
if !matches!(doc.engine.settings.workbench.as_str(), "diagram" | "pcb") { return; }
let history = &doc.engine.history;
let revision = history.revision();
if doc.ecad_parts_revision == Some(revision) { return; }
let features: Vec<_> = (0..history.len()).filter_map(|index| {
let kind = history.feature_type(index)?;
if !matches!(kind.as_str(), "ACOMP" | "ASSEMBLY COMPONENT") { return None; }
Some(serde_json::json!({"type": kind, "inputParams": history.feature_params(index)?}))
}).collect();
let reached = devices(&features, history.parts_library());
let missing: Vec<String> = reached.iter().filter(|device| !device.known)
.map(|device| format!(
"'{}' is placed from part '{}', which this assembly's library does not \
carry — it cannot be offered to Diagram or PCB.", device.reference, device.key))
.collect();
let (offered, refused) = offers_and_refusals(reached);
let missing: Vec<String> = missing.into_iter().chain(refused).collect();
doc.ecad.diagram.parts = offered.iter().filter(|offer| offer.symbol).map(|offer| offer.part.clone()).collect();
doc.ecad.pcb.parts = offered.into_iter().map(|offer| offer.part).collect();
let hints = [
(&mut doc.ecad.diagram, "A part appears here once the assembly holds it and it has a symbol. Draw one in the Symbol workbench, or import one from KiCad into the part."),
(&mut doc.ecad.pcb, "A part appears here once the assembly holds it and it has a symbol or pads. Parts with pads go onto the board as well."),
];
for (editor, hint) in hints {
editor.parts_hint = hint.into();
editor.add_part_label = Some(ADD_PART_LABEL.into());
editor.labels_from_host = Some("Named by the BOM: rename it in the BOM pane's Reference_Designator column.".into());
}
let fresh: Vec<String> = missing.iter().filter(|line| !doc.ecad_parts_reported.contains(line)).cloned().collect();
doc.ecad_parts_reported = missing;
for line in fresh { doc.engine.push_notice_as(NoticeSeverity::Warning, line); }
doc.ecad_parts_revision = Some(revision);
}
const ADD_PART_LABEL: &str = "Add part to assembly\u{2026}";
pub static HIT_KEYS: &[crate::automation::hit_keys::HitKeyDoc] = &[
crate::automation::hit_keys::HitKeyDoc { panel: "ecadparts", prefix: "parts:add", meaning: "add a part to the assembly: Assembly's Add Component (the insert-component modal), the same arm as the toolbar's assembly.add_component.", command: Some("workbench_button") },
crate::automation::hit_keys::HitKeyDoc { panel: "ecadparts", prefix: "parts:search", meaning: "the search field over the part cards (shown while the pane lists any)", command: None },
crate::automation::hit_keys::HitKeyDoc { panel: "ecadparts", prefix: "parts:card:", meaning: "a part card (parts:card:<reference>, or <name> for a part with no reference), visible rows only: a click starts placing it, a drag drops it on the sheet, a right-click opens its menu", command: None },
];
pub(crate) fn take_add_part_request(doc: &mut Document) -> bool {
let active = match doc.engine.settings.workbench.as_str() {
"diagram" => Some(&doc.ecad.diagram),
"pcb" => Some(&doc.ecad.pcb),
_ => None,
};
if crate::automation::registry::enabled() {
let hits: Vec<(String, eframe::egui::Rect)> = active.map(|e| e.library_hits.clone()).unwrap_or_default();
crate::automation::registry::publish(
"__brepEcadPartsHit",
"the eCAD parts pane's widget rects (parts:add, parts:search, parts:card:<reference>)",
&crate::automation::hit_rects::hits_json(hits.iter().map(|(k, r)| (k, r))),
);
}
let mut asked = false;
for editor in [&mut doc.ecad.diagram, &mut doc.ecad.pcb] {
editor.library_hits.clear();
asked |= std::mem::take(&mut editor.add_part_requested);
}
asked
}
pub(crate) fn take_open_requests(doc: &mut Document) -> Vec<String> {
let requests: BTreeSet<_> = doc.ecad.diagram.open_part_requests.drain(..)
.chain(doc.ecad.pcb.open_part_requests.drain(..)).collect();
if requests.is_empty() { return Vec::new(); }
let document: Value = serde_json::from_str(&doc.engine.history.request_json()).unwrap_or_default();
let mut entries: BTreeMap<String, String> = BTreeMap::new();
let mut libraries = vec![document["partsLibrary"].clone()];
while let Some(library) = libraries.pop() {
for (key, entry) in library.as_object().into_iter().flatten() {
let source = entry["sourceKey"].as_str().unwrap_or("").to_owned();
entries.entry(key.clone()).or_insert(source);
let nested = entry["document"]["partsLibrary"].clone();
if nested.is_object() { libraries.push(nested); }
}
}
requests.into_iter().filter_map(|part| {
match entries.get(&part).filter(|key| !key.is_empty()) {
Some(key) => Some(key.clone()),
None => { doc.engine.push_notice(format!("'{part}' is embedded only; use Edit Part in Assembly to edit it.")); None }
}
}).collect()
}
pub(crate) fn refresh_snapshots(
assembly: &mut Value, before: &Value, key: &str,
) -> Result<Vec<String>, String> {
let now = devices(features_of(assembly), &assembly["partsLibrary"]);
let then = devices(features_of(before), &before["partsLibrary"]);
let nothing = Value::Null;
let entry = &assembly["partsLibrary"][key];
let legacy = Device {
occurrence: String::new(), key: key.to_owned(),
signature: entry["sourceSignature"].as_str().unwrap_or_default().to_owned(),
reference: String::new(), blocks: blocks_of(&entry["document"]), known: true,
local_index: None,
};
let legacy_before = blocks_of(&before["partsLibrary"][key]["document"]);
let resolve = |source: &brep_ecad_core::PartSource| -> Option<(&Device, &Value)> {
match &source.instance {
Some(chain) => Some((
now.iter().find(|device| device.occurrence == *chain)?,
then.iter().find(|device| device.occurrence == *chain)
.map_or(¬hing, |device| &device.blocks),
)),
None if source.key == key => Some((&legacy, &legacy_before)),
None => None,
}
};
let mut notes: Vec<String> = Vec::new();
for block in ["diagram", "pcb"] {
let Some(value) = assembly.get(block).filter(|v| !v.is_null()) else { continue; };
let mut sheet = brep_ecad_core::Document::from_value(value.clone())
.map_err(|e| format!("{block}: {e}"))?;
let mut moved = false;
for component in sheet.components.clone() {
let Some(source) = &component.part else { continue; };
let Some((device, was)) = resolve(source) else { continue; };
if device.signature.is_empty() || device.signature == source.signature { continue; }
let mut refused = |error: String| {
let line = format!("'{}' kept the version it had: part '{}' {error}",
component.reference, device.key);
if !notes.contains(&line) { notes.push(line) }
};
let symbol = match device.blocks.get("symbol").filter(|v| !v.is_null())
.map(|v| serde_json::from_value(v.clone())).transpose() {
Ok(symbol) => symbol.unwrap_or_else(|| empty_symbol(&device.key)),
Err(error) => { refused(format!("symbol: {error}")); continue; }
};
let pads: Option<brep_ecad_core::board::Footprint> =
match device.blocks.get("pads").filter(|v| !v.is_null())
.map(|v| serde_json::from_value(v.clone())).transpose() {
Ok(pads) => pads.map(|pads| named_pads(pads, &device.key)),
Err(error) => { refused(format!("pads: {error}")); continue; }
};
let renames = brep_render::brep_kernel::point_renames(was, &device.blocks);
if let Err(error) = sheet.refresh_component(
component.id, &symbol, pads.as_ref(), &device.signature, &renames.all()) {
refused(error);
continue;
}
moved = true;
for (from, to) in &renames.positional {
let line = format!(
"'{}': point '{from}' was read as a rename to '{to}' by POSITION — nothing in \
either version seats it, so a deleted point and a new one would read the \
same. Check the connections on '{to}'.", device.key);
if !notes.contains(&line) { notes.push(line) }
}
}
if moved { assembly[block] = sheet.to_value()?; }
}
Ok(notes)
}
pub(crate) fn declared_endpoints(document: &Value) -> Vec<brep_render::brep_kernel::WireHarnessEndpoint> {
devices(features_of(document), &document["partsLibrary"]).iter()
.flat_map(|device| brep_render::brep_kernel::declared_point_endpoints(
&device.occurrence, &device.blocks))
.collect()
}
fn unlinked_note(component: &brep_ecad_core::Component, reason: &str) -> String {
format!("'{}' is UNLINKED: {reason}. Pick the device it belongs to.", component.reference)
}
pub(crate) fn follow_sheet(
document: &mut Value, block: &str, sheet: &brep_ecad_core::Document,
endpoints: &[brep_render::brep_kernel::WireHarnessEndpoint],
) -> Result<Vec<String>, String> {
let features = document["features"].as_array().cloned().unwrap_or_default();
let reached = devices(&features, &document["partsLibrary"]);
let mut problems = Vec::new();
let mut note = |line: String| if !problems.contains(&line) { problems.push(line) };
let device = |component: &brep_ecad_core::Component| -> Result<Option<&Device>, String> {
let Some(source) = &component.part else { return Ok(None) };
if let Some(chain) = &source.instance {
return reached.iter().find(|device| device.occurrence == *chain).map(Some).ok_or_else(||
unlinked_note(component, &format!("this assembly no longer has the occurrence '{chain}'")));
}
let matches: Vec<&Device> = reached.iter().filter(|device|
device.key == source.key && device.reference == component.reference).collect();
match matches.as_slice() {
[device] => Ok(Some(device)),
[] => Err(unlinked_note(component, "no device of this assembly carries its label")),
several => Err(unlinked_note(component,
&format!("{} devices carry its label", several.len()))),
}
};
if block == "pcb" {
for placement in &sheet.board.placements {
let Some(component) = sheet.components.iter().find(|c| c.id == placement.component) else { continue; };
let device = match device(component) {
Ok(Some(device)) => device,
Ok(None) => continue,
Err(line) => { note(line); continue; }
};
let Some(index) = device.local_index else {
note(format!("'{}' sits inside '{}' and its board placement is not written back to the assembly.",
component.reference, device.occurrence));
continue;
};
document["features"][index]["inputParams"]["transform"] = board_transform(placement);
}
} else if block == "diagram" {
let mut connections = document["wireHarness"]["connections"].as_array().cloned().unwrap_or_default();
let previous = connections.clone();
connections.retain(|c| !c["id"].as_str().is_some_and(|id| id.starts_with("diagram:")));
for wire in &sheet.wires {
let (Some(start), Some(end)) = (&wire.start, &wire.end) else { continue; };
let resolve = |terminal: &brep_ecad_core::Terminal| -> Result<Option<String>, String> {
let component = sheet.components.iter().find(|c| c.id == terminal.component)
.ok_or_else(|| "A connection names a component the sheet no longer has.".to_string())?;
let Some(device) = device(component)? else {
return Ok(None);
};
let group = component.symbol.pins.iter().find(|pin| pin.number == terminal.pin)
.and_then(|pin| brep_render::brep_kernel::port_group_for_unit(&device.blocks, pin.unit));
brep_render::brep_kernel::resolve_component_pin(
endpoints, &device.occurrence, group.as_deref(), &terminal.pin)
.map(Some)
.map_err(|reason| format!("'{}' pin '{}': {reason}", component.reference, terminal.pin))
};
let (from, to) = match (resolve(start), resolve(end)) {
(Ok(Some(from)), Ok(Some(to))) => (from, to),
(from, to) => {
for line in [from.err(), to.err()].into_iter().flatten() { note(line); }
continue;
}
};
let id = format!("diagram:{}", wire.id);
let diameter = previous.iter().find(|c| c["id"] == id)
.and_then(|c| c["diameter"].as_f64()).unwrap_or(1.0);
connections.push(serde_json::json!({"id": id, "name": wire.connection_id,
"from": from, "to": to, "diameter": diameter}));
}
if !connections.is_empty() || document.get("wireHarness").is_some() {
if !document["wireHarness"].is_object() { document["wireHarness"] = serde_json::json!({}); }
document["wireHarness"]["connections"] = Value::Array(connections);
}
}
Ok(problems)
}