Skip to main content

brep_app/panels/
replace_component.rs

1//! REPLACE COMPONENT, the app side: what a replacement can be (a stored
2//! part, or a part the assembly already carries), which occurrences a
3//! "Replace all instances" covers, and the one door the menu, the summary and
4//! the automation command share. The engine does the swap and the mate
5//! classification (`brep_render::engine_state::replace_component`).
6
7use 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
13/// The parts-library name a stored source takes: a PLM revision is named
14/// after its part (`sub-b`, from the store's index), not after its key's
15/// last segment, which is the revision label (`B`); a file by its name.
16fn 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/// What the occurrence is replaced WITH.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum ReplaceSource {
27    /// A part document in the store, by its identity (as Insert component
28    /// names it).
29    Stored(String),
30    /// A parts-library entry the assembly already carries.
31    Library(String),
32}
33
34/// Every ACOMP occurrence that shows the same part as `component_id`
35/// (itself included), in history order — the "Replace all instances of
36/// `<part>`" set.
37pub 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
47/// A replace edits the assembly, and a Work part edited in context couples its
48/// part's undo to the assembly's: a part undo after the replace would undo the
49/// REPLACE, and a push meant for the old part would land on whatever the
50/// occurrence shows now. So when the active assembly hosts a Work part
51/// session, the replace first finishes it (Return to assembly: its edits are
52/// kept) and says so. Returns that line, or `None` when no session was open.
53pub 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
65/// What a Pattern Component makes of replacing `seed`: opened on one of its
66/// instances (`instance`: the instance id, its pattern and number), why the
67/// chooser shows the seed; else how many instances follow it. `None` when no
68/// pattern is seeded by it.
69pub 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
90/// How the replace compares identities for LINEAGE: the store's own
91/// spelling, and, in a PLM session, every revision of one PLM part as that
92/// part (`plm-part:<id>`): replacing revision A with revision C of the same
93/// part keeps the mates by name, as a variation of the same part should.
94fn 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
100/// One identity as lineage compares it (see [`lineage_canonicalizer`]).
101fn 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
108/// Replace the part shown by `component_ids` with `source`. One undo step;
109/// the mates' report lands with the run (`EngineState::replace_report`).
110/// Returns the new parts-library entry name.
111pub 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
145// ---------------------------------------------------------------------------
146// Inside a sub-assembly: replace from the top
147// ---------------------------------------------------------------------------
148
149fn 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
155/// Replace a part placed INSIDE a sub-assembly, from the top assembly.
156/// `path` is the occurrence path (`ACOMP3:ACOMP1`: the top occurrence, then
157/// the placement inside its definition, at any depth). The sub-assembly's
158/// definition is edited, so every placement of that sub-assembly follows,
159/// and a Save writes it into the sub-assembly's own file. With
160/// `all_instances`, every placement of the same part in that sub-assembly is
161/// replaced. Mates inside the sub-assembly are held (disabled, marked) when
162/// the parts share no lineage, exactly as at the top; the top assembly's mates
163/// on the replaced part go through the usual classification. One undo step.
164pub 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    // The definition chain down to the sub-assembly that places `target`.
183    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    // The replacement's entry, as the sub-assembly's library holds it.
209    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    // Which placements: the one, or every one of the same part in there.
247    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    // The sub-assembly's own mates on a replaced placement: held when the
269    // parts are not related (the run inside the sub-assembly would otherwise
270    // resolve colliding names), confirmed later in that sub-assembly.
271    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            // Each reference's frame BEFORE the swap (read through the top
291            // assembly, in the top occurrence's frame), kept in the marker:
292            // the top window chooses and ticks suggestions from it later.
293            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    // ONE document edit through the Work part's refresh (every copy of the
311    // sub-assembly definition follows, ancestors rebuild).
312    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    // A Pattern Component of that sub-assembly seeded by a replaced
328    // placement follows it, as at the top: name it, counting the instances
329    // this occurrence of the sub-assembly shows.
330    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
355// ---------------------------------------------------------------------------
356// Held mates at every level: the top window lists, repairs and deletes them
357// ---------------------------------------------------------------------------
358
359/// Every mate a replace is holding, at every depth, as the top window lists
360/// them. A top-level mate is the engine's row. A mate inside a sub-assembly is
361/// listed per occurrence path of that sub-assembly: its id is
362/// `<occurrence path>:<constraint id>` (`ACOMP1:CONC1`) and its refs are
363/// namespaced the same way (`ACOMP1:ACOMP1:Hole_S`), with its suggestion
364/// chosen and ticked from the new part's geometry exactly as at the top.
365pub 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    // A mate held inside a definition is ONE mate however many times that
376    // definition is placed: listed once, at its first placement (a Repair
377    // there rewrites the definition, so every placement takes it). It was
378    // listed once per placement: 18 rows for 9 mates on l500.
379    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
427/// What the Constraints pane says about held mates, `(count, wording)`, kept
428/// in `cache` per history revision. [`held_mates`] clones the whole parts
429/// library and walks every nested definition (~5 ms a frame on l500); the
430/// count and the wording read only the document, never the suggestions that
431/// follow the scene, and every document write moves the revision (undo and
432/// redo included).
433pub 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
452/// Walk a sub-assembly definition placed at `prefix` (its part-name chain
453/// `definition`, `/`-joined), collecting its marked constraints and those of
454/// everything it places, each with the definition it lives in.
455fn 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
481/// The definition chain of the sub-assembly placed at occurrence `path`
482/// (`ACOMP3`, `ACOMP3:ACOMP1`): its part names from the top library down, its
483/// source key, and its document.
484fn 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
509/// Split a held-mate id into (sub-assembly occurrence path, constraint id);
510/// `None` for a top-level mate.
511fn nested_id(id: &str) -> Option<(&str, &str)> {
512    id.rsplit_once(':')
513}
514
515/// Apply edits to nested definitions, one sub-assembly at a time, as ONE
516/// document edit (one undo step) through the Work part's refresh.
517fn 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    // Top-level repairs ride in the same document edit (one undo step).
524    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
544/// Repair held references at any depth, as ONE undo step: `(mate id, old ref,
545/// new ref)` with the ids and refs [`held_mates`] lists. A nested mate's new
546/// ref must lie inside the same sub-assembly. A mate with nothing left held
547/// is re-enabled (if it was enabled before) and loses its marker.
548pub 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
570/// `replace_repair`'s rule on one `assembly` block (also a nested one).
571fn 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
604/// Accept every PRE-TICKED suggestion of the given held mates (every held
605/// mate, at every depth, when `ids` is empty) as one undo step.
606pub 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
630/// Delete a held mate at any depth (one undo step).
631pub 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// ---------------------------------------------------------------------------
652// The chooser: what the occurrence can be replaced WITH
653// ---------------------------------------------------------------------------
654
655/// One offered replacement.
656#[derive(Debug, Clone, PartialEq, Eq)]
657pub struct Candidate {
658    pub source: ReplaceSource,
659    /// What the row says: a family member by its row name (part number), a
660    /// file by its display name, a library entry by its entry name.
661    pub label: String,
662    /// The section it sits in: `revisions` (PLM), `family`, `assembly`,
663    /// `folder`, `recent`, `browse`.
664    pub group: &'static str,
665}
666
667impl Candidate {
668    /// The row's stable key (widget keys, automation).
669    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
677/// Section titles, in the order the chooser shows them.
678pub 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
687/// A listed document that can be a component: anything but a family or a
688/// template (a store may list bare names with no extension at all).
689fn 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
694/// The candidates for replacing `component_id`, best first: other members of
695/// its family (when it is one; by row name), the other parts this assembly
696/// already carries, the part files beside it (its folder, or the PLM
697/// workspace a PLM store lists), recent files. `browse` adds every part file
698/// the store lists. The occurrence's own part is never offered, and neither
699/// is `exclude` (the assembly being edited: it cannot contain itself).
700pub 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    // In a PLM session: the part's OTHER revisions first (newest first), by
726    // label and state. The store lists every indexed revision, loaded or not.
727    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    // Same family: the family's table rows, by part number, beside the
752    // family file (members sit beside their family).
753    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            // The family file is not here (moved, or members copied without
767            // it): the members beside this one still say which family they
768            // are, by their stamp.
769            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    // Parts this assembly already carries.
783    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    // Beside it: the same folder (or the whole PLM workspace).
793    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
814/// Whether `candidate` matches what the user typed: its label, its store
815/// name, and in a PLM session the part number and name the index holds for
816/// it. Case-insensitive; an empty filter matches everything.
817pub 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
841/// The chooser's list for what was typed: the usual sections when nothing
842/// is, and EVERY part the store lists (other folders, the whole PLM index)
843/// narrowed to the matches once something is, with no Browse click.
844pub 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/// How long the chooser trusts a store listing (or a document it found
861/// missing) it read at one store generation: a file copied in outside the
862/// app shows within this, without a re-read on every keystroke or frame.
863const SEARCH_LISTING_TTL: f64 = 1.0;
864
865/// What replacing `component_id` with `candidate` will do, BEFORE it is done.
866#[derive(Debug, Clone, PartialEq)]
867pub struct Preview {
868    /// `family` / `derived` (mates keep their faces by name), `None` for an
869    /// unrelated part (every mate waits for confirmation).
870    pub lineage: Option<&'static str>,
871    /// How many mates touch the occurrence.
872    pub mates: usize,
873    /// The candidate's own document, for its picture (`None` while loading).
874    pub document: Option<serde_json::Value>,
875}
876
877impl Preview {
878    /// The one line the chooser shows.
879    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
889/// The preview for `candidate` (lineage against the occurrence's current
890/// part, the mates it touches, the candidate's document). `None` for an
891/// unknown component.
892pub 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// ---------------------------------------------------------------------------
929// The dialog: choose, then the summary (kept / unresolved, repair, delete)
930// ---------------------------------------------------------------------------
931
932/// The Replace component window. `open` it on an occurrence; it shows the
933/// chooser, runs the replace, then the summary of what happened to the mates
934/// until the user closes it. Reopened with no target ([`Self::review`]) it is
935/// the summary alone, for mates still held from an earlier replace.
936#[derive(Default)]
937pub struct ReplaceDialog {
938    target: Option<String>,
939    /// The document being edited (never offered as its own component).
940    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    /// After the replace: the summary is up.
948    summary: bool,
949    /// User's suggestion ticks, `(constraint, ref)` → ticked; absent = the
950    /// suggestion's own `preTicked`.
951    ticks: std::collections::BTreeMap<(String, String), bool>,
952    /// The repair pick running in the result window, `(mate, ref)`: a mirror
953    /// of the engine's [`RefSelectTarget::ReplaceRepair`] pick, refreshed each
954    /// frame the window draws, for the automation state.
955    picking: Option<(String, String)>,
956    /// The chosen candidate is still loading (a PLM revision listed by the
957    /// index, not read yet): the replace runs the frame it arrives.
958    waiting: Option<usize>,
959    /// The filter the candidate list was last built for.
960    searched: String,
961    /// The chosen candidate's preview and picture, for the key it was made for.
962    preview: Option<(String, Preview, Option<egui::TextureHandle>)>,
963    /// When a preview found no document it could load (missing, unreadable,
964    /// not JSON): the store generation and time it looked. It looks again
965    /// only once the store changed or a second passed, not every frame.
966    preview_missing: Option<(u64, f64)>,
967    /// Every part the store lists for the search, read once per store
968    /// generation (at most once a second for changes made outside the app),
969    /// not on every keystroke: `(target, document, generation, time)`.
970    searchable: Option<((String, Option<String>, u64, f64), Vec<Candidate>)>,
971    /// What a Pattern Component makes of this replace (see [`pattern_line`]).
972    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    /// Open the chooser on `component_id`.
983    pub fn open(&mut self, engine: &EngineState, store: &dyn ModelStore, component_id: &str, document: Option<&str>) {
984        *self = Self::default();
985        // A Pattern Component instance shows its seed's part and has no
986        // feature to replace: the chooser opens on the seed and says so.
987        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    /// Open the summary alone (mates held by an earlier replace).
1002    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    /// The shell's Escape: close the chooser. `true` when it took the key.
1012    /// A repair pick is the engine's reference pick, which the shell's own
1013    /// Escape cancels next; the result window (summary) stays: held mates are
1014    /// not something to dismiss by a stray key.
1015    pub fn escape(&mut self) -> bool {
1016        if self.target.is_some() {
1017            self.close();
1018            return true;
1019        }
1020        false
1021    }
1022
1023    /// Replace with candidate `index` (the chooser's Replace button, and the
1024    /// automation door). Opens the summary. A candidate still loading is
1025    /// asked for and waited on (`waiting`), not refused.
1026    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    /// The automation state of the open window.
1045    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        // Published on every frame, a closed window included: closed by
1082        // Escape, by its x or by a replace, the blob must say so rather than
1083        // keep the last open frame's state.
1084        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        // A chosen candidate that was still loading: replace when it lands.
1092        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        // The keyboard, read BEFORE the filter field draws: a focused text
1124        // field swallows Escape and the arrows, and the shell leaves every key
1125        // to a focused field, so read later nobody would see them. Arrows move
1126        // through the shown rows, Enter replaces, Escape closes.
1127        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        // Typing searches every part the store lists (no Browse click).
1161        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        // The chosen candidate's preview: its picture and what will happen
1226        // to the mates.
1227        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            // A document still loading is looked for every frame (the store
1232            // answers from memory); one that is not there, again only once
1233            // the store changed or a second passed.
1234            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        // Enter takes the chosen row, or the top hit of what was typed.
1276        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        // A finished repair pick: it must lie in the same occurrence as the
1339        // reference it replaces — the ref minus its entity name
1340        // (`ACOMP1:ACOMP1:Hole_S` → `ACOMP1:ACOMP1`).
1341        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                            // The standard reference pick runs for this row:
1392                            // a face or edge clicked in the view finishes it
1393                            // (one pick) and the repair lands next frame.
1394                            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                                // Claimed the frame it opens, too: the shell
1411                                // finishes an in-form pick no form claims.
1412                                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
1461/// The hit keys this window publishes (see `automation::hit_keys`).
1462pub 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