use super::*;
use serde_json::{json, Value};
use std::borrow::Borrow;
use std::collections::{BTreeMap, BTreeSet};
pub const DERIVED_FROM_KEY: &str = "derivedFrom";
pub const REPLACE_MARKER: &str = "replace";
const BROKEN: [&str; 3] = ["invalid-selection", "unsupported-selection", "error"];
pub const BROKEN_STATUSES: [&str; 3] = BROKEN;
pub fn lineage_keys(
document: &Value,
identity: &str,
canonicalize: &dyn Fn(&str) -> String,
) -> BTreeSet<String> {
let mut keys = BTreeSet::new();
if !identity.trim().is_empty() {
keys.insert(canonicalize(identity.trim()));
}
for entry in document[DERIVED_FROM_KEY].as_array().into_iter().flatten() {
if let Some(entry) = entry.as_str().map(str::trim).filter(|e| !e.is_empty()) {
if entry.starts_with("family:") {
keys.insert(entry.to_string());
} else {
keys.insert(canonicalize(entry));
}
}
}
if let Some(family) = family_key(document) {
keys.insert(family);
}
keys
}
pub fn family_key(document: &Value) -> Option<String> {
document["familySource"]["family"]
.as_str()
.map(str::trim)
.filter(|family| !family.is_empty())
.map(|family| format!("family:{family}"))
}
pub fn shared_lineage(old: &BTreeSet<String>, new: &BTreeSet<String>) -> Option<&'static str> {
let common: Vec<&String> = old.intersection(new).collect();
if common.is_empty() {
None
} else if common.iter().any(|key| !key.starts_with("family:")) {
Some("derived")
} else {
Some("family")
}
}
pub fn stamp_derived_from(document: &mut Value, source_identity: &str) {
let Some(object) = document.as_object_mut() else { return };
let family = object
.get("familySource")
.and_then(|source| source.get("family"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|family| !family.is_empty())
.map(|family| format!("family:{family}"));
let chain = object
.entry(DERIVED_FROM_KEY)
.or_insert_with(|| Value::Array(Vec::new()));
if !chain.is_array() {
*chain = Value::Array(Vec::new());
}
let list = chain.as_array_mut().expect("array");
for entry in [Some(source_identity.trim().to_string()), family].into_iter().flatten() {
if !entry.is_empty() && !list.iter().any(|e| e.as_str() == Some(&entry)) {
list.push(Value::String(entry));
}
}
}
fn triple(value: &Value) -> Option<[f64; 3]> {
let list = value.as_array()?;
if list.len() != 3 {
return None;
}
Some([list[0].as_f64()?, list[1].as_f64()?, list[2].as_f64()?])
}
use crate::geometry3d::sub3 as sub;
use crate::geometry3d::dot3 as dot;
use crate::geometry3d::len3 as norm;
fn line_distance(p: [f64; 3], origin: [f64; 3], direction: [f64; 3]) -> f64 {
let d = sub(p, origin);
let along = dot(d, direction);
(dot(d, d) - along * along).max(0.0).sqrt()
}
const POSITION_TOLERANCE: f64 = 1e-6;
const ANGLE_TOLERANCE: f64 = 1e-9;
pub fn geometry_agrees(old: &Value, new: &Value) -> bool {
if old["geom"] != new["geom"] {
return false;
}
let (Some(p_old), Some(p_new)) = (triple(&old["point"]), triple(&new["point"])) else {
return false;
};
let directions = (triple(&old["direction"]), triple(&new["direction"]));
let radius_agrees = match (old["radius"].as_f64(), new["radius"].as_f64()) {
(Some(a), Some(b)) => (a - b).abs() <= POSITION_TOLERANCE * (1.0 + a.abs()),
(None, None) => true,
_ => false,
};
let scale = POSITION_TOLERANCE * (1.0 + norm(p_old));
match old["geom"].as_str().unwrap_or("") {
"plane" => match directions {
(Some(n_old), Some(n_new)) => {
dot(n_old, n_new) >= 1.0 - ANGLE_TOLERANCE
&& dot(sub(p_old, p_new), n_new).abs() <= scale
}
_ => false,
},
"axis" | "line" => match directions {
(Some(d_old), Some(d_new)) => {
dot(d_old, d_new).abs() >= 1.0 - ANGLE_TOLERANCE
&& line_distance(p_old, p_new, d_new) <= scale
&& radius_agrees
}
_ => false,
},
"circle" => match directions {
(Some(d_old), Some(d_new)) => {
dot(d_old, d_new).abs() >= 1.0 - ANGLE_TOLERANCE
&& norm(sub(p_old, p_new)) <= scale
&& radius_agrees
}
_ => false,
},
"sphere" | "point" => norm(sub(p_old, p_new)) <= scale && radius_agrees,
_ => false,
}
}
fn suggestion_score(old: &Value, new: &Value) -> f64 {
let distance = match (triple(&old["point"]), triple(&new["point"])) {
(Some(a), Some(b)) => norm(sub(a, b)),
_ => f64::INFINITY,
};
let misalignment = match (triple(&old["direction"]), triple(&new["direction"])) {
(Some(a), Some(b)) => {
if old["geom"] == "plane" {
1.0 - dot(a, b)
} else {
1.0 - dot(a, b).abs()
}
}
_ => 0.0,
};
distance + 1000.0 * misalignment
}
fn local_name<'a>(component: &str, reference: &'a str) -> &'a str {
reference
.strip_prefix(component)
.and_then(|rest| rest.strip_prefix(':'))
.unwrap_or(reference)
}
#[derive(Debug, Clone, PartialEq)]
pub enum RefOutcome {
Kept { split: bool },
Unresolved {
reason: &'static str,
suggestion: Option<Suggestion>,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct Suggestion {
pub reference: String,
pub basis: &'static str,
pub pre_ticked: bool,
}
impl Suggestion {
fn to_json(&self) -> Value {
json!({ "ref": self.reference, "basis": self.basis, "preTicked": self.pre_ticked })
}
}
pub fn classify_ref<V: std::borrow::Borrow<Value>>(
component: &str,
reference: &str,
pre: &Value,
candidates: &[V],
old_names: &BTreeSet<String>,
trusted: bool,
) -> RefOutcome {
let name = local_name(component, reference);
let by_name = candidates
.iter()
.map(Borrow::borrow)
.find(|candidate| candidate["ref"].as_str().map(|r| local_name(component, r)) == Some(name));
if trusted {
match by_name {
Some(candidate) if candidate["geom"] == pre["geom"] => {
let split_piece = format!("{name}_1");
let split = !old_names.contains(&split_piece)
&& candidates.iter().map(Borrow::borrow).any(|c: &Value| {
c["ref"].as_str().map(|r| local_name(component, r)) == Some(split_piece.as_str())
});
return RefOutcome::Kept { split };
}
Some(_) => {
return RefOutcome::Unresolved {
reason: "type-changed",
suggestion: nearest(pre, candidates),
}
}
None => {
return RefOutcome::Unresolved { reason: "missing", suggestion: nearest(pre, candidates) }
}
}
}
let suggestion = match by_name {
Some(candidate) if candidate["geom"] == pre["geom"] => Some(Suggestion {
reference: candidate["ref"].as_str().unwrap_or_default().to_string(),
basis: "same-name",
pre_ticked: geometry_agrees(pre, candidate),
}),
_ => nearest(pre, candidates),
};
RefOutcome::Unresolved { reason: "unconfirmed", suggestion }
}
pub fn classify_vertex_ref(reference: &str, pre: &Value, post: Option<&Value>, trusted: bool) -> RefOutcome {
let found = post.filter(|row| row["ok"] == true);
let same_point = found.is_some_and(|row| geometry_agrees(pre, row));
if trusted && same_point {
return RefOutcome::Kept { split: false };
}
let suggestion = found.map(|_| Suggestion {
reference: reference.to_string(),
basis: "same-name",
pre_ticked: same_point,
});
let reason = match (trusted, found) {
(false, _) => "unconfirmed",
(true, Some(_)) => "moved",
(true, None) => "missing",
};
RefOutcome::Unresolved { reason, suggestion }
}
fn nearest<V: std::borrow::Borrow<Value>>(pre: &Value, candidates: &[V]) -> Option<Suggestion> {
candidates
.iter()
.map(Borrow::borrow)
.filter(|candidate| candidate["geom"] == pre["geom"])
.map(|candidate| (suggestion_score(pre, candidate), candidate))
.filter(|(score, _)| score.is_finite())
.min_by(|a, b| a.0.total_cmp(&b.0))
.map(|(_, candidate)| Suggestion {
reference: candidate["ref"].as_str().unwrap_or_default().to_string(),
basis: "nearest",
pre_ticked: geometry_agrees(pre, candidate),
})
}
pub fn marker_json(origin: &str, from: &str, to: &str, was_enabled: bool, refs: Vec<Value>) -> Value {
json!({
"origin": origin,
"from": from,
"to": to,
"wasEnabled": was_enabled,
"refs": refs,
})
}
fn unique_carrier(pre: &Value, candidates: &[&Value]) -> Option<String> {
let mut agreeing = candidates.iter().filter(|candidate| geometry_agrees(pre, candidate));
let only = agreeing.next()?;
if agreeing.next().is_some() {
return None;
}
only["ref"].as_str().map(str::to_string)
}
#[derive(Debug, Clone)]
pub(crate) struct AffectedMate {
id: String,
constraint_type: String,
was_enabled: bool,
refs: Vec<(String, String, Value)>,
home: Option<(String, String)>,
}
#[derive(Debug, Clone)]
pub(crate) struct PendingReplace {
generation: u64,
components: Vec<String>,
from: BTreeMap<String, String>,
to: String,
lineage: BTreeMap<String, &'static str>,
affected: Vec<AffectedMate>,
old_names: BTreeMap<String, BTreeSet<String>>,
notes: Vec<String>,
origin: &'static str,
signatures: BTreeMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct ReplaceCapture {
components: Vec<String>,
affected: Vec<AffectedMate>,
old_names: BTreeMap<String, BTreeSet<String>>,
}
pub fn entries_lineage(old: &Value, new: &Value, canonicalize: &dyn Fn(&str) -> String) -> Option<&'static str> {
let old_keys = lineage_keys(&old["document"], old["sourceKey"].as_str().unwrap_or(""), canonicalize);
let new_keys = lineage_keys(&new["document"], new["sourceKey"].as_str().unwrap_or(""), canonicalize);
shared_lineage(&old_keys, &new_keys)
}
#[derive(Default)]
struct EntityListings {
by_top: BTreeMap<String, Vec<Value>>,
names_by_owner: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
rows_by_owner: BTreeMap<String, BTreeMap<String, Vec<usize>>>,
}
fn owners_below<'a>(top: &'a str, reference: &'a str) -> impl Iterator<Item = String> + 'a {
let rest = reference.strip_prefix(top).and_then(|r| r.strip_prefix(':')).unwrap_or("");
rest.match_indices(':').map(move |(at, _)| format!("{top}:{}", &rest[..at]))
}
impl EntityListings {
fn listed(&mut self, top: &str) -> &[Value] {
self.by_top.entry(top.to_string()).or_insert_with(|| {
entities_of(top)
})
}
fn index(&mut self, path: &str) {
let top = path.split(':').next().unwrap_or(path).to_string();
if self.rows_by_owner.contains_key(&top) {
return;
}
let rows = self.listed(&top);
let mut index: BTreeMap<String, Vec<usize>> = BTreeMap::new();
index.insert(top.clone(), (0..rows.len()).collect());
for (at, row) in rows.iter().enumerate() {
let Some(reference) = row["ref"].as_str() else { continue };
for owner in owners_below(&top, reference) {
index.entry(owner).or_default().push(at);
}
}
self.rows_by_owner.insert(top, index);
}
fn under(&self, path: &str) -> Vec<&Value> {
let top = path.split(':').next().unwrap_or(path);
let (Some(rows), Some(index)) = (self.by_top.get(top), self.rows_by_owner.get(top)) else {
return Vec::new();
};
index.get(path).into_iter().flatten().map(|&at| &rows[at]).collect()
}
fn names(&mut self, path: &str) -> BTreeSet<String> {
let top = path.split(':').next().unwrap_or(path).to_string();
if !self.names_by_owner.contains_key(&top) {
self.listed(&top);
let mut index: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for row in self.by_top.get(&top).into_iter().flatten() {
let Some(reference) = row["ref"].as_str() else { continue };
let Some(rest) = reference.strip_prefix(&top).and_then(|r| r.strip_prefix(':')) else {
index.entry(top.clone()).or_default().insert(reference.to_string());
continue;
};
let mut owner = top.clone();
index.entry(owner.clone()).or_default().insert(rest.to_string());
let mut tail = rest;
while let Some((segment, after)) = tail.split_once(':') {
owner = format!("{owner}:{segment}");
index.entry(owner.clone()).or_default().insert(after.to_string());
tail = after;
}
}
self.names_by_owner.insert(top.clone(), index);
}
self.names_by_owner[&top].get(path).cloned().unwrap_or_default()
}
}
fn entities_of(path: &str) -> Vec<Value> {
let top = path.split(':').next().unwrap_or(path);
let prefix = format!("{path}:");
parse(&brep_kernel::assembly_component_entities_json(top))
.as_array()
.cloned()
.unwrap_or_default()
.into_iter()
.filter(|row| top == path || row["ref"].as_str().is_some_and(|r| r.starts_with(&prefix)))
.collect()
}
fn document_in_chain_mut<'a>(document: &'a mut Value, chain: &[String]) -> Option<&'a mut Value> {
let mut current = document;
for part in chain {
current = current.get_mut("partsLibrary")?.get_mut(part.as_str())?.get_mut("document")?;
}
Some(current)
}
fn owner_among<'a>(ids: &'a [String], reference: &str) -> Option<&'a String> {
ids.iter().find(|id| {
reference == id.as_str()
|| reference
.strip_prefix(id.as_str())
.is_some_and(|rest| rest.starts_with(':'))
})
}
pub fn follow_pattern_seeds(
document: &Value,
from: &BTreeMap<String, String>,
to: &str,
instances: &[String],
) -> Vec<String> {
let mut notes = Vec::new();
for feature in document["features"].as_array().into_iter().flatten() {
if feature["type"] != "APAT" {
continue;
}
let pattern = feature["inputParams"]["id"].as_str().unwrap_or_default().to_string();
let mut seeded = BTreeSet::new();
for name in feature["inputParams"]["seeds"].as_array().into_iter().flatten().filter_map(Value::as_str) {
let owner = name.split(':').next().unwrap_or(name).to_string();
if from.contains_key(&owner) {
seeded.insert(owner);
}
}
for owner in seeded {
let count = instances
.iter()
.filter_map(|id| brep_kernel::pattern_component_instance(id))
.filter(|(p, s, _)| *p == pattern && *s == owner)
.count();
notes.push(format!("{pattern}: its {count} instance(s) of {owner} follow the seed and now show '{to}'"));
}
}
notes
}
fn parse(json: &str) -> Value {
serde_json::from_str(json).unwrap_or(Value::Null)
}
impl EngineState {
pub fn replace_components(
&mut self,
component_ids: &[String],
insert: ComponentInsert<'_>,
canonicalize: &dyn Fn(&str) -> String,
) -> Result<String, String> {
if component_ids.is_empty() {
return Err("replace: no component given".into());
}
let mut ids: Vec<String> = Vec::new();
let mut instance_notes = Vec::new();
for id in component_ids {
let id = match brep_kernel::pattern_component_instance(id) {
Some((pattern, seed, n)) => {
instance_notes.push(format!(
"{id} is instance {n} of {pattern} and follows its seed {seed}: the seed was replaced"
));
seed
}
None => id.clone(),
};
if !ids.contains(&id) {
ids.push(id);
}
}
let component_ids = ids.as_slice();
let mut from = BTreeMap::new();
for id in component_ids {
let info = self
.component_info(id)
.ok_or_else(|| format!("replace: '{id}' is not an assembly component"))?;
from.insert(id.clone(), info.part_name);
}
let capture = self.capture_replace(component_ids);
let old_library = self.history.parts_library().clone();
let to = self.resolve_component_insert(insert)?;
let library = parse(&brep_kernel::parts_library_json());
let new_entry = &library[&to];
let mut lineage = BTreeMap::new();
for (id, part) in &from {
if part == &to {
continue;
}
let old_entry = if old_library[part].is_object() { &old_library[part] } else { &library[part] };
if let Some(word) = entries_lineage(old_entry, new_entry, canonicalize) {
lineage.insert(id.clone(), word);
}
}
if from.values().all(|part| part == &to) {
return Err(format!("replace: the component already shows '{to}'"));
}
let mut document = parse(&self.history_request_json());
document["partsLibrary"] = library.clone();
for feature in document["features"].as_array_mut().into_iter().flatten() {
let id = feature["inputParams"]["id"].as_str().unwrap_or_default().to_string();
if from.contains_key(&id) {
feature["inputParams"]["partName"] = Value::String(to.clone());
}
}
let mut notes = instance_notes;
notes.extend(follow_pattern_seeds(&document, &from, &to, &self.pattern_instance_ids()));
self.commit_replace(capture, document, from, to.clone(), lineage, notes)?;
Ok(to)
}
pub fn capture_replace(&mut self, paths: &[String]) -> ReplaceCapture {
self.capture_replace_listed(paths, &mut EntityListings::default())
}
fn capture_replace_listed(&mut self, paths: &[String], listings: &mut EntityListings) -> ReplaceCapture {
self.ensure_assembly_session();
let state = parse(&brep_kernel::assembly_state_json());
let mut affected = Vec::new();
for entry in state["constraints"].as_array().into_iter().flatten() {
let refs: Vec<(String, String)> = entry["inputParams"]["elements"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.filter_map(|r| owner_among(paths, r).map(|owner| (owner.clone(), r.to_string())))
.collect();
if refs.is_empty() {
continue;
}
let enabled = entry["enabled"].as_bool().unwrap_or(true);
let status = entry["persistentData"]["status"].as_str().unwrap_or("");
if enabled && BROKEN.contains(&status) {
continue; }
let names: Vec<&String> = refs.iter().map(|(_, r)| r).collect();
let rows = parse(&brep_kernel::assembly_resolve_refs_json(
&serde_json::to_string(&names).unwrap_or_default(),
));
let rows = rows.as_array().cloned().unwrap_or_default();
affected.push(AffectedMate {
id: entry["inputParams"]["id"].as_str().unwrap_or_default().to_string(),
constraint_type: entry["type"].as_str().unwrap_or_default().to_string(),
was_enabled: enabled,
refs: refs
.into_iter()
.zip(rows.into_iter().chain(std::iter::repeat(Value::Null)))
.map(|((owner, r), row)| (owner, r, row))
.collect(),
home: None,
});
}
let mut old_names = BTreeMap::new();
for path in paths {
old_names.insert(path.clone(), listings.names(path));
}
ReplaceCapture { components: paths.to_vec(), affected, old_names }
}
pub fn capture_part_edit(&mut self, paths: &[String]) -> ReplaceCapture {
let mut listings = EntityListings::default();
let mut capture = self.capture_replace_listed(paths, &mut listings);
let mut seen: BTreeSet<Vec<String>> = BTreeSet::new();
let mut stack: Vec<String> = paths.to_vec();
while let Some(path) = stack.pop() {
let Some(chain) = self.part_chain(&path) else { continue };
if !seen.insert(chain.clone()) {
continue;
}
let Some(document) = self.document_in_chain(&chain) else { continue };
for feature in document["features"].as_array().into_iter().flatten() {
if super::components::is_acomp_feature_type(feature["type"].as_str().unwrap_or("")) {
if let Some(id) = feature["inputParams"]["id"].as_str() {
stack.push(format!("{path}:{id}"));
}
}
}
for entry in document["assembly"]["constraints"].as_array().into_iter().flatten() {
let inner_id = entry["inputParams"]["id"].as_str().unwrap_or_default();
let refs: Vec<(String, String)> = entry["inputParams"]["elements"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(|r| {
let occurrence = r.split(':').next().unwrap_or(r);
(format!("{path}:{occurrence}"), format!("{path}:{r}"))
})
.collect();
if inner_id.is_empty() || refs.is_empty() {
continue;
}
let enabled = entry["enabled"].as_bool().unwrap_or(true);
let status = entry["persistentData"]["status"].as_str().unwrap_or("");
if enabled && BROKEN.contains(&status) {
continue; }
let names: Vec<&String> = refs.iter().map(|(_, r)| r).collect();
let rows = parse(&brep_kernel::assembly_resolve_refs_json(
&serde_json::to_string(&names).unwrap_or_default(),
));
let rows = rows.as_array().cloned().unwrap_or_default();
if rows.len() != refs.len() || rows.iter().any(|row| row["ok"] != true) {
continue;
}
for (owner, _) in &refs {
if !capture.old_names.contains_key(owner) {
let names = listings.names(owner);
capture.old_names.insert(owner.clone(), names);
}
}
capture.affected.push(AffectedMate {
id: format!("{path}:{inner_id}"),
constraint_type: entry["type"].as_str().unwrap_or_default().to_string(),
was_enabled: enabled,
refs: refs
.into_iter()
.zip(rows.into_iter().chain(std::iter::repeat(Value::Null)))
.map(|((owner, r), row)| (owner, r, row))
.collect(),
home: Some((path.clone(), inner_id.to_string())),
});
}
}
capture
}
fn write_back_inner_mates(
&mut self,
affected: &[AffectedMate],
reattached: &[(String, String, String)],
markers: &BTreeMap<String, Value>,
) {
let touched: Vec<&AffectedMate> = affected
.iter()
.filter(|mate| mate.home.is_some())
.filter(|mate| markers.contains_key(&mate.id) || reattached.iter().any(|(id, _, _)| *id == mate.id))
.collect();
if touched.is_empty() {
return;
}
let Ok(mut document) = serde_json::from_str::<Value>(&self.history.request_json()) else { return };
let mut chains: BTreeSet<Vec<String>> = BTreeSet::new();
for mate in touched {
let (home, inner_id) = mate.home.as_ref().expect("filtered");
let Some(chain) = self.part_chain(home) else { continue };
let Some(inner) = document_in_chain_mut(&mut document, &chain) else { continue };
let prefix = format!("{home}:");
let local = |reference: &str| reference.strip_prefix(&prefix).unwrap_or(reference).to_string();
for entry in inner["assembly"]["constraints"].as_array_mut().into_iter().flatten() {
if entry["inputParams"]["id"].as_str() != Some(inner_id.as_str()) {
continue;
}
for (_, old_ref, new_ref) in reattached.iter().filter(|(id, _, _)| *id == mate.id) {
for element in entry["inputParams"]["elements"].as_array_mut().into_iter().flatten() {
if element == local(old_ref).as_str() {
*element = Value::String(local(new_ref));
}
}
}
if let Some(marker) = markers.get(&mate.id) {
let mut marker = marker.clone();
for row in marker["refs"].as_array_mut().into_iter().flatten() {
if let Some(reference) = row["ref"].as_str().map(|r| local(r)) {
row["ref"] = Value::String(reference);
}
if let Some(reference) = row["suggestion"]["ref"].as_str().map(|r| local(r)) {
row["suggestion"]["ref"] = Value::String(reference);
}
}
entry["enabled"] = Value::Bool(false);
if !entry["persistentData"].is_object() {
entry["persistentData"] = json!({});
}
entry["persistentData"][REPLACE_MARKER] = marker;
}
}
chains.insert(chain);
}
let mut prefixes: BTreeSet<Vec<String>> = BTreeSet::new();
for chain in &chains {
for depth in 1..=chain.len() {
prefixes.insert(chain[..depth].to_vec());
}
}
let mut prefixes: Vec<Vec<String>> = prefixes.into_iter().collect();
prefixes.sort_by_key(|chain| std::cmp::Reverse(chain.len()));
for chain in prefixes {
let (last, parent) = chain.split_last().expect("non-empty");
let library = if parent.is_empty() {
Some(&mut document["partsLibrary"])
} else {
document_in_chain_mut(&mut document, parent).map(|doc| &mut doc["partsLibrary"])
};
let Some(entry) = library.and_then(|library| library.get_mut(last.as_str())) else { continue };
let signature = super::assembly_ops::document_signature(&entry["document"].to_string());
entry["sourceSignature"] = Value::String(signature);
entry["snapshot"] = Value::String(String::new());
if let Some(object) = entry.as_object_mut() {
object.remove("nestedRows");
}
}
let written = serde_json::from_value::<brep_kernel::PartsLibraryMap>(document["partsLibrary"].clone())
.map_err(|error| error.to_string())
.and_then(|library| self.history.adopt_document(&document.to_string()).map(|()| library));
match written {
Ok(library) => {
brep_kernel::install_parts_library(&library);
}
Err(error) => {
self.push_notice(format!("part edit: the sub-assembly mates could not be written: {error}"));
}
}
}
fn document_in_chain(&self, chain: &[String]) -> Option<&Value> {
let mut library = self.history.parts_library();
let mut document = None;
for part in chain {
let entry = library.get(part.as_str())?;
document = Some(&entry["document"]);
library = &entry["document"]["partsLibrary"];
}
document
}
fn part_chain(&self, path: &str) -> Option<Vec<String>> {
let mut segments = path.split(':');
let top = segments.next()?;
let index = self.history.index_of(top)?;
let mut part = self.history.feature_params(index)?["partName"].as_str()?.to_string();
let mut chain = vec![part.clone()];
let mut library = self.history.parts_library();
for id in segments {
let document = &library.get(part.as_str())?["document"];
part = document["features"]
.as_array()?
.iter()
.find(|f| f["inputParams"]["id"].as_str() == Some(id))?["inputParams"]["partName"]
.as_str()?
.to_string();
chain.push(part.clone());
library = &document["partsLibrary"];
}
Some(chain)
}
pub fn commit_replace(
&mut self,
capture: ReplaceCapture,
mut document: Value,
from: BTreeMap<String, String>,
to: String,
lineage: BTreeMap<String, &'static str>,
notes: Vec<String>,
) -> Result<(), String> {
let untrusted: BTreeSet<&String> =
capture.components.iter().filter(|id| !lineage.contains_key(*id)).collect();
if let Some(constraints) = document["assembly"]["constraints"].as_array_mut() {
for entry in constraints.iter_mut() {
let id = entry["inputParams"]["id"].as_str().unwrap_or_default();
let Some(mate) = capture.affected.iter().find(|m| m.id == id) else { continue };
if mate.refs.iter().any(|(owner, _, _)| untrusted.contains(owner)) {
entry["enabled"] = Value::Bool(false);
}
}
}
self.edit_document(document)?;
self.replace_pending = Some(PendingReplace {
generation: self.run_generation,
components: capture.components,
from,
to,
lineage,
affected: capture.affected,
old_names: capture.old_names,
notes,
origin: "replace",
signatures: BTreeMap::new(),
});
self.last_replace_report = None;
if !self.run_pending() {
self.finish_component_replace();
}
Ok(())
}
pub fn commit_part_edit(&mut self, capture: ReplaceCapture, document: Value, parts: &[String]) -> Result<(), String> {
self.finish_component_replace();
if self.replace_pending.is_some() {
return Err("a component replace is still landing; try again in a moment".into());
}
let signatures: BTreeMap<String, String> = parts
.iter()
.map(|part| {
let signature = document["partsLibrary"][part]["sourceSignature"].as_str().unwrap_or_default();
(part.clone(), signature.to_string())
})
.collect();
let from: BTreeMap<String, String> = capture
.components
.iter()
.filter_map(|path| Some((path.clone(), self.part_at_path(path)?)))
.collect();
self.edit_document(document)?;
if capture.affected.is_empty() {
return Ok(());
}
self.replace_pending = Some(PendingReplace {
generation: self.run_generation,
lineage: capture.components.iter().map(|path| (path.clone(), "edit")).collect(),
components: capture.components,
to: parts.join(", "),
from,
affected: capture.affected,
old_names: capture.old_names,
notes: Vec::new(),
origin: "edit",
signatures,
});
self.last_replace_report = None;
if !self.run_pending() {
self.finish_component_replace();
}
Ok(())
}
pub fn components_of_parts(&self, parts: &[String]) -> Vec<String> {
self.history
.features()
.iter()
.filter(|feature| super::components::is_acomp_feature_type(feature["type"].as_str().unwrap_or("")))
.filter(|feature| {
feature["inputParams"]["partName"].as_str().is_some_and(|part| parts.iter().any(|p| p == part))
})
.filter_map(|feature| feature["inputParams"]["id"].as_str().map(str::to_string))
.collect()
}
pub(crate) fn finish_component_replace(&mut self) {
let Some(pending) = self.replace_pending.as_ref() else { return };
if self.run_pending() || self.applied_generation < pending.generation {
return;
}
let pending = self.replace_pending.take().expect("checked above");
let landed = if pending.origin == "edit" {
pending.signatures.iter().all(|(part, signature)| {
self.history.parts_library()[part.as_str()]["sourceSignature"].as_str() == Some(signature.as_str())
})
} else {
pending.components.iter().all(|path| self.part_at_path(path).as_deref() == Some(pending.to.as_str()))
};
if !landed {
self.last_replace_report = None;
return;
}
self.ensure_assembly_session();
let statuses = parse(&brep_kernel::assembly_statuses_json());
let nested = parse(&brep_kernel::assembly_dof_json())["nestedConstraints"].clone();
let status_of = |id: &str| -> String {
statuses
.as_array()
.into_iter()
.flatten()
.chain(nested.as_array().into_iter().flatten())
.find(|row| row["id"] == id)
.and_then(|row| row["status"].as_str())
.unwrap_or("")
.to_string()
};
let vertex_refs: Vec<&String> = pending
.affected
.iter()
.flat_map(|mate| mate.refs.iter().map(|(_, r, _)| r))
.filter(|r| r.contains('@'))
.collect();
let post_vertex: BTreeMap<String, Value> = if vertex_refs.is_empty() {
BTreeMap::new()
} else {
parse(&brep_kernel::assembly_resolve_refs_json(&serde_json::to_string(&vertex_refs).unwrap_or_default()))
.as_array()
.cloned()
.unwrap_or_default()
.into_iter()
.filter_map(|row| Some((row["ref"].as_str()?.to_string(), row)))
.collect()
};
let mut listings = EntityListings::default();
let owners: BTreeSet<&String> = pending
.affected
.iter()
.flat_map(|mate| mate.refs.iter().map(move |(owner, _, _)| (mate, owner)))
.filter(|(mate, owner)| mate.home.is_some() || pending.components.contains(owner))
.map(|(_, owner)| owner)
.collect();
for owner in &owners {
listings.index(owner);
}
let entities: BTreeMap<&String, Vec<&Value>> = owners.iter().map(|owner| (*owner, listings.under(owner))).collect();
let mut kept = Vec::new();
let mut unresolved = Vec::new();
let mut notes = pending.notes.clone();
let mut markers: BTreeMap<String, Value> = BTreeMap::new();
let mut reattached: Vec<(String, String, String)> = Vec::new();
for mate in &pending.affected {
let mut ref_rows = Vec::new();
for (owner, reference, pre) in &mate.refs {
let trusted = pending.lineage.contains_key(owner)
|| (mate.home.is_some() && owner_among(&pending.components, owner).is_some_and(|top| pending.lineage.contains_key(top)));
let outcome = if pre["ok"] != true {
RefOutcome::Unresolved { reason: "missing", suggestion: None }
} else if reference.contains('@') {
classify_vertex_ref(reference, pre, post_vertex.get(reference.as_str()), trusted)
} else {
classify_ref(
owner,
reference,
pre,
entities.get(owner).map(Vec::as_slice).unwrap_or(&[]),
pending.old_names.get(owner).unwrap_or(&BTreeSet::new()),
trusted,
)
};
match outcome {
RefOutcome::Kept { split } => {
if split {
notes.push(format!(
"{}: '{}' was split by the new part; the mate keeps the piece that kept the name (same carrier)",
mate.id, reference
));
}
}
RefOutcome::Unresolved { reason, suggestion } => {
let only = (pending.origin == "edit" && !reference.contains('@'))
.then(|| unique_carrier(pre, entities.get(owner).map(Vec::as_slice).unwrap_or(&[])))
.flatten();
match only {
Some(new_ref) => reattached.push((mate.id.clone(), reference.clone(), new_ref)),
None => ref_rows.push(json!({
"ref": reference,
"reason": reason,
"suggestion": suggestion.as_ref().map(Suggestion::to_json),
})),
}
}
}
}
let status = status_of(&mate.id);
let mate_reattached = reattached.iter().any(|(id, _, _)| *id == mate.id);
if ref_rows.is_empty() && !mate_reattached && mate.was_enabled && BROKEN.contains(&status.as_str()) {
for (_, reference, _) in &mate.refs {
ref_rows.push(json!({ "ref": reference, "reason": "no-longer-maps", "suggestion": null }));
}
}
if ref_rows.is_empty() {
kept.push(json!({ "id": mate.id, "type": mate.constraint_type }));
continue;
}
let owner = &mate.refs[0].0;
let marker = marker_json(
pending.origin,
&pending.from.get(owner).cloned().or_else(|| self.part_at_path(owner)).unwrap_or_default(),
&pending.to,
mate.was_enabled,
ref_rows,
);
unresolved.push(json!({
"id": mate.id,
"type": mate.constraint_type,
"refs": marker["refs"],
}));
markers.insert(mate.id.clone(), marker);
}
if !markers.is_empty() || !reattached.is_empty() {
let mut block = self.history.assembly_block().cloned().unwrap_or(Value::Null);
let mut disabled_now = false;
for entry in block["constraints"].as_array_mut().into_iter().flatten() {
let id = entry["inputParams"]["id"].as_str().unwrap_or_default().to_string();
for (mate, old_ref, new_ref) in &reattached {
if *mate != id {
continue;
}
for element in entry["inputParams"]["elements"].as_array_mut().into_iter().flatten() {
if element == old_ref.as_str() {
*element = Value::String(new_ref.clone());
}
}
}
let Some(marker) = markers.get(&id) else { continue };
if entry["enabled"] != false {
entry["enabled"] = Value::Bool(false);
disabled_now = true;
}
if !entry["persistentData"].is_object() {
entry["persistentData"] = json!({});
}
entry["persistentData"][REPLACE_MARKER] = marker.clone();
}
self.history.set_assembly_block(block, false);
self.write_back_inner_mates(&pending.affected, &reattached, &markers);
let _ = disabled_now;
self.rerun_history();
}
if pending.origin == "edit" && (!reattached.is_empty() || !unresolved.is_empty()) {
let mut lines = Vec::new();
if !reattached.is_empty() {
let moves: Vec<String> = reattached.iter().map(|(id, from, to)| format!("{id} {from} → {to}")).collect();
lines.push(format!("re-attached {}", moves.join(", ")));
}
if !unresolved.is_empty() {
let ids: Vec<&str> = unresolved.iter().filter_map(|row| row["id"].as_str()).collect();
lines.push(format!(
"held for Repair (Constraints panel): {} — the face or edge it used is gone and no single one replaces it",
ids.join(", ")
));
}
let severity = if unresolved.is_empty() { NoticeSeverity::Info } else { NoticeSeverity::Warning };
self.push_notice_as(severity, format!("Mates after editing {}: {}.", pending.to, lines.join("; ")));
}
let report = json!({
"ok": true,
"origin": pending.origin,
"reattached": reattached.iter().map(|(id, from, to)| json!({ "id": id, "from": from, "to": to })).collect::<Vec<_>>(),
"components": pending.components,
"from": pending.from,
"to": pending.to,
"lineage": pending.components.iter().map(|id| {
(id.clone(), Value::String(pending.lineage.get(id).copied().unwrap_or("none").to_string()))
}).collect::<serde_json::Map<String, Value>>(),
"kept": kept,
"unresolved": unresolved,
"notes": notes,
});
self.last_replace_report = Some(report);
}
pub fn occurrence_entities(&mut self, path: &str) -> Vec<Value> {
self.ensure_assembly_session();
entities_of(path)
}
pub fn resolve_refs(&mut self, refs: &[String]) -> Vec<Value> {
self.ensure_assembly_session();
parse(&brep_kernel::assembly_resolve_refs_json(&serde_json::to_string(refs).unwrap_or_default()))
.as_array()
.cloned()
.unwrap_or_default()
}
pub fn replace_report(&mut self) -> Value {
self.finish_component_replace();
match &self.last_replace_report {
Some(report) if self.report_in_effect(report) => report.clone(),
_ => Value::Null,
}
}
fn report_in_effect(&self, report: &Value) -> bool {
let to = report["to"].as_str().unwrap_or_default();
report["components"]
.as_array()
.is_some_and(|paths| paths.iter().filter_map(Value::as_str).all(|path| self.part_at_path(path).as_deref() == Some(to)))
}
pub fn part_at_path(&self, path: &str) -> Option<String> {
let mut segments = path.split(':');
let top = segments.next()?;
let index = self.history.index_of(top)?;
let mut part = self.history.feature_params(index)?["partName"].as_str()?.to_string();
let mut library = self.history.parts_library();
for id in segments {
let document = &library[&part]["document"];
part = document["features"]
.as_array()?
.iter()
.find(|f| f["inputParams"]["id"].as_str() == Some(id))?["inputParams"]["partName"]
.as_str()?
.to_string();
library = &document["partsLibrary"];
}
Some(part)
}
pub fn replace_unresolved(&self) -> Value {
let rows: Vec<Value> = self
.history
.assembly_block()
.and_then(|block| block["constraints"].as_array().cloned())
.unwrap_or_default()
.into_iter()
.filter(|entry| entry["persistentData"][REPLACE_MARKER].is_object())
.map(|entry| {
let marker = &entry["persistentData"][REPLACE_MARKER];
json!({
"id": entry["inputParams"]["id"],
"type": entry["type"],
"origin": marker["origin"].as_str().unwrap_or("replace"),
"from": marker["from"],
"to": marker["to"],
"refs": marker["refs"],
})
})
.collect();
Value::Array(rows)
}
pub fn begin_ref_select_for_replace_repair(&mut self, mate: &str, old_ref: &str) {
let filter = vec!["FACE".to_string(), "EDGE".to_string()];
let restore_index = self.history.rollback();
self.selection_filter = SelectionFilter::from_ref_filter(&filter);
self.replace_repair_pick = None;
self.ref_select = Some(RefSelectState {
feature_id: mate.to_string(),
path: vec![old_ref.to_string()],
label: format!("Replacement for {old_ref}"),
filter,
multiple: false,
names: Vec::new(),
restore_index,
target: RefSelectTarget::ReplaceRepair,
picks: 0,
claimed: false,
finish_requested: false,
});
self.sync_ref_select_emphasis();
}
pub fn take_replace_repair_pick(&mut self) -> Option<(String, String, String)> {
self.replace_repair_pick.take()
}
pub fn replace_repair(&mut self, repairs: &[(String, String, String)]) -> Result<(), String> {
let mut block = self
.history
.assembly_block()
.cloned()
.ok_or("replace repair: the document has no assembly")?;
for (id, old_ref, new_ref) in repairs {
let entry = block["constraints"]
.as_array_mut()
.into_iter()
.flatten()
.find(|entry| entry["inputParams"]["id"] == id.as_str())
.ok_or_else(|| format!("replace repair: no constraint '{id}'"))?;
let marker = &mut entry["persistentData"][REPLACE_MARKER];
let refs = marker["refs"]
.as_array_mut()
.ok_or_else(|| format!("replace repair: '{id}' has nothing unresolved"))?;
let before = refs.len();
refs.retain(|row| row["ref"] != old_ref.as_str());
if refs.len() == before {
return Err(format!("replace repair: '{id}' has no unresolved ref '{old_ref}'"));
}
let done = refs.is_empty();
let was_enabled = marker["wasEnabled"].as_bool().unwrap_or(true);
for element in entry["inputParams"]["elements"].as_array_mut().into_iter().flatten() {
if element == old_ref.as_str() {
*element = Value::String(new_ref.clone());
}
}
if done {
if let Some(data) = entry["persistentData"].as_object_mut() {
data.remove(REPLACE_MARKER);
}
entry["enabled"] = Value::Bool(was_enabled);
}
}
self.history.set_assembly_block(block, true);
self.rerun_history();
Ok(())
}
pub fn replace_accept_suggestions(&mut self, ids: &[String]) -> Result<usize, String> {
let unresolved = self.replace_unresolved();
let mut repairs = Vec::new();
for mate in unresolved.as_array().into_iter().flatten() {
let id = mate["id"].as_str().unwrap_or_default().to_string();
if !ids.is_empty() && !ids.contains(&id) {
continue;
}
for row in mate["refs"].as_array().into_iter().flatten() {
if row["suggestion"]["preTicked"] == true {
repairs.push((
id.clone(),
row["ref"].as_str().unwrap_or_default().to_string(),
row["suggestion"]["ref"].as_str().unwrap_or_default().to_string(),
));
}
}
}
if repairs.is_empty() {
return Ok(0);
}
self.replace_repair(&repairs)?;
Ok(repairs.len())
}
}