1use brep_render::engine_state::{document_signature, ComponentInsert, EngineState, RefSelectTarget};
8use eframe::egui;
9
10use crate::automation::hit_keys::HitKeyDoc;
11use crate::store::{model_display_name, ModelStore};
12
13fn stored_display_name(store: &dyn ModelStore, name: &str) -> String {
17 crate::plm::bom::revision_of_document(name)
18 .and_then(|(part, revision)| store.plm_revision(&crate::plm::identity::document_key(&part, &revision)))
19 .map(|row| row.part_name)
20 .filter(|part_name| !part_name.is_empty())
21 .unwrap_or_else(|| model_display_name(name))
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum ReplaceSource {
27 Stored(String),
30 Library(String),
32}
33
34pub fn instances_of(engine: &EngineState, component_id: &str) -> Vec<String> {
38 let Some(part) = engine.component_info(component_id).map(|info| info.part_name) else {
39 return Vec::new();
40 };
41 (0..engine.history_len())
42 .filter_map(|index| engine.feature_id_at(index))
43 .filter(|id| engine.component_info(id).is_some_and(|info| info.part_name == part))
44 .collect()
45}
46
47pub fn finish_work_part_for_replace(docs: &mut crate::document::Documents) -> Result<Option<String>, String> {
54 let Some(path) = docs.work_part_path().map(str::to_string) else { return Ok(None) };
55 let hosted_here = docs.work_part_assembly().is_some_and(|host| std::ptr::eq(host, docs.tab_engine()));
56 if !hosted_here {
57 return Ok(None);
58 }
59 docs.return_to_assembly()?;
60 let line = format!("Replace: the Work part {path} was finished first (its edits are kept); the replace edits the assembly.");
61 docs.engine_mut().push_notice_as(brep_render::engine_state::NoticeSeverity::Info, line.clone());
62 Ok(Some(line))
63}
64
65pub fn pattern_line(engine: &EngineState, seed: &str, instance: Option<&(String, String, i64)>) -> Option<String> {
70 let mut follow: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
71 for id in engine.pattern_instance_ids() {
72 if let Some((pattern, s, _)) = brep_render::brep_kernel::pattern_component_instance(&id) {
73 if s == seed {
74 *follow.entry(pattern).or_default() += 1;
75 }
76 }
77 }
78 if let Some((id, pattern, n)) = instance {
79 let places = follow.get(pattern).copied().unwrap_or(0) + 1;
80 return Some(format!(
81 "{id} is instance {n} of {pattern} and follows its seed {seed}: replacing {seed} changes all {places} places."
82 ));
83 }
84 (!follow.is_empty()).then(|| {
85 let parts: Vec<String> = follow.iter().map(|(pattern, count)| format!("{count} in {pattern}")).collect();
86 format!("Its pattern instances follow it ({}).", parts.join(", "))
87 })
88}
89
90fn lineage_canonicalizer(store: &dyn ModelStore) -> impl Fn(&str) -> String {
95 let canonicalize = store.canonicalizer();
96 let plm = store.plm_session().is_some();
97 move |key: &str| lineage_key(key, plm, &*canonicalize)
98}
99
100fn lineage_key(key: &str, plm: bool, canonicalize: &dyn Fn(&str) -> String) -> String {
102 match plm.then(|| crate::panels::plm_host::revision_of(key)).flatten() {
103 Some((part, _revision)) => format!("plm-part:{part}"),
104 None => canonicalize(key),
105 }
106}
107
108pub fn replace(
112 engine: &mut EngineState,
113 store: &dyn ModelStore,
114 component_ids: &[String],
115 source: &ReplaceSource,
116) -> Result<String, String> {
117 let canonicalize = lineage_canonicalizer(store);
118 match source {
119 ReplaceSource::Library(part_name) => engine.replace_components(
120 component_ids,
121 ComponentInsert::Existing { part_name },
122 &canonicalize,
123 ),
124 ReplaceSource::Stored(name) => {
125 let contents = match crate::store::read_now(store, name) {
126 crate::store::ReadNow::Ready(contents) => contents,
127 crate::store::ReadNow::Loading => return Err(format!("'{name}' is still loading; try again")),
128 crate::store::ReadNow::Absent => return Err(format!("'{name}' not found")),
129 };
130 let display = stored_display_name(store, name);
131 engine.replace_components(
132 component_ids,
133 ComponentInsert::New {
134 name: &display,
135 source_key: name,
136 source_signature: &document_signature(&contents),
137 document_json: &contents,
138 },
139 &canonicalize,
140 )
141 }
142 }
143}
144
145fn acomp_mut<'a>(document: &'a mut serde_json::Value, id: &str) -> Option<&'a mut serde_json::Value> {
150 document["features"].as_array_mut()?.iter_mut().find(|feature| {
151 matches!(feature["type"].as_str(), Some("ACOMP" | "ASSEMBLY COMPONENT")) && feature["inputParams"]["id"].as_str() == Some(id)
152 })
153}
154
155pub fn replace_nested(
165 engine: &mut EngineState,
166 store: &dyn ModelStore,
167 path: &str,
168 source: &ReplaceSource,
169 all_instances: bool,
170) -> Result<String, String> {
171 use serde_json::Value;
172 let segments: Vec<&str> = path.split(':').collect();
173 let [top, inner @ ..] = segments.as_slice() else { return Err("replace: empty path".into()) };
174 let Some((target, owners)) = inner.split_last() else {
175 return Err(format!("replace: '{path}' is not inside a sub-assembly"));
176 };
177 let library = engine.history.parts_library().clone();
178 let top_part = engine
179 .component_info(top)
180 .ok_or_else(|| format!("replace: '{top}' is not an assembly component"))?
181 .part_name;
182 let mut names = vec![top_part];
184 let mut owner = library[&names[0]].clone();
185 for id in owners {
186 let part = owner["document"]["features"]
187 .as_array()
188 .into_iter()
189 .flatten()
190 .find(|f| f["inputParams"]["id"].as_str() == Some(*id))
191 .and_then(|f| f["inputParams"]["partName"].as_str())
192 .ok_or_else(|| format!("replace: '{id}' is not placed in '{}'", names.last().unwrap()))?
193 .to_string();
194 owner = owner["document"]["partsLibrary"][&part].clone();
195 names.push(part);
196 }
197 let owner_source = owner["sourceKey"].as_str().filter(|k| !k.is_empty()).map(str::to_string);
198 let mut definition = owner["document"].clone();
199 let old_part = definition["features"]
200 .as_array()
201 .into_iter()
202 .flatten()
203 .find(|f| f["inputParams"]["id"].as_str() == Some(*target))
204 .and_then(|f| f["inputParams"]["partName"].as_str())
205 .ok_or_else(|| format!("replace: '{target}' is not placed in '{}'", names.last().unwrap()))?
206 .to_string();
207
208 let (display, entry) = match source {
210 ReplaceSource::Library(part) => (part.clone(), library[part].clone()),
211 ReplaceSource::Stored(name) => {
212 let contents = match crate::store::read_now(store, name) {
213 crate::store::ReadNow::Ready(contents) => contents,
214 crate::store::ReadNow::Loading => return Err(format!("'{name}' is still loading; try again")),
215 crate::store::ReadNow::Absent => return Err(format!("'{name}' not found")),
216 };
217 let document: Value = serde_json::from_str(&contents).map_err(|e| format!("'{name}': {e}"))?;
218 let entry = serde_json::json!({
219 "sourceKey": name,
220 "sourceSignature": document_signature(&contents),
221 "document": document,
222 "snapshot": "",
223 });
224 (stored_display_name(store, name), entry)
225 }
226 };
227 if !entry["document"].is_object() {
228 return Err("replace: the replacement has no part document".into());
229 }
230 let mut to = display.clone();
231 let mut n = 2;
232 while definition["partsLibrary"][&to].is_object() && definition["partsLibrary"][&to]["sourceKey"] != entry["sourceKey"] {
233 to = format!("{display} ({n})");
234 n += 1;
235 }
236 if to == old_part {
237 return Err(format!("replace: '{path}' already shows '{to}'"));
238 }
239 let canonicalize = lineage_canonicalizer(store);
240 let lineage = brep_render::engine_state::replace_component::entries_lineage(
241 &definition["partsLibrary"][&old_part],
242 &entry,
243 &canonicalize,
244 );
245
246 let prefix: String = segments[..segments.len() - 1].join(":");
248 let targets: Vec<String> = if all_instances {
249 definition["features"]
250 .as_array()
251 .into_iter()
252 .flatten()
253 .filter(|f| f["inputParams"]["partName"].as_str() == Some(old_part.as_str()))
254 .filter_map(|f| f["inputParams"]["id"].as_str().map(str::to_string))
255 .collect()
256 } else {
257 vec![target.to_string()]
258 };
259 if !definition["partsLibrary"].is_object() {
260 definition["partsLibrary"] = serde_json::json!({});
261 }
262 definition["partsLibrary"][&to] = entry;
263 for id in &targets {
264 if let Some(feature) = acomp_mut(&mut definition, id) {
265 feature["inputParams"]["partName"] = Value::String(to.clone());
266 }
267 }
268 let mut held = Vec::new();
272 if lineage.is_none() {
273 for constraint in definition["assembly"]["constraints"].as_array_mut().into_iter().flatten() {
274 let refs: Vec<String> = constraint["inputParams"]["elements"]
275 .as_array()
276 .into_iter()
277 .flatten()
278 .filter_map(Value::as_str)
279 .filter(|r| targets.iter().any(|id| *r == id.as_str() || r.starts_with(&format!("{id}:"))))
280 .map(str::to_string)
281 .collect();
282 if refs.is_empty() {
283 continue;
284 }
285 let was_enabled = constraint["enabled"].as_bool().unwrap_or(true);
286 constraint["enabled"] = Value::Bool(false);
287 if !constraint["persistentData"].is_object() {
288 constraint["persistentData"] = serde_json::json!({});
289 }
290 let namespaced: Vec<String> = refs.iter().map(|r| format!("{prefix}:{r}")).collect();
294 let pre = engine.resolve_refs(&namespaced);
295 constraint["persistentData"][brep_render::engine_state::replace_component::REPLACE_MARKER] = serde_json::json!({
296 "from": old_part,
297 "to": to,
298 "wasEnabled": was_enabled,
299 "refs": refs.iter().zip(pre.iter().chain(std::iter::repeat(&Value::Null))).map(|(r, pre)| serde_json::json!({
300 "ref": r,
301 "reason": "unconfirmed",
302 "suggestion": { "ref": r, "basis": "same-name", "preTicked": false },
303 "pre": pre,
304 })).collect::<Vec<_>>(),
305 });
306 held.push(constraint["inputParams"]["id"].as_str().unwrap_or("").to_string());
307 }
308 }
309
310 let paths: Vec<String> = targets.iter().map(|id| format!("{prefix}:{id}")).collect();
313 let capture = engine.capture_replace(&paths);
314 let mut assembly: Value = serde_json::from_str(&engine.history_request_json()).map_err(|e| e.to_string())?;
315 let mut notes = Vec::new();
316 if !crate::document::work_part::refresh_parts(&mut assembly, &names, owner_source.as_deref(), &definition, &mut notes)? {
317 return Err("replace: the assembly no longer holds that sub-assembly".into());
318 }
319 if !held.is_empty() {
320 notes.push(format!(
321 "{} mate(s) inside '{}' are held until confirmed there: {} (make it the Work part, then Review)",
322 held.len(),
323 names.last().unwrap(),
324 held.join(", ")
325 ));
326 }
327 let inner_from: std::collections::BTreeMap<String, String> =
331 targets.iter().map(|id| (id.clone(), old_part.clone())).collect();
332 let mut instances: Vec<String> = engine
333 .scene
334 .solids()
335 .iter()
336 .filter_map(|solid| solid.name.strip_prefix(&format!("{prefix}:")))
337 .filter_map(|rest| rest.split(':').next())
338 .filter(|id| brep_render::brep_kernel::pattern_component_instance(id).is_some())
339 .map(str::to_string)
340 .collect();
341 instances.sort();
342 instances.dedup();
343 for note in brep_render::engine_state::replace_component::follow_pattern_seeds(&definition, &inner_from, &to, &instances) {
344 notes.push(format!("In '{}', {note}", names.last().unwrap()));
345 }
346 let from = paths.iter().map(|p| (p.clone(), old_part.clone())).collect();
347 let lineage = match lineage {
348 Some(word) => paths.iter().map(|p| (p.clone(), word)).collect(),
349 None => Default::default(),
350 };
351 engine.commit_replace(capture, assembly, from, to.clone(), lineage, notes)?;
352 Ok(to)
353}
354
355pub fn held_mates(engine: &mut EngineState) -> Vec<serde_json::Value> {
366 use serde_json::Value;
367 let mut rows = engine.replace_unresolved().as_array().cloned().unwrap_or_default();
368 let library = engine.history.parts_library().clone();
369 let mut nested = Vec::new();
370 for index in 0..engine.history_len() {
371 let Some(id) = engine.feature_id_at(index) else { continue };
372 let Some(info) = engine.component_info(&id) else { continue };
373 collect_held(&library[&info.part_name]["document"], &id, &info.part_name, 0, &mut nested);
374 }
375 let mut listed: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();
380 nested.retain(|(_, definition, entry)| {
381 listed.insert((definition.clone(), entry["inputParams"]["id"].as_str().unwrap_or("").to_string()))
382 });
383 for (prefix, _, entry) in nested {
384 let marker = &entry["persistentData"][brep_render::engine_state::replace_component::REPLACE_MARKER];
385 let refs: Vec<Value> = marker["refs"]
386 .as_array()
387 .into_iter()
388 .flatten()
389 .map(|row| {
390 let local = row["ref"].as_str().unwrap_or("");
391 let reference = format!("{prefix}:{local}");
392 let owner = format!("{prefix}:{}", local.split(':').next().unwrap_or(local));
393 let pre = &row["pre"];
394 let suggestion = if pre["ok"] == true {
395 let candidates = engine.occurrence_entities(&owner);
396 match brep_render::engine_state::replace_component::classify_ref(
397 &owner,
398 &reference,
399 pre,
400 &candidates,
401 &Default::default(),
402 false,
403 ) {
404 brep_render::engine_state::replace_component::RefOutcome::Unresolved { suggestion: Some(s), .. } => {
405 serde_json::json!({ "ref": s.reference, "basis": s.basis, "preTicked": s.pre_ticked })
406 }
407 _ => Value::Null,
408 }
409 } else {
410 Value::Null
411 };
412 serde_json::json!({ "ref": reference, "reason": row["reason"], "suggestion": suggestion })
413 })
414 .collect();
415 rows.push(serde_json::json!({
416 "id": format!("{prefix}:{}", entry["inputParams"]["id"].as_str().unwrap_or("")),
417 "type": entry["type"],
418 "origin": marker["origin"].as_str().unwrap_or("replace"),
419 "from": marker["from"],
420 "to": marker["to"],
421 "refs": refs,
422 }));
423 }
424 rows
425}
426
427pub fn held_summary(cache: &mut Option<(u64, usize, &'static str)>, engine: &mut EngineState) -> (usize, &'static str) {
434 let revision = engine.history.revision();
435 if let Some((at, count, after)) = *cache {
436 if at == revision {
437 return (count, after);
438 }
439 }
440 let rows = held_mates(engine);
441 let after = if rows.iter().all(|row| row["origin"] == "edit") {
442 "a part edit"
443 } else if rows.iter().all(|row| row["origin"] != "edit") {
444 "Replace"
445 } else {
446 "Replace or a part edit"
447 };
448 *cache = Some((revision, rows.len(), after));
449 (rows.len(), after)
450}
451
452fn collect_held(
456 document: &serde_json::Value,
457 prefix: &str,
458 definition: &str,
459 depth: usize,
460 out: &mut Vec<(String, String, serde_json::Value)>,
461) {
462 if depth > 32 || !document.is_object() {
463 return;
464 }
465 for entry in document["assembly"]["constraints"].as_array().into_iter().flatten() {
466 if entry["persistentData"][brep_render::engine_state::replace_component::REPLACE_MARKER].is_object() {
467 out.push((prefix.to_string(), definition.to_string(), entry.clone()));
468 }
469 }
470 for feature in document["features"].as_array().into_iter().flatten() {
471 if !matches!(feature["type"].as_str(), Some("ACOMP" | "ASSEMBLY COMPONENT")) {
472 continue;
473 }
474 let (Some(id), Some(part)) = (feature["inputParams"]["id"].as_str(), feature["inputParams"]["partName"].as_str()) else {
475 continue;
476 };
477 collect_held(&document["partsLibrary"][part]["document"], &format!("{prefix}:{id}"), &format!("{definition}/{part}"), depth + 1, out);
478 }
479}
480
481fn definition_at(engine: &EngineState, path: &str) -> Result<(Vec<String>, Option<String>, serde_json::Value), String> {
485 let segments: Vec<&str> = path.split(':').collect();
486 let library = engine.history.parts_library().clone();
487 let top = engine
488 .component_info(segments[0])
489 .ok_or_else(|| format!("'{}' is not an assembly component", segments[0]))?
490 .part_name;
491 let mut names = vec![top];
492 let mut entry = library[&names[0]].clone();
493 for id in &segments[1..] {
494 let part = entry["document"]["features"]
495 .as_array()
496 .into_iter()
497 .flatten()
498 .find(|f| f["inputParams"]["id"].as_str() == Some(*id))
499 .and_then(|f| f["inputParams"]["partName"].as_str())
500 .ok_or_else(|| format!("'{id}' is not placed in '{}'", names.last().unwrap()))?
501 .to_string();
502 entry = entry["document"]["partsLibrary"][&part].clone();
503 names.push(part);
504 }
505 let source = entry["sourceKey"].as_str().filter(|k| !k.is_empty()).map(str::to_string);
506 Ok((names, source, entry["document"].clone()))
507}
508
509fn nested_id(id: &str) -> Option<(&str, &str)> {
512 id.rsplit_once(':')
513}
514
515fn edit_definitions(
518 engine: &mut EngineState,
519 top: &[(String, String, String)],
520 edits: std::collections::BTreeMap<String, Vec<Box<dyn FnOnce(&mut serde_json::Value) -> Result<(), String>>>>,
521) -> Result<(), String> {
522 let mut assembly: serde_json::Value = serde_json::from_str(&engine.history_request_json()).map_err(|e| e.to_string())?;
523 if !top.is_empty() {
525 repair_block(&mut assembly["assembly"], top, "")?;
526 }
527 let mut notes = Vec::new();
528 for (path, changes) in edits {
529 let (names, source, mut definition) = definition_at(engine, &path)?;
530 for change in changes {
531 change(&mut definition)?;
532 }
533 if !crate::document::work_part::refresh_parts(&mut assembly, &names, source.as_deref(), &definition, &mut notes)? {
534 return Err(format!("the assembly no longer holds the sub-assembly at '{path}'"));
535 }
536 }
537 engine.edit_document_json(&assembly.to_string())?;
538 for note in notes {
539 engine.push_notice(format!("Replace: {note}"));
540 }
541 Ok(())
542}
543
544pub fn repair(engine: &mut EngineState, repairs: &[(String, String, String)]) -> Result<(), String> {
549 let (top, nested): (Vec<_>, Vec<_>) = repairs.iter().cloned().partition(|(id, _, _)| nested_id(id).is_none());
550 if nested.is_empty() {
551 return engine.replace_repair(&top);
552 }
553 let mut edits: std::collections::BTreeMap<String, Vec<Box<dyn FnOnce(&mut serde_json::Value) -> Result<(), String>>>> =
554 Default::default();
555 for (id, old_ref, new_ref) in nested {
556 let (path, _) = nested_id(&id).unwrap();
557 let path = path.to_string();
558 let prefix = format!("{path}:");
559 let local = |r: &str| r.strip_prefix(&prefix).map(str::to_string);
560 let old_local = local(&old_ref).ok_or_else(|| format!("'{old_ref}' is not inside '{path}'"))?;
561 let new_local = local(&new_ref).ok_or_else(|| format!("pick a face inside '{path}' for {id}"))?;
562 let constraint = nested_id(&id).unwrap().1.to_string();
563 edits.entry(path).or_default().push(Box::new(move |definition: &mut serde_json::Value| {
564 repair_block(&mut definition["assembly"], &[(constraint, old_local, new_local)], "")
565 }));
566 }
567 edit_definitions(engine, &top, edits)
568}
569
570fn repair_block(block: &mut serde_json::Value, repairs: &[(String, String, String)], _prefix: &str) -> Result<(), String> {
572 use brep_render::engine_state::replace_component::REPLACE_MARKER;
573 for (id, old_ref, new_ref) in repairs {
574 let entry = block["constraints"]
575 .as_array_mut()
576 .into_iter()
577 .flatten()
578 .find(|entry| entry["inputParams"]["id"] == id.as_str())
579 .ok_or_else(|| format!("no constraint '{id}'"))?;
580 let marker = &mut entry["persistentData"][REPLACE_MARKER];
581 let refs = marker["refs"].as_array_mut().ok_or_else(|| format!("'{id}' has nothing held"))?;
582 let before = refs.len();
583 refs.retain(|row| row["ref"] != old_ref.as_str());
584 if refs.len() == before {
585 return Err(format!("'{id}' holds no ref '{old_ref}'"));
586 }
587 let done = refs.is_empty();
588 let was_enabled = marker["wasEnabled"].as_bool().unwrap_or(true);
589 for element in entry["inputParams"]["elements"].as_array_mut().into_iter().flatten() {
590 if element == old_ref.as_str() {
591 *element = serde_json::Value::String(new_ref.clone());
592 }
593 }
594 if done {
595 if let Some(data) = entry["persistentData"].as_object_mut() {
596 data.remove(REPLACE_MARKER);
597 }
598 entry["enabled"] = serde_json::Value::Bool(was_enabled);
599 }
600 }
601 Ok(())
602}
603
604pub fn accept_suggestions(engine: &mut EngineState, ids: &[String]) -> Result<usize, String> {
607 let mut repairs = Vec::new();
608 for mate in held_mates(engine) {
609 let id = mate["id"].as_str().unwrap_or_default().to_string();
610 if !ids.is_empty() && !ids.contains(&id) {
611 continue;
612 }
613 for row in mate["refs"].as_array().into_iter().flatten() {
614 if row["suggestion"]["preTicked"] == true {
615 repairs.push((
616 id.clone(),
617 row["ref"].as_str().unwrap_or_default().to_string(),
618 row["suggestion"]["ref"].as_str().unwrap_or_default().to_string(),
619 ));
620 }
621 }
622 }
623 if repairs.is_empty() {
624 return Ok(0);
625 }
626 repair(engine, &repairs)?;
627 Ok(repairs.len())
628}
629
630pub fn delete_held(engine: &mut EngineState, id: &str) -> Result<(), String> {
632 let Some((path, constraint)) = nested_id(id) else {
633 return engine.assembly_remove_constraint(id);
634 };
635 let constraint = constraint.to_string();
636 let mut edits: std::collections::BTreeMap<String, Vec<Box<dyn FnOnce(&mut serde_json::Value) -> Result<(), String>>>> =
637 Default::default();
638 let id = id.to_string();
639 edits.entry(path.to_string()).or_default().push(Box::new(move |definition: &mut serde_json::Value| {
640 let list = definition["assembly"]["constraints"].as_array_mut().ok_or("the sub-assembly has no constraints")?;
641 let before = list.len();
642 list.retain(|entry| entry["inputParams"]["id"] != constraint.as_str());
643 if list.len() == before {
644 return Err(format!("no constraint '{id}'"));
645 }
646 Ok(())
647 }));
648 edit_definitions(engine, &[], edits)
649}
650
651#[derive(Debug, Clone, PartialEq, Eq)]
657pub struct Candidate {
658 pub source: ReplaceSource,
659 pub label: String,
662 pub group: &'static str,
665}
666
667impl Candidate {
668 pub fn key(&self) -> String {
670 match &self.source {
671 ReplaceSource::Stored(name) => format!("file:{name}"),
672 ReplaceSource::Library(part) => format!("part:{part}"),
673 }
674 }
675}
676
677pub const GROUPS: [(&str, &str); 6] = [
679 ("revisions", "Other revisions"),
680 ("family", "Same family"),
681 ("assembly", "In this assembly"),
682 ("folder", "Same folder"),
683 ("recent", "Recent"),
684 ("browse", "All parts"),
685];
686
687fn is_part_file(name: &str) -> bool {
690 use crate::document_class::DocumentClass;
691 !matches!(DocumentClass::of_name(name), Some(DocumentClass::Family | DocumentClass::Template))
692}
693
694pub fn candidates(
701 engine: &EngineState,
702 store: &dyn ModelStore,
703 component_id: &str,
704 browse: bool,
705 exclude: Option<&str>,
706) -> Vec<Candidate> {
707 let Some(info) = engine.component_info(component_id) else { return Vec::new() };
708 let canonicalize = store.canonicalizer();
709 let source_key = engine
710 .part_source(&info.part_name)
711 .map(|(key, _)| key)
712 .filter(|key| !key.is_empty());
713 let current = source_key.as_deref().map(|key| canonicalize(key));
714 let mut seen: std::collections::BTreeSet<String> = current.iter().cloned().collect();
715 if let Some(exclude) = exclude {
716 seen.insert(canonicalize(exclude));
717 }
718 let mut out = Vec::new();
719 let push_file = |out: &mut Vec<Candidate>, seen: &mut std::collections::BTreeSet<String>, name: String, label: String, group: &'static str| {
720 if seen.insert(canonicalize(&name)) {
721 out.push(Candidate { source: ReplaceSource::Stored(name), label, group });
722 }
723 };
724
725 let listing = store.list();
728 if store.plm_session().is_some() {
729 if let Some((part, revision)) = source_key.as_deref().and_then(crate::panels::plm_host::revision_of) {
730 let mut revisions: Vec<(String, String)> = listing
731 .iter()
732 .filter_map(|name| {
733 let (other_part, other) = crate::panels::plm_host::revision_of(name)?;
734 (other_part == part && other != revision).then(|| {
735 let key = crate::plm::identity::document_key(&other_part, &other);
736 let label = match store.plm_revision(&key) {
737 Some(entry) => format!("Revision {} ({})", entry.revision_label, entry.lifecycle),
738 None => format!("Revision {other}"),
739 };
740 (name.clone(), label)
741 })
742 })
743 .collect();
744 revisions.reverse();
745 for (name, label) in revisions {
746 push_file(&mut out, &mut seen, name, label, "revisions");
747 }
748 }
749 }
750
751 let document = engine.history.parts_library()[&info.part_name]["document"].clone();
754 if let (Some(family), Some(key)) = (crate::family_table::family_source(&document), source_key.as_deref()) {
755 let family_identity = crate::store::sibling_identity(key, &family.family);
756 if let crate::store::ReadNow::Ready(text) = crate::store::read_now(store, &family_identity) {
757 let table = crate::family_table::read_table(&text);
758 for row in &table.rows {
759 if let Some(member) = crate::family_table::member_identity(Some(&family_identity), &row.part_number) {
760 if store.read(&member).is_some() {
761 push_file(&mut out, &mut seen, member, row.part_number.trim().to_string(), "family");
762 }
763 }
764 }
765 } else {
766 for name in store.list() {
770 if crate::store::sibling_identity(key, &crate::store::file_name_of(&name)) != name {
771 continue;
772 }
773 let Some(text) = store.read(&name) else { continue };
774 let Ok(other) = serde_json::from_str::<serde_json::Value>(&text) else { continue };
775 if let Some(stamp) = crate::family_table::family_source(&other).filter(|s| s.family == family.family) {
776 push_file(&mut out, &mut seen, name, stamp.part_number, "family");
777 }
778 }
779 }
780 }
781
782 let mut library = engine.history.parts_library().as_object().cloned().unwrap_or_default();
784 library.remove(&info.part_name);
785 for (part, entry) in library {
786 if let Some(key) = entry["sourceKey"].as_str().filter(|k| !k.is_empty()) {
787 seen.insert(canonicalize(key));
788 }
789 out.push(Candidate { source: ReplaceSource::Library(part.clone()), label: part, group: "assembly" });
790 }
791
792 if let Some(key) = source_key.as_deref() {
794 for name in listing.iter().filter(|name| is_part_file(name)) {
795 let beside = crate::store::sibling_identity(key, &crate::store::file_name_of(name)) == *name;
796 if beside || store.plm_session().is_some() {
797 push_file(&mut out, &mut seen, name.clone(), model_display_name(name), "folder");
798 }
799 }
800 }
801 for name in crate::panels::file::recent_documents(store).into_iter().filter(|name| is_part_file(name)) {
802 let label = model_display_name(&name);
803 push_file(&mut out, &mut seen, name, label, "recent");
804 }
805 if browse {
806 for name in listing.into_iter().filter(|name| is_part_file(name)) {
807 let label = model_display_name(&name);
808 push_file(&mut out, &mut seen, name, label, "browse");
809 }
810 }
811 out
812}
813
814pub fn candidate_matches(store: &dyn ModelStore, candidate: &Candidate, filter: &str) -> bool {
818 let filter = filter.trim().to_lowercase();
819 if filter.is_empty() {
820 return true;
821 }
822 let mut haystack = candidate.label.to_lowercase();
823 match &candidate.source {
824 ReplaceSource::Stored(name) => {
825 haystack.push(' ');
826 haystack.push_str(&name.to_lowercase());
827 if let Some((part, revision)) = crate::panels::plm_host::revision_of(name) {
828 if let Some(entry) = store.plm_revision(&crate::plm::identity::document_key(&part, &revision)) {
829 haystack.push_str(&format!(" {} {}", entry.part_number, entry.part_name).to_lowercase());
830 }
831 }
832 }
833 ReplaceSource::Library(part) => {
834 haystack.push(' ');
835 haystack.push_str(&part.to_lowercase());
836 }
837 }
838 filter.split_whitespace().all(|word| haystack.contains(word))
839}
840
841pub fn search_candidates(
845 engine: &EngineState,
846 store: &dyn ModelStore,
847 component_id: &str,
848 filter: &str,
849 exclude: Option<&str>,
850) -> Vec<Candidate> {
851 if filter.trim().is_empty() {
852 return candidates(engine, store, component_id, false, exclude);
853 }
854 candidates(engine, store, component_id, true, exclude)
855 .into_iter()
856 .filter(|candidate| candidate_matches(store, candidate, filter))
857 .collect()
858}
859
860const SEARCH_LISTING_TTL: f64 = 1.0;
864
865#[derive(Debug, Clone, PartialEq)]
867pub struct Preview {
868 pub lineage: Option<&'static str>,
871 pub mates: usize,
873 pub document: Option<serde_json::Value>,
875}
876
877impl Preview {
878 pub fn sentence(&self) -> String {
880 match (self.lineage, self.mates) {
881 (_, 0) => "No mates on this component.".into(),
882 (Some("family"), n) => format!("Same family: the {n} mate(s) keep their faces by name where the faces still exist."),
883 (Some(_), n) => format!("A variation of this part: the {n} mate(s) keep their faces by name where the faces still exist."),
884 (None, n) => format!("An unrelated part: the {n} mate(s) will wait for your confirmation."),
885 }
886 }
887}
888
889pub fn preview(engine: &EngineState, store: &dyn ModelStore, component_id: &str, candidate: &Candidate) -> Option<Preview> {
893 let info = engine.component_info(component_id)?;
894 let library = engine.history.parts_library();
895 let old = &library[&info.part_name];
896 let new = match &candidate.source {
897 ReplaceSource::Library(part) => Some(library[part].clone()),
898 ReplaceSource::Stored(name) => match crate::store::read_now(store, name) {
899 crate::store::ReadNow::Ready(text) => serde_json::from_str::<serde_json::Value>(&text)
900 .ok()
901 .map(|document| serde_json::json!({ "sourceKey": name, "document": document })),
902 _ => None,
903 },
904 };
905 let canonicalize = lineage_canonicalizer(store);
906 let lineage = new
907 .as_ref()
908 .and_then(|new| brep_render::engine_state::replace_component::entries_lineage(old, new, &canonicalize));
909 let prefix = format!("{component_id}:");
910 let mates = engine
911 .history
912 .assembly_block()
913 .and_then(|block| block["constraints"].as_array().cloned())
914 .unwrap_or_default()
915 .iter()
916 .filter(|entry| {
917 entry["inputParams"]["elements"]
918 .as_array()
919 .into_iter()
920 .flatten()
921 .filter_map(serde_json::Value::as_str)
922 .any(|r| r == component_id || r.starts_with(&prefix))
923 })
924 .count();
925 Some(Preview { lineage, mates, document: new.map(|entry| entry["document"].clone()) })
926}
927
928#[derive(Default)]
937pub struct ReplaceDialog {
938 target: Option<String>,
939 document: Option<String>,
941 part: String,
942 instances: Vec<String>,
943 all: bool,
944 filter: String,
945 candidates: Vec<Candidate>,
946 chosen: Option<usize>,
947 summary: bool,
949 ticks: std::collections::BTreeMap<(String, String), bool>,
952 picking: Option<(String, String)>,
956 waiting: Option<usize>,
959 searched: String,
961 preview: Option<(String, Preview, Option<egui::TextureHandle>)>,
963 preview_missing: Option<(u64, f64)>,
967 searchable: Option<((String, Option<String>, u64, f64), Vec<Candidate>)>,
971 pattern: Option<String>,
973 status: String,
974 hits: std::collections::HashMap<String, egui::Rect>,
975}
976
977impl ReplaceDialog {
978 pub fn is_open(&self) -> bool {
979 self.target.is_some() || self.summary
980 }
981
982 pub fn open(&mut self, engine: &EngineState, store: &dyn ModelStore, component_id: &str, document: Option<&str>) {
984 *self = Self::default();
985 let (component_id, instance) = match brep_render::brep_kernel::pattern_component_instance(component_id) {
988 Some((pattern, seed, n)) => (seed, Some((component_id.to_string(), pattern, n))),
989 None => (component_id.to_string(), None),
990 };
991 let component_id = component_id.as_str();
992 self.pattern = pattern_line(engine, component_id, instance.as_ref());
993 let Some(info) = engine.component_info(component_id) else { return };
994 self.part = info.part_name;
995 self.instances = instances_of(engine, component_id);
996 self.target = Some(component_id.to_string());
997 self.document = document.map(str::to_string);
998 self.candidates = candidates(engine, store, component_id, false, document);
999 }
1000
1001 pub fn review(&mut self) {
1003 *self = Self::default();
1004 self.summary = true;
1005 }
1006
1007 pub fn close(&mut self) {
1008 *self = Self::default();
1009 }
1010
1011 pub fn escape(&mut self) -> bool {
1016 if self.target.is_some() {
1017 self.close();
1018 return true;
1019 }
1020 false
1021 }
1022
1023 pub fn apply(&mut self, engine: &mut EngineState, store: &dyn ModelStore, index: usize) -> Result<(), String> {
1027 let target = self.target.clone().ok_or("no component to replace")?;
1028 let candidate = self.candidates.get(index).cloned().ok_or("no such candidate")?;
1029 if let ReplaceSource::Stored(name) = &candidate.source {
1030 if matches!(crate::store::read_now(store, name), crate::store::ReadNow::Loading) {
1031 self.waiting = Some(index);
1032 self.status = format!("Loading {}\u{2026}", candidate.label);
1033 return Ok(());
1034 }
1035 }
1036 self.waiting = None;
1037 let ids = if self.all { self.instances.clone() } else { vec![target] };
1038 replace(engine, store, &ids, &candidate.source)?;
1039 self.target = None;
1040 self.summary = true;
1041 Ok(())
1042 }
1043
1044 pub fn state_json(&self) -> serde_json::Value {
1046 serde_json::json!({
1047 "open": self.is_open(),
1048 "target": self.target,
1049 "part": self.part,
1050 "instances": self.instances,
1051 "all": self.all,
1052 "summary": self.summary,
1053 "picking": self.picking.as_ref().map(|(id, r)| serde_json::json!({ "id": id, "ref": r })),
1054 "status": self.status,
1055 "pattern": self.pattern,
1056 "waiting": self.waiting.is_some(),
1057 "chosen": self.chosen,
1058 "preview": self.preview.as_ref().map(|(key, preview, picture)| serde_json::json!({
1059 "key": key,
1060 "lineage": preview.lineage,
1061 "mates": preview.mates,
1062 "sentence": preview.sentence(),
1063 "picture": picture.is_some(),
1064 "loading": preview.document.is_none(),
1065 })),
1066 "candidates": self.candidates.iter().map(|c| serde_json::json!({
1067 "key": c.key(), "label": c.label, "group": c.group,
1068 })).collect::<Vec<_>>(),
1069 })
1070 }
1071
1072 pub fn hits_json(&self) -> String {
1073 crate::automation::hit_rects::hits_json(&self.hits)
1074 }
1075
1076 pub fn show(&mut self, ctx: &egui::Context, engine: &mut EngineState, store: &dyn ModelStore) {
1077 self.hits.clear();
1078 if self.is_open() {
1079 self.window(ctx, engine, store);
1080 }
1081 if crate::automation::registry::enabled() {
1085 crate::automation::registry::publish("__brepReplaceHit", "replace component window widget rects", &self.hits_json());
1086 crate::automation::registry::publish("__brepReplace", "replace component window state", &self.state_json().to_string());
1087 }
1088 }
1089
1090 fn window(&mut self, ctx: &egui::Context, engine: &mut EngineState, store: &dyn ModelStore) {
1091 if let Some(index) = self.waiting {
1093 if let Err(error) = self.apply(engine, store, index) {
1094 self.waiting = None;
1095 self.status = error;
1096 }
1097 if self.waiting.is_some() {
1098 ctx.request_repaint_after(std::time::Duration::from_millis(100));
1099 }
1100 }
1101 let mut open = true;
1102 let title = if self.summary { "Replace component: result" } else { "Replace component" };
1103 egui::Window::new(title)
1104 .id(egui::Id::new("replace-component"))
1105 .open(&mut open)
1106 .resizable(true)
1107 .default_size([420.0, 460.0])
1108 .default_pos(super::below_chrome(ctx, 420.0, 60.0))
1109 .show(ctx, |ui| {
1110 if self.summary {
1111 self.summary_ui(ui, engine);
1112 } else {
1113 self.chooser_ui(ui, engine, store);
1114 }
1115 });
1116 if !open {
1117 self.close();
1118 }
1119 }
1120
1121 fn chooser_ui(&mut self, ui: &mut egui::Ui, engine: &mut EngineState, store: &dyn ModelStore) {
1122 let Some(target) = self.target.clone() else { return };
1123 let (down, up, enter, escape) = ui.ctx().input(|i| {
1128 (
1129 i.key_pressed(egui::Key::ArrowDown),
1130 i.key_pressed(egui::Key::ArrowUp),
1131 i.key_pressed(egui::Key::Enter),
1132 i.key_pressed(egui::Key::Escape),
1133 )
1134 });
1135 if escape {
1136 self.target = None;
1137 return;
1138 }
1139 ui.label(egui::RichText::new(format!("Replace {} ({target}) with:", self.part)).strong());
1140 if let Some(line) = &self.pattern {
1141 ui.label(line);
1142 }
1143 if self.instances.len() > 1 {
1144 let label = format!("Replace all {} instances of {}", self.instances.len(), self.part);
1145 let response = ui.checkbox(&mut self.all, label);
1146 self.hits.insert("replace:all".into(), response.rect);
1147 }
1148 ui.horizontal(|ui| {
1149 ui.label("Filter");
1150 let response = ui.text_edit_singleline(&mut self.filter);
1151 self.hits.insert("replace:filter".into(), response.rect);
1152 let browse = ui.button("Browse\u{2026}").on_hover_text("List every part file in the store");
1153 self.hits.insert("replace:browse".into(), browse.rect);
1154 if browse.clicked() {
1155 self.candidates = candidates(engine, store, &target, true, self.document.as_deref());
1156 self.chosen = None;
1157 }
1158 });
1159 ui.separator();
1160 if self.filter.trim() != self.searched {
1162 self.searched = self.filter.trim().to_string();
1163 self.candidates = if self.searched.is_empty() {
1164 candidates(engine, store, &target, false, self.document.as_deref())
1165 } else {
1166 let now = ui.input(|i| i.time);
1167 let generation = store.mutation_generation();
1168 let fresh = self.searchable.as_ref().is_some_and(|((t, d, g, at), _)| {
1169 *t == target && *d == self.document && *g == generation && now - at < SEARCH_LISTING_TTL
1170 });
1171 if !fresh {
1172 let all = candidates(engine, store, &target, true, self.document.as_deref());
1173 self.searchable = Some(((target.clone(), self.document.clone(), generation, now), all));
1174 }
1175 let all = self.searchable.as_ref().map(|(_, all)| all.as_slice()).unwrap_or_default();
1176 all.iter().filter(|candidate| candidate_matches(store, candidate, &self.searched)).cloned().collect()
1177 };
1178 self.chosen = None;
1179 }
1180 let filter = self.filter.trim().to_lowercase();
1181 let mut double_clicked = None;
1182 let mut shown: Vec<usize> = Vec::new();
1183 egui::ScrollArea::vertical().max_height(300.0).auto_shrink([false, true]).show(ui, |ui| {
1184 if self.candidates.is_empty() {
1185 ui.weak("No other parts found: use Browse\u{2026}");
1186 }
1187 for (group, heading) in GROUPS {
1188 let rows: Vec<usize> = (0..self.candidates.len())
1189 .filter(|&i| self.candidates[i].group == group)
1190 .filter(|&i| candidate_matches(store, &self.candidates[i], &filter))
1191 .collect();
1192 if rows.is_empty() {
1193 continue;
1194 }
1195 let heading = if group == "folder" && store.plm_session().is_some() { "PLM workspace" } else { heading };
1196 ui.label(egui::RichText::new(heading).weak().small());
1197 for i in rows {
1198 let response = ui.selectable_label(self.chosen == Some(i), &self.candidates[i].label);
1199 self.hits.insert(format!("replace:candidate:{}", self.candidates[i].key()), response.rect);
1200 self.hits.insert(format!("replace:row:{}", shown.len()), response.rect);
1201 shown.push(i);
1202 if response.clicked() {
1203 self.chosen = Some(i);
1204 }
1205 if response.double_clicked() {
1206 double_clicked = Some(i);
1207 }
1208 }
1209 }
1210 });
1211 if (down || up) && !shown.is_empty() {
1212 let at = self.chosen.and_then(|c| shown.iter().position(|&i| i == c));
1213 let next = match (at, down) {
1214 (None, true) => 0,
1215 (None, false) => shown.len() - 1,
1216 (Some(p), true) => (p + 1).min(shown.len() - 1),
1217 (Some(p), false) => p.saturating_sub(1),
1218 };
1219 self.chosen = Some(shown[next]);
1220 }
1221 ui.separator();
1222 if !self.status.is_empty() {
1223 ui.colored_label(ui.visuals().warn_fg_color, &self.status);
1224 }
1225 if let Some(index) = self.chosen {
1228 let key = self.candidates[index].key();
1229 let now = ui.input(|i| i.time);
1230 let generation = store.mutation_generation();
1231 let settled_missing = self
1235 .preview_missing
1236 .is_some_and(|(g, at)| g == generation && now - at < SEARCH_LISTING_TTL);
1237 if self.preview.as_ref().is_none_or(|(k, p, _)| *k != key || (p.document.is_none() && !settled_missing)) {
1238 let loading = match &self.candidates[index].source {
1239 ReplaceSource::Stored(name) => store.residency(name) == crate::store::Residency::OnDisk,
1240 ReplaceSource::Library(_) => false,
1241 };
1242 self.preview = preview(engine, store, &target, &self.candidates[index]).map(|preview| {
1243 let picture = preview
1244 .document
1245 .as_ref()
1246 .and_then(crate::plm::thumbnail::embedded_image)
1247 .map(|image| ui.ctx().load_texture(format!("replace-preview:{key}"), image, egui::TextureOptions::LINEAR));
1248 (key, preview, picture)
1249 });
1250 self.preview_missing = match &self.preview {
1251 Some((_, preview, _)) if preview.document.is_none() && !loading => Some((generation, now)),
1252 _ => None,
1253 };
1254 }
1255 if let Some((_, preview, picture)) = &self.preview {
1256 ui.horizontal(|ui| {
1257 match picture {
1258 Some(texture) => {
1259 let response = ui.add(egui::Image::new(texture).fit_to_exact_size(egui::vec2(96.0, 96.0)));
1260 self.hits.insert("replace:preview".into(), response.rect);
1261 }
1262 None if preview.document.is_none() => {
1263 ui.weak("Loading\u{2026}");
1264 }
1265 None => {
1266 ui.weak("(no picture)");
1267 }
1268 }
1269 ui.label(preview.sentence());
1270 });
1271 }
1272 } else {
1273 self.preview = None;
1274 }
1275 let top_hit = (!filter.is_empty()).then(|| shown.first().copied()).flatten();
1277 let mut apply = double_clicked.or(if enter { self.chosen.or(top_hit) } else { None });
1278 ui.horizontal(|ui| {
1279 let replace = ui.add_enabled(self.chosen.is_some(), egui::Button::new("Replace"));
1280 self.hits.insert("replace:apply".into(), replace.rect);
1281 if replace.clicked() {
1282 apply = self.chosen;
1283 }
1284 let cancel = ui.button("Cancel");
1285 self.hits.insert("replace:cancel".into(), cancel.rect);
1286 if cancel.clicked() {
1287 self.target = None;
1288 }
1289 });
1290 if let Some(index) = apply {
1291 if let Err(error) = self.apply(engine, store, index) {
1292 self.status = error;
1293 }
1294 }
1295 }
1296
1297 fn summary_ui(&mut self, ui: &mut egui::Ui, engine: &mut EngineState) {
1298 let report = engine.replace_report();
1299 let unresolved = serde_json::Value::Array(held_mates(engine));
1300 if report.is_null() && unresolved.as_array().is_none_or(Vec::is_empty) {
1301 if engine.run_pending() {
1302 ui.label("Solving\u{2026}");
1303 } else {
1304 ui.label("Nothing to review.");
1305 }
1306 }
1307 if !report.is_null() && report["origin"] == "edit" {
1308 ui.label(format!("After editing {}:", report["to"].as_str().unwrap_or("")));
1309 let kept = report["kept"].as_array().map_or(0, Vec::len);
1310 ui.label(format!("{kept} mate(s) kept."));
1311 for moved in report["reattached"].as_array().into_iter().flatten() {
1312 ui.label(format!(
1313 "{}: re-attached {} \u{2192} {} (the one face on the same carrier)",
1314 moved["id"].as_str().unwrap_or(""),
1315 moved["from"].as_str().unwrap_or(""),
1316 moved["to"].as_str().unwrap_or("")
1317 ));
1318 }
1319 for note in report["notes"].as_array().into_iter().flatten() {
1320 ui.weak(note.as_str().unwrap_or(""));
1321 }
1322 } else if !report.is_null() {
1323 let lineage = report["lineage"].as_object().and_then(|m| m.values().next()).and_then(|v| v.as_str()).unwrap_or("none");
1324 let words = match lineage {
1325 "family" => "a member of the same family: mates kept by face name",
1326 "derived" => "a variation of the same part: mates kept by face name",
1327 _ => "an unrelated part: every mate waits for your confirmation",
1328 };
1329 ui.label(format!("Now showing {} ({words}).", report["to"].as_str().unwrap_or("")));
1330 let kept = report["kept"].as_array().map_or(0, Vec::len);
1331 ui.label(format!("{kept} mate(s) kept."));
1332 for note in report["notes"].as_array().into_iter().flatten() {
1333 ui.weak(note.as_str().unwrap_or(""));
1334 }
1335 }
1336 let rows = unresolved.as_array().cloned().unwrap_or_default();
1337 let mut repairs: Vec<(String, String, String)> = Vec::new();
1338 if let Some((id, reference, picked)) = engine.take_replace_repair_pick() {
1342 let owner = reference.rsplit_once(':').map(|(path, _)| path).unwrap_or("").to_string();
1343 if picked.starts_with(&format!("{owner}:")) {
1344 repairs.push((id, reference, picked));
1345 } else {
1346 self.status = format!("Pick a face or edge of {owner} for {id}: {picked} is on another part");
1347 }
1348 }
1349 let running = engine.ref_select_field().filter(|f| f.target == RefSelectTarget::ReplaceRepair);
1350 self.picking = running.as_ref().map(|f| (f.subject.clone(), f.path.first().cloned().unwrap_or_default()));
1351 let mut delete: Option<String> = None;
1352 if !rows.is_empty() {
1353 ui.separator();
1354 ui.label(egui::RichText::new(format!("{} mate(s) need a face (held, not solved):", rows.len())).strong());
1355 }
1356 egui::ScrollArea::vertical().max_height(300.0).auto_shrink([false, true]).show(ui, |ui| {
1357 for mate in &rows {
1358 let id = mate["id"].as_str().unwrap_or("").to_string();
1359 ui.horizontal(|ui| {
1360 ui.label(egui::RichText::new(format!("{id} ({})", mate["type"].as_str().unwrap_or(""))).strong());
1361 let delete_button = ui.small_button("\u{2716} Delete mate").on_hover_text("Delete this mate");
1362 self.hits.insert(format!("replace:delete:{id}"), delete_button.rect);
1363 if delete_button.clicked() {
1364 delete = Some(id.clone());
1365 }
1366 });
1367 for row in mate["refs"].as_array().into_iter().flatten() {
1368 let reference = row["ref"].as_str().unwrap_or("").to_string();
1369 let edited = mate["origin"] == "edit";
1370 let reason = match row["reason"].as_str().unwrap_or("") {
1371 "missing" if edited => "gone after the edit, and no single face replaces it",
1372 "missing" => "not on the new part",
1373 "type-changed" => "now a different kind of face",
1374 "moved" => "that corner moved on the new part",
1375 "no-longer-maps" => "no longer fits this mate",
1376 _ => "unrelated part: confirm",
1377 };
1378 ui.indent(format!("ref-{id}-{reference}"), |ui| {
1379 ui.label(format!("{reference}: {reason}"));
1380 let row_key = format!("{id}:{reference}");
1381 if let Some(suggestion) = row["suggestion"]["ref"].as_str() {
1382 let pre = row["suggestion"]["preTicked"].as_bool().unwrap_or(false);
1383 let tick = self.ticks.entry((id.clone(), reference.clone())).or_insert(pre);
1384 let basis = if row["suggestion"]["basis"] == "same-name" { "same name" } else { "nearest" };
1385 let label = format!("Use {suggestion} ({basis}{})", if pre { ", same geometry" } else { "" });
1386 let response = ui.checkbox(tick, label);
1387 self.hits.insert(format!("replace:tick:{row_key}"), response.rect);
1388 }
1389 let picking = self.picking.as_ref() == Some(&(id.clone(), reference.clone()));
1390 if picking {
1391 engine.ref_select_claim();
1395 ui.weak("Click the face or edge to use in the viewport.");
1396 let cancel = ui.small_button("Cancel pick");
1397 self.hits.insert(format!("replace:cancel-pick:{row_key}"), cancel.rect);
1398 if cancel.clicked() {
1399 engine.cancel_ref_select();
1400 }
1401 } else {
1402 let pick = ui.small_button("Pick\u{2026}").on_hover_text("Pick the replacement face or edge in the viewport");
1403 self.hits.insert(format!("replace:pick:{row_key}"), pick.rect);
1404 if pick.clicked() {
1405 if engine.ref_select_active() {
1406 engine.finish_ref_select();
1407 }
1408 self.status.clear();
1409 engine.begin_ref_select_for_replace_repair(&id, &reference);
1410 engine.ref_select_claim();
1413 }
1414 }
1415 });
1416 }
1417 }
1418 });
1419 ui.separator();
1420 ui.horizontal(|ui| {
1421 let ticked: Vec<(String, String, String)> = rows
1422 .iter()
1423 .flat_map(|mate| {
1424 let id = mate["id"].as_str().unwrap_or("").to_string();
1425 mate["refs"].as_array().cloned().unwrap_or_default().into_iter().filter_map(move |row| {
1426 let reference = row["ref"].as_str()?.to_string();
1427 let suggestion = row["suggestion"]["ref"].as_str()?.to_string();
1428 Some((id.clone(), reference, suggestion))
1429 })
1430 })
1431 .filter(|(id, reference, _)| self.ticks.get(&(id.clone(), reference.clone())).copied().unwrap_or(false))
1432 .collect();
1433 let apply = ui.add_enabled(!ticked.is_empty(), egui::Button::new(format!("Apply ticked ({})", ticked.len())));
1434 self.hits.insert("replace:apply-ticked".into(), apply.rect);
1435 if apply.clicked() {
1436 repairs.extend(ticked);
1437 }
1438 let close = ui.button("Close").on_hover_text("Held mates stay disabled and listed; reopen this from the Constraints panel");
1439 self.hits.insert("replace:close".into(), close.rect);
1440 if close.clicked() {
1441 self.summary = false;
1442 }
1443 });
1444 if !self.status.is_empty() {
1445 ui.colored_label(ui.visuals().warn_fg_color, &self.status);
1446 }
1447 if !repairs.is_empty() {
1448 match repair(engine, &repairs) {
1449 Ok(()) => self.status.clear(),
1450 Err(error) => self.status = error,
1451 }
1452 }
1453 if let Some(id) = delete {
1454 if let Err(error) = delete_held(engine, &id) {
1455 self.status = error;
1456 }
1457 }
1458 }
1459}
1460
1461pub static HIT_KEYS: &[HitKeyDoc] = &[
1463 HitKeyDoc { panel: "replace", prefix: "replace:all", meaning: "Replace all instances of the part (checkbox)", command: Some("assembly_replace_component") },
1464 HitKeyDoc { panel: "replace", prefix: "replace:filter", meaning: "filter the candidate list", command: None },
1465 HitKeyDoc { panel: "replace", prefix: "replace:browse", meaning: "list every part file in the store", command: None },
1466 HitKeyDoc { panel: "replace", prefix: "replace:candidate:", meaning: "a replacement candidate (replace:candidate:file:<name> or replace:candidate:part:<entry>)", command: None },
1467 HitKeyDoc { panel: "replace", prefix: "replace:row:", meaning: "a candidate by its position in the list as shown (replace:row:0 is the first row, under its section heading); the keyboard moves through the same rows (arrows, Enter replaces, Escape closes)", command: None },
1468 HitKeyDoc { panel: "replace", prefix: "replace:preview", meaning: "the chosen candidate's picture (shown when its document carries one)", command: None },
1469 HitKeyDoc { panel: "replace", prefix: "replace:apply", meaning: "replace with the chosen candidate", command: Some("assembly_replace_component") },
1470 HitKeyDoc { panel: "replace", prefix: "replace:cancel", meaning: "close the chooser without replacing", command: None },
1471 HitKeyDoc { panel: "replace", prefix: "replace:tick:", meaning: "use the suggested face for an unresolved reference (replace:tick:<mate>:<ref>)", command: None },
1472 HitKeyDoc { panel: "replace", prefix: "replace:pick:", meaning: "start the reference pick for one held reference (replace:pick:<mate>:<ref>): faces and edges only, and the one face or edge clicked in the viewport repairs it", command: Some("assembly_replace_repair") },
1473 HitKeyDoc { panel: "replace", prefix: "replace:cancel-pick:", meaning: "cancel the running repair pick (replace:cancel-pick:<mate>:<ref>)", command: None },
1474 HitKeyDoc { panel: "replace", prefix: "replace:delete:", meaning: "delete an unresolved mate (replace:delete:<mate>)", command: Some("assembly_remove_constraint") },
1475 HitKeyDoc { panel: "replace", prefix: "replace:apply-ticked", meaning: "apply every ticked suggestion", command: Some("assembly_replace_accept") },
1476 HitKeyDoc { panel: "replace", prefix: "replace:close", meaning: "close the summary (held mates stay listed)", command: None },
1477];
1478