1use brep_render::engine_state::{document_signature, ComponentInsert, EngineState};
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
860#[derive(Debug, Clone, PartialEq)]
862pub struct Preview {
863 pub lineage: Option<&'static str>,
866 pub mates: usize,
868 pub document: Option<serde_json::Value>,
870}
871
872impl Preview {
873 pub fn sentence(&self) -> String {
875 match (self.lineage, self.mates) {
876 (_, 0) => "No mates on this component.".into(),
877 (Some("family"), n) => format!("Same family: the {n} mate(s) keep their faces by name where the faces still exist."),
878 (Some(_), n) => format!("A variation of this part: the {n} mate(s) keep their faces by name where the faces still exist."),
879 (None, n) => format!("An unrelated part: the {n} mate(s) will wait for your confirmation."),
880 }
881 }
882}
883
884pub fn preview(engine: &EngineState, store: &dyn ModelStore, component_id: &str, candidate: &Candidate) -> Option<Preview> {
888 let info = engine.component_info(component_id)?;
889 let library = engine.history.parts_library();
890 let old = &library[&info.part_name];
891 let new = match &candidate.source {
892 ReplaceSource::Library(part) => Some(library[part].clone()),
893 ReplaceSource::Stored(name) => match crate::store::read_now(store, name) {
894 crate::store::ReadNow::Ready(text) => serde_json::from_str::<serde_json::Value>(&text)
895 .ok()
896 .map(|document| serde_json::json!({ "sourceKey": name, "document": document })),
897 _ => None,
898 },
899 };
900 let canonicalize = lineage_canonicalizer(store);
901 let lineage = new
902 .as_ref()
903 .and_then(|new| brep_render::engine_state::replace_component::entries_lineage(old, new, &canonicalize));
904 let prefix = format!("{component_id}:");
905 let mates = engine
906 .history
907 .assembly_block()
908 .and_then(|block| block["constraints"].as_array().cloned())
909 .unwrap_or_default()
910 .iter()
911 .filter(|entry| {
912 entry["inputParams"]["elements"]
913 .as_array()
914 .into_iter()
915 .flatten()
916 .filter_map(serde_json::Value::as_str)
917 .any(|r| r == component_id || r.starts_with(&prefix))
918 })
919 .count();
920 Some(Preview { lineage, mates, document: new.map(|entry| entry["document"].clone()) })
921}
922
923#[derive(Default)]
932pub struct ReplaceDialog {
933 target: Option<String>,
934 document: Option<String>,
936 part: String,
937 instances: Vec<String>,
938 all: bool,
939 filter: String,
940 candidates: Vec<Candidate>,
941 chosen: Option<usize>,
942 summary: bool,
944 ticks: std::collections::BTreeMap<(String, String), bool>,
947 picking: Option<(String, String)>,
949 waiting: Option<usize>,
952 searched: String,
954 preview: Option<(String, Preview, Option<egui::TextureHandle>)>,
956 pattern: Option<String>,
958 status: String,
959 hits: std::collections::HashMap<String, egui::Rect>,
960}
961
962impl ReplaceDialog {
963 pub fn is_open(&self) -> bool {
964 self.target.is_some() || self.summary
965 }
966
967 pub fn open(&mut self, engine: &EngineState, store: &dyn ModelStore, component_id: &str, document: Option<&str>) {
969 *self = Self::default();
970 let (component_id, instance) = match brep_render::brep_kernel::pattern_component_instance(component_id) {
973 Some((pattern, seed, n)) => (seed, Some((component_id.to_string(), pattern, n))),
974 None => (component_id.to_string(), None),
975 };
976 let component_id = component_id.as_str();
977 self.pattern = pattern_line(engine, component_id, instance.as_ref());
978 let Some(info) = engine.component_info(component_id) else { return };
979 self.part = info.part_name;
980 self.instances = instances_of(engine, component_id);
981 self.target = Some(component_id.to_string());
982 self.document = document.map(str::to_string);
983 self.candidates = candidates(engine, store, component_id, false, document);
984 }
985
986 pub fn review(&mut self) {
988 *self = Self::default();
989 self.summary = true;
990 }
991
992 pub fn close(&mut self) {
993 *self = Self::default();
994 }
995
996 pub fn escape(&mut self) -> bool {
1000 if self.picking.take().is_some() {
1001 return true;
1002 }
1003 if self.target.is_some() {
1004 self.close();
1005 return true;
1006 }
1007 false
1008 }
1009
1010 pub fn apply(&mut self, engine: &mut EngineState, store: &dyn ModelStore, index: usize) -> Result<(), String> {
1014 let target = self.target.clone().ok_or("no component to replace")?;
1015 let candidate = self.candidates.get(index).cloned().ok_or("no such candidate")?;
1016 if let ReplaceSource::Stored(name) = &candidate.source {
1017 if matches!(crate::store::read_now(store, name), crate::store::ReadNow::Loading) {
1018 self.waiting = Some(index);
1019 self.status = format!("Loading {}\u{2026}", candidate.label);
1020 return Ok(());
1021 }
1022 }
1023 self.waiting = None;
1024 let ids = if self.all { self.instances.clone() } else { vec![target] };
1025 replace(engine, store, &ids, &candidate.source)?;
1026 self.target = None;
1027 self.summary = true;
1028 Ok(())
1029 }
1030
1031 pub fn state_json(&self) -> serde_json::Value {
1033 serde_json::json!({
1034 "open": self.is_open(),
1035 "target": self.target,
1036 "part": self.part,
1037 "instances": self.instances,
1038 "all": self.all,
1039 "summary": self.summary,
1040 "picking": self.picking.as_ref().map(|(id, r)| serde_json::json!({ "id": id, "ref": r })),
1041 "status": self.status,
1042 "pattern": self.pattern,
1043 "waiting": self.waiting.is_some(),
1044 "chosen": self.chosen,
1045 "preview": self.preview.as_ref().map(|(key, preview, picture)| serde_json::json!({
1046 "key": key,
1047 "lineage": preview.lineage,
1048 "mates": preview.mates,
1049 "sentence": preview.sentence(),
1050 "picture": picture.is_some(),
1051 "loading": preview.document.is_none(),
1052 })),
1053 "candidates": self.candidates.iter().map(|c| serde_json::json!({
1054 "key": c.key(), "label": c.label, "group": c.group,
1055 })).collect::<Vec<_>>(),
1056 })
1057 }
1058
1059 pub fn hits_json(&self) -> String {
1060 crate::automation::hit_rects::hits_json(&self.hits)
1061 }
1062
1063 pub fn show(&mut self, ctx: &egui::Context, engine: &mut EngineState, store: &dyn ModelStore) {
1064 self.hits.clear();
1065 if self.is_open() {
1066 self.window(ctx, engine, store);
1067 }
1068 if crate::automation::registry::enabled() {
1072 crate::automation::registry::publish("__brepReplaceHit", "replace component window widget rects", &self.hits_json());
1073 crate::automation::registry::publish("__brepReplace", "replace component window state", &self.state_json().to_string());
1074 }
1075 }
1076
1077 fn window(&mut self, ctx: &egui::Context, engine: &mut EngineState, store: &dyn ModelStore) {
1078 if let Some(index) = self.waiting {
1080 if let Err(error) = self.apply(engine, store, index) {
1081 self.waiting = None;
1082 self.status = error;
1083 }
1084 if self.waiting.is_some() {
1085 ctx.request_repaint_after(std::time::Duration::from_millis(100));
1086 }
1087 }
1088 let mut open = true;
1089 let title = if self.summary { "Replace component: result" } else { "Replace component" };
1090 egui::Window::new(title)
1091 .id(egui::Id::new("replace-component"))
1092 .open(&mut open)
1093 .resizable(true)
1094 .default_size([420.0, 460.0])
1095 .default_pos(super::below_chrome(ctx, 420.0, 60.0))
1096 .show(ctx, |ui| {
1097 if self.summary {
1098 self.summary_ui(ui, engine);
1099 } else {
1100 self.chooser_ui(ui, engine, store);
1101 }
1102 });
1103 if !open {
1104 self.close();
1105 }
1106 }
1107
1108 fn chooser_ui(&mut self, ui: &mut egui::Ui, engine: &mut EngineState, store: &dyn ModelStore) {
1109 let Some(target) = self.target.clone() else { return };
1110 let (down, up, enter, escape) = ui.ctx().input(|i| {
1115 (
1116 i.key_pressed(egui::Key::ArrowDown),
1117 i.key_pressed(egui::Key::ArrowUp),
1118 i.key_pressed(egui::Key::Enter),
1119 i.key_pressed(egui::Key::Escape),
1120 )
1121 });
1122 if escape {
1123 self.target = None;
1124 return;
1125 }
1126 ui.label(egui::RichText::new(format!("Replace {} ({target}) with:", self.part)).strong());
1127 if let Some(line) = &self.pattern {
1128 ui.label(line);
1129 }
1130 if self.instances.len() > 1 {
1131 let label = format!("Replace all {} instances of {}", self.instances.len(), self.part);
1132 let response = ui.checkbox(&mut self.all, label);
1133 self.hits.insert("replace:all".into(), response.rect);
1134 }
1135 ui.horizontal(|ui| {
1136 ui.label("Filter");
1137 let response = ui.text_edit_singleline(&mut self.filter);
1138 self.hits.insert("replace:filter".into(), response.rect);
1139 let browse = ui.button("Browse\u{2026}").on_hover_text("List every part file in the store");
1140 self.hits.insert("replace:browse".into(), browse.rect);
1141 if browse.clicked() {
1142 self.candidates = candidates(engine, store, &target, true, self.document.as_deref());
1143 self.chosen = None;
1144 }
1145 });
1146 ui.separator();
1147 if self.filter.trim() != self.searched {
1149 self.searched = self.filter.trim().to_string();
1150 self.candidates = search_candidates(engine, store, &target, &self.searched, self.document.as_deref());
1151 self.chosen = None;
1152 }
1153 let filter = self.filter.trim().to_lowercase();
1154 let mut double_clicked = None;
1155 let mut shown: Vec<usize> = Vec::new();
1156 egui::ScrollArea::vertical().max_height(300.0).auto_shrink([false, true]).show(ui, |ui| {
1157 if self.candidates.is_empty() {
1158 ui.weak("No other parts found: use Browse\u{2026}");
1159 }
1160 for (group, heading) in GROUPS {
1161 let rows: Vec<usize> = (0..self.candidates.len())
1162 .filter(|&i| self.candidates[i].group == group)
1163 .filter(|&i| candidate_matches(store, &self.candidates[i], &filter))
1164 .collect();
1165 if rows.is_empty() {
1166 continue;
1167 }
1168 let heading = if group == "folder" && store.plm_session().is_some() { "PLM workspace" } else { heading };
1169 ui.label(egui::RichText::new(heading).weak().small());
1170 for i in rows {
1171 let response = ui.selectable_label(self.chosen == Some(i), &self.candidates[i].label);
1172 self.hits.insert(format!("replace:candidate:{}", self.candidates[i].key()), response.rect);
1173 self.hits.insert(format!("replace:row:{}", shown.len()), response.rect);
1174 shown.push(i);
1175 if response.clicked() {
1176 self.chosen = Some(i);
1177 }
1178 if response.double_clicked() {
1179 double_clicked = Some(i);
1180 }
1181 }
1182 }
1183 });
1184 if (down || up) && !shown.is_empty() {
1185 let at = self.chosen.and_then(|c| shown.iter().position(|&i| i == c));
1186 let next = match (at, down) {
1187 (None, true) => 0,
1188 (None, false) => shown.len() - 1,
1189 (Some(p), true) => (p + 1).min(shown.len() - 1),
1190 (Some(p), false) => p.saturating_sub(1),
1191 };
1192 self.chosen = Some(shown[next]);
1193 }
1194 ui.separator();
1195 if !self.status.is_empty() {
1196 ui.colored_label(ui.visuals().warn_fg_color, &self.status);
1197 }
1198 if let Some(index) = self.chosen {
1201 let key = self.candidates[index].key();
1202 if self.preview.as_ref().is_none_or(|(k, p, _)| *k != key || p.document.is_none()) {
1203 self.preview = preview(engine, store, &target, &self.candidates[index]).map(|preview| {
1204 let picture = preview
1205 .document
1206 .as_ref()
1207 .and_then(crate::plm::thumbnail::embedded_image)
1208 .map(|image| ui.ctx().load_texture(format!("replace-preview:{key}"), image, egui::TextureOptions::LINEAR));
1209 (key, preview, picture)
1210 });
1211 }
1212 if let Some((_, preview, picture)) = &self.preview {
1213 ui.horizontal(|ui| {
1214 match picture {
1215 Some(texture) => {
1216 let response = ui.add(egui::Image::new(texture).fit_to_exact_size(egui::vec2(96.0, 96.0)));
1217 self.hits.insert("replace:preview".into(), response.rect);
1218 }
1219 None if preview.document.is_none() => {
1220 ui.weak("Loading\u{2026}");
1221 }
1222 None => {
1223 ui.weak("(no picture)");
1224 }
1225 }
1226 ui.label(preview.sentence());
1227 });
1228 }
1229 } else {
1230 self.preview = None;
1231 }
1232 let top_hit = (!filter.is_empty()).then(|| shown.first().copied()).flatten();
1234 let mut apply = double_clicked.or(if enter { self.chosen.or(top_hit) } else { None });
1235 ui.horizontal(|ui| {
1236 let replace = ui.add_enabled(self.chosen.is_some(), egui::Button::new("Replace"));
1237 self.hits.insert("replace:apply".into(), replace.rect);
1238 if replace.clicked() {
1239 apply = self.chosen;
1240 }
1241 let cancel = ui.button("Cancel");
1242 self.hits.insert("replace:cancel".into(), cancel.rect);
1243 if cancel.clicked() {
1244 self.target = None;
1245 }
1246 });
1247 if let Some(index) = apply {
1248 if let Err(error) = self.apply(engine, store, index) {
1249 self.status = error;
1250 }
1251 }
1252 }
1253
1254 fn summary_ui(&mut self, ui: &mut egui::Ui, engine: &mut EngineState) {
1255 let report = engine.replace_report();
1256 let unresolved = serde_json::Value::Array(held_mates(engine));
1257 if report.is_null() && unresolved.as_array().is_none_or(Vec::is_empty) {
1258 if engine.run_pending() {
1259 ui.label("Solving\u{2026}");
1260 } else {
1261 ui.label("Nothing to review.");
1262 }
1263 }
1264 if !report.is_null() && report["origin"] == "edit" {
1265 ui.label(format!("After editing {}:", report["to"].as_str().unwrap_or("")));
1266 let kept = report["kept"].as_array().map_or(0, Vec::len);
1267 ui.label(format!("{kept} mate(s) kept."));
1268 for moved in report["reattached"].as_array().into_iter().flatten() {
1269 ui.label(format!(
1270 "{}: re-attached {} \u{2192} {} (the one face on the same carrier)",
1271 moved["id"].as_str().unwrap_or(""),
1272 moved["from"].as_str().unwrap_or(""),
1273 moved["to"].as_str().unwrap_or("")
1274 ));
1275 }
1276 for note in report["notes"].as_array().into_iter().flatten() {
1277 ui.weak(note.as_str().unwrap_or(""));
1278 }
1279 } else if !report.is_null() {
1280 let lineage = report["lineage"].as_object().and_then(|m| m.values().next()).and_then(|v| v.as_str()).unwrap_or("none");
1281 let words = match lineage {
1282 "family" => "a member of the same family: mates kept by face name",
1283 "derived" => "a variation of the same part: mates kept by face name",
1284 _ => "an unrelated part: every mate waits for your confirmation",
1285 };
1286 ui.label(format!("Now showing {} ({words}).", report["to"].as_str().unwrap_or("")));
1287 let kept = report["kept"].as_array().map_or(0, Vec::len);
1288 ui.label(format!("{kept} mate(s) kept."));
1289 for note in report["notes"].as_array().into_iter().flatten() {
1290 ui.weak(note.as_str().unwrap_or(""));
1291 }
1292 }
1293 let rows = unresolved.as_array().cloned().unwrap_or_default();
1294 let mut repairs: Vec<(String, String, String)> = Vec::new();
1295 let mut delete: Option<String> = None;
1296 if !rows.is_empty() {
1297 ui.separator();
1298 ui.label(egui::RichText::new(format!("{} mate(s) need a face (held, not solved):", rows.len())).strong());
1299 }
1300 egui::ScrollArea::vertical().max_height(300.0).auto_shrink([false, true]).show(ui, |ui| {
1301 for mate in &rows {
1302 let id = mate["id"].as_str().unwrap_or("").to_string();
1303 ui.horizontal(|ui| {
1304 ui.label(egui::RichText::new(format!("{id} ({})", mate["type"].as_str().unwrap_or(""))).strong());
1305 let delete_button = ui.small_button("\u{2716} Delete mate").on_hover_text("Delete this mate");
1306 self.hits.insert(format!("replace:delete:{id}"), delete_button.rect);
1307 if delete_button.clicked() {
1308 delete = Some(id.clone());
1309 }
1310 });
1311 for row in mate["refs"].as_array().into_iter().flatten() {
1312 let reference = row["ref"].as_str().unwrap_or("").to_string();
1313 let edited = mate["origin"] == "edit";
1314 let reason = match row["reason"].as_str().unwrap_or("") {
1315 "missing" if edited => "gone after the edit, and no single face replaces it",
1316 "missing" => "not on the new part",
1317 "type-changed" => "now a different kind of face",
1318 "moved" => "that corner moved on the new part",
1319 "no-longer-maps" => "no longer fits this mate",
1320 _ => "unrelated part: confirm",
1321 };
1322 ui.indent(format!("ref-{id}-{reference}"), |ui| {
1323 ui.label(format!("{reference}: {reason}"));
1324 let row_key = format!("{id}:{reference}");
1325 if let Some(suggestion) = row["suggestion"]["ref"].as_str() {
1326 let pre = row["suggestion"]["preTicked"].as_bool().unwrap_or(false);
1327 let tick = self.ticks.entry((id.clone(), reference.clone())).or_insert(pre);
1328 let basis = if row["suggestion"]["basis"] == "same-name" { "same name" } else { "nearest" };
1329 let label = format!("Use {suggestion} ({basis}{})", if pre { ", same geometry" } else { "" });
1330 let response = ui.checkbox(tick, label);
1331 self.hits.insert(format!("replace:tick:{row_key}"), response.rect);
1332 }
1333 let picking = self.picking.as_ref() == Some(&(id.clone(), reference.clone()));
1334 if picking {
1335 let selected: Vec<String> = engine
1336 .emphasis
1337 .selected_faces
1338 .iter()
1339 .chain(engine.emphasis.selected_edges.iter())
1340 .cloned()
1341 .collect();
1342 let owner = reference.rsplit_once(':').map(|(path, _)| path).unwrap_or("").to_string();
1346 let usable = selected.len() == 1 && selected[0].starts_with(&format!("{owner}:"));
1347 ui.weak("Select the face or edge to use in the viewport.");
1348 let use_button = ui.add_enabled(usable, egui::Button::new("Use selection"));
1349 self.hits.insert("replace:use-selection".into(), use_button.rect);
1350 if use_button.clicked() {
1351 repairs.push((id.clone(), reference.clone(), selected[0].clone()));
1352 self.picking = None;
1353 }
1354 } else {
1355 let pick = ui.small_button("Pick\u{2026}").on_hover_text("Pick the replacement face in the viewport");
1356 self.hits.insert(format!("replace:pick:{row_key}"), pick.rect);
1357 if pick.clicked() {
1358 self.picking = Some((id.clone(), reference.clone()));
1359 engine.clear_selection();
1360 }
1361 }
1362 });
1363 }
1364 }
1365 });
1366 ui.separator();
1367 ui.horizontal(|ui| {
1368 let ticked: Vec<(String, String, String)> = rows
1369 .iter()
1370 .flat_map(|mate| {
1371 let id = mate["id"].as_str().unwrap_or("").to_string();
1372 mate["refs"].as_array().cloned().unwrap_or_default().into_iter().filter_map(move |row| {
1373 let reference = row["ref"].as_str()?.to_string();
1374 let suggestion = row["suggestion"]["ref"].as_str()?.to_string();
1375 Some((id.clone(), reference, suggestion))
1376 })
1377 })
1378 .filter(|(id, reference, _)| self.ticks.get(&(id.clone(), reference.clone())).copied().unwrap_or(false))
1379 .collect();
1380 let apply = ui.add_enabled(!ticked.is_empty(), egui::Button::new(format!("Apply ticked ({})", ticked.len())));
1381 self.hits.insert("replace:apply-ticked".into(), apply.rect);
1382 if apply.clicked() {
1383 repairs.extend(ticked);
1384 }
1385 let close = ui.button("Close").on_hover_text("Held mates stay disabled and listed; reopen this from the Constraints panel");
1386 self.hits.insert("replace:close".into(), close.rect);
1387 if close.clicked() {
1388 self.summary = false;
1389 }
1390 });
1391 if !self.status.is_empty() {
1392 ui.colored_label(ui.visuals().warn_fg_color, &self.status);
1393 }
1394 if !repairs.is_empty() {
1395 match repair(engine, &repairs) {
1396 Ok(()) => self.status.clear(),
1397 Err(error) => self.status = error,
1398 }
1399 }
1400 if let Some(id) = delete {
1401 if let Err(error) = delete_held(engine, &id) {
1402 self.status = error;
1403 }
1404 }
1405 }
1406}
1407
1408pub static HIT_KEYS: &[HitKeyDoc] = &[
1410 HitKeyDoc { panel: "replace", prefix: "replace:all", meaning: "Replace all instances of the part (checkbox)", command: Some("assembly_replace_component") },
1411 HitKeyDoc { panel: "replace", prefix: "replace:filter", meaning: "filter the candidate list", command: None },
1412 HitKeyDoc { panel: "replace", prefix: "replace:browse", meaning: "list every part file in the store", command: None },
1413 HitKeyDoc { panel: "replace", prefix: "replace:candidate:", meaning: "a replacement candidate (replace:candidate:file:<name> or replace:candidate:part:<entry>)", command: None },
1414 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 },
1415 HitKeyDoc { panel: "replace", prefix: "replace:preview", meaning: "the chosen candidate's picture (shown when its document carries one)", command: None },
1416 HitKeyDoc { panel: "replace", prefix: "replace:apply", meaning: "replace with the chosen candidate", command: Some("assembly_replace_component") },
1417 HitKeyDoc { panel: "replace", prefix: "replace:cancel", meaning: "close the chooser without replacing", command: None },
1418 HitKeyDoc { panel: "replace", prefix: "replace:tick:", meaning: "use the suggested face for an unresolved reference (replace:tick:<mate>:<ref>)", command: None },
1419 HitKeyDoc { panel: "replace", prefix: "replace:pick:", meaning: "pick the replacement face in the viewport (replace:pick:<mate>:<ref>)", command: None },
1420 HitKeyDoc { panel: "replace", prefix: "replace:use-selection", meaning: "use the selected face for the reference being picked", command: Some("assembly_replace_repair") },
1421 HitKeyDoc { panel: "replace", prefix: "replace:delete:", meaning: "delete an unresolved mate (replace:delete:<mate>)", command: Some("assembly_remove_constraint") },
1422 HitKeyDoc { panel: "replace", prefix: "replace:apply-ticked", meaning: "apply every ticked suggestion", command: Some("assembly_replace_accept") },
1423 HitKeyDoc { panel: "replace", prefix: "replace:close", meaning: "close the summary (held mates stay listed)", command: None },
1424];
1425