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/// What replacing `component_id` with `candidate` will do, BEFORE it is done.
861#[derive(Debug, Clone, PartialEq)]
862pub struct Preview {
863    /// `family` / `derived` (mates keep their faces by name), `None` for an
864    /// unrelated part (every mate waits for confirmation).
865    pub lineage: Option<&'static str>,
866    /// How many mates touch the occurrence.
867    pub mates: usize,
868    /// The candidate's own document, for its picture (`None` while loading).
869    pub document: Option<serde_json::Value>,
870}
871
872impl Preview {
873    /// The one line the chooser shows.
874    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
884/// The preview for `candidate` (lineage against the occurrence's current
885/// part, the mates it touches, the candidate's document). `None` for an
886/// unknown component.
887pub 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// ---------------------------------------------------------------------------
924// The dialog: choose, then the summary (kept / unresolved, repair, delete)
925// ---------------------------------------------------------------------------
926
927/// The Replace component window. `open` it on an occurrence; it shows the
928/// chooser, runs the replace, then the summary of what happened to the mates
929/// until the user closes it. Reopened with no target ([`Self::review`]) it is
930/// the summary alone, for mates still held from an earlier replace.
931#[derive(Default)]
932pub struct ReplaceDialog {
933    target: Option<String>,
934    /// The document being edited (never offered as its own component).
935    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    /// After the replace: the summary is up.
943    summary: bool,
944    /// User's suggestion ticks, `(constraint, ref)` → ticked; absent = the
945    /// suggestion's own `preTicked`.
946    ticks: std::collections::BTreeMap<(String, String), bool>,
947    /// The repair pick running in the result window, `(mate, ref)`: a mirror
948    /// of the engine's [`RefSelectTarget::ReplaceRepair`] pick, refreshed each
949    /// frame the window draws, for the automation state.
950    picking: Option<(String, String)>,
951    /// The chosen candidate is still loading (a PLM revision listed by the
952    /// index, not read yet): the replace runs the frame it arrives.
953    waiting: Option<usize>,
954    /// The filter the candidate list was last built for.
955    searched: String,
956    /// The chosen candidate's preview and picture, for the key it was made for.
957    preview: Option<(String, Preview, Option<egui::TextureHandle>)>,
958    /// What a Pattern Component makes of this replace (see [`pattern_line`]).
959    pattern: Option<String>,
960    status: String,
961    hits: std::collections::HashMap<String, egui::Rect>,
962}
963
964impl ReplaceDialog {
965    pub fn is_open(&self) -> bool {
966        self.target.is_some() || self.summary
967    }
968
969    /// Open the chooser on `component_id`.
970    pub fn open(&mut self, engine: &EngineState, store: &dyn ModelStore, component_id: &str, document: Option<&str>) {
971        *self = Self::default();
972        // A Pattern Component instance shows its seed's part and has no
973        // feature to replace: the chooser opens on the seed and says so.
974        let (component_id, instance) = match brep_render::brep_kernel::pattern_component_instance(component_id) {
975            Some((pattern, seed, n)) => (seed, Some((component_id.to_string(), pattern, n))),
976            None => (component_id.to_string(), None),
977        };
978        let component_id = component_id.as_str();
979        self.pattern = pattern_line(engine, component_id, instance.as_ref());
980        let Some(info) = engine.component_info(component_id) else { return };
981        self.part = info.part_name;
982        self.instances = instances_of(engine, component_id);
983        self.target = Some(component_id.to_string());
984        self.document = document.map(str::to_string);
985        self.candidates = candidates(engine, store, component_id, false, document);
986    }
987
988    /// Open the summary alone (mates held by an earlier replace).
989    pub fn review(&mut self) {
990        *self = Self::default();
991        self.summary = true;
992    }
993
994    pub fn close(&mut self) {
995        *self = Self::default();
996    }
997
998    /// The shell's Escape: close the chooser. `true` when it took the key.
999    /// A repair pick is the engine's reference pick, which the shell's own
1000    /// Escape cancels next; the result window (summary) stays: held mates are
1001    /// not something to dismiss by a stray key.
1002    pub fn escape(&mut self) -> bool {
1003        if self.target.is_some() {
1004            self.close();
1005            return true;
1006        }
1007        false
1008    }
1009
1010    /// Replace with candidate `index` (the chooser's Replace button, and the
1011    /// automation door). Opens the summary. A candidate still loading is
1012    /// asked for and waited on (`waiting`), not refused.
1013    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    /// The automation state of the open window.
1032    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        // Published on every frame, a closed window included: closed by
1069        // Escape, by its x or by a replace, the blob must say so rather than
1070        // keep the last open frame's state.
1071        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        // A chosen candidate that was still loading: replace when it lands.
1079        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        // The keyboard, read BEFORE the filter field draws: a focused text
1111        // field swallows Escape and the arrows, and the shell leaves every key
1112        // to a focused field, so read later nobody would see them. Arrows move
1113        // through the shown rows, Enter replaces, Escape closes.
1114        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        // Typing searches every part the store lists (no Browse click).
1148        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        // The chosen candidate's preview: its picture and what will happen
1199        // to the mates.
1200        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        // Enter takes the chosen row, or the top hit of what was typed.
1233        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        // A finished repair pick: it must lie in the same occurrence as the
1296        // reference it replaces — the ref minus its entity name
1297        // (`ACOMP1:ACOMP1:Hole_S` → `ACOMP1:ACOMP1`).
1298        if let Some((id, reference, picked)) = engine.take_replace_repair_pick() {
1299            let owner = reference.rsplit_once(':').map(|(path, _)| path).unwrap_or("").to_string();
1300            if picked.starts_with(&format!("{owner}:")) {
1301                repairs.push((id, reference, picked));
1302            } else {
1303                self.status = format!("Pick a face or edge of {owner} for {id}: {picked} is on another part");
1304            }
1305        }
1306        let running = engine.ref_select_field().filter(|f| f.target == RefSelectTarget::ReplaceRepair);
1307        self.picking = running.as_ref().map(|f| (f.subject.clone(), f.path.first().cloned().unwrap_or_default()));
1308        let mut delete: Option<String> = None;
1309        if !rows.is_empty() {
1310            ui.separator();
1311            ui.label(egui::RichText::new(format!("{} mate(s) need a face (held, not solved):", rows.len())).strong());
1312        }
1313        egui::ScrollArea::vertical().max_height(300.0).auto_shrink([false, true]).show(ui, |ui| {
1314            for mate in &rows {
1315                let id = mate["id"].as_str().unwrap_or("").to_string();
1316                ui.horizontal(|ui| {
1317                    ui.label(egui::RichText::new(format!("{id} ({})", mate["type"].as_str().unwrap_or(""))).strong());
1318                    let delete_button = ui.small_button("\u{2716} Delete mate").on_hover_text("Delete this mate");
1319                    self.hits.insert(format!("replace:delete:{id}"), delete_button.rect);
1320                    if delete_button.clicked() {
1321                        delete = Some(id.clone());
1322                    }
1323                });
1324                for row in mate["refs"].as_array().into_iter().flatten() {
1325                    let reference = row["ref"].as_str().unwrap_or("").to_string();
1326                    let edited = mate["origin"] == "edit";
1327                    let reason = match row["reason"].as_str().unwrap_or("") {
1328                        "missing" if edited => "gone after the edit, and no single face replaces it",
1329                        "missing" => "not on the new part",
1330                        "type-changed" => "now a different kind of face",
1331                        "moved" => "that corner moved on the new part",
1332                        "no-longer-maps" => "no longer fits this mate",
1333                        _ => "unrelated part: confirm",
1334                    };
1335                    ui.indent(format!("ref-{id}-{reference}"), |ui| {
1336                        ui.label(format!("{reference}: {reason}"));
1337                        let row_key = format!("{id}:{reference}");
1338                        if let Some(suggestion) = row["suggestion"]["ref"].as_str() {
1339                            let pre = row["suggestion"]["preTicked"].as_bool().unwrap_or(false);
1340                            let tick = self.ticks.entry((id.clone(), reference.clone())).or_insert(pre);
1341                            let basis = if row["suggestion"]["basis"] == "same-name" { "same name" } else { "nearest" };
1342                            let label = format!("Use {suggestion} ({basis}{})", if pre { ", same geometry" } else { "" });
1343                            let response = ui.checkbox(tick, label);
1344                            self.hits.insert(format!("replace:tick:{row_key}"), response.rect);
1345                        }
1346                        let picking = self.picking.as_ref() == Some(&(id.clone(), reference.clone()));
1347                        if picking {
1348                            // The standard reference pick runs for this row:
1349                            // a face or edge clicked in the view finishes it
1350                            // (one pick) and the repair lands next frame.
1351                            engine.ref_select_claim();
1352                            ui.weak("Click the face or edge to use in the viewport.");
1353                            let cancel = ui.small_button("Cancel pick");
1354                            self.hits.insert(format!("replace:cancel-pick:{row_key}"), cancel.rect);
1355                            if cancel.clicked() {
1356                                engine.cancel_ref_select();
1357                            }
1358                        } else {
1359                            let pick = ui.small_button("Pick\u{2026}").on_hover_text("Pick the replacement face or edge in the viewport");
1360                            self.hits.insert(format!("replace:pick:{row_key}"), pick.rect);
1361                            if pick.clicked() {
1362                                if engine.ref_select_active() {
1363                                    engine.finish_ref_select();
1364                                }
1365                                self.status.clear();
1366                                engine.begin_ref_select_for_replace_repair(&id, &reference);
1367                                // Claimed the frame it opens, too: the shell
1368                                // finishes an in-form pick no form claims.
1369                                engine.ref_select_claim();
1370                            }
1371                        }
1372                    });
1373                }
1374            }
1375        });
1376        ui.separator();
1377        ui.horizontal(|ui| {
1378            let ticked: Vec<(String, String, String)> = rows
1379                .iter()
1380                .flat_map(|mate| {
1381                    let id = mate["id"].as_str().unwrap_or("").to_string();
1382                    mate["refs"].as_array().cloned().unwrap_or_default().into_iter().filter_map(move |row| {
1383                        let reference = row["ref"].as_str()?.to_string();
1384                        let suggestion = row["suggestion"]["ref"].as_str()?.to_string();
1385                        Some((id.clone(), reference, suggestion))
1386                    })
1387                })
1388                .filter(|(id, reference, _)| self.ticks.get(&(id.clone(), reference.clone())).copied().unwrap_or(false))
1389                .collect();
1390            let apply = ui.add_enabled(!ticked.is_empty(), egui::Button::new(format!("Apply ticked ({})", ticked.len())));
1391            self.hits.insert("replace:apply-ticked".into(), apply.rect);
1392            if apply.clicked() {
1393                repairs.extend(ticked);
1394            }
1395            let close = ui.button("Close").on_hover_text("Held mates stay disabled and listed; reopen this from the Constraints panel");
1396            self.hits.insert("replace:close".into(), close.rect);
1397            if close.clicked() {
1398                self.summary = false;
1399            }
1400        });
1401        if !self.status.is_empty() {
1402            ui.colored_label(ui.visuals().warn_fg_color, &self.status);
1403        }
1404        if !repairs.is_empty() {
1405            match repair(engine, &repairs) {
1406                Ok(()) => self.status.clear(),
1407                Err(error) => self.status = error,
1408            }
1409        }
1410        if let Some(id) = delete {
1411            if let Err(error) = delete_held(engine, &id) {
1412                self.status = error;
1413            }
1414        }
1415    }
1416}
1417
1418/// The hit keys this window publishes (see `automation::hit_keys`).
1419pub static HIT_KEYS: &[HitKeyDoc] = &[
1420    HitKeyDoc { panel: "replace", prefix: "replace:all", meaning: "Replace all instances of the part (checkbox)", command: Some("assembly_replace_component") },
1421    HitKeyDoc { panel: "replace", prefix: "replace:filter", meaning: "filter the candidate list", command: None },
1422    HitKeyDoc { panel: "replace", prefix: "replace:browse", meaning: "list every part file in the store", command: None },
1423    HitKeyDoc { panel: "replace", prefix: "replace:candidate:", meaning: "a replacement candidate (replace:candidate:file:<name> or replace:candidate:part:<entry>)", command: None },
1424    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 },
1425    HitKeyDoc { panel: "replace", prefix: "replace:preview", meaning: "the chosen candidate's picture (shown when its document carries one)", command: None },
1426    HitKeyDoc { panel: "replace", prefix: "replace:apply", meaning: "replace with the chosen candidate", command: Some("assembly_replace_component") },
1427    HitKeyDoc { panel: "replace", prefix: "replace:cancel", meaning: "close the chooser without replacing", command: None },
1428    HitKeyDoc { panel: "replace", prefix: "replace:tick:", meaning: "use the suggested face for an unresolved reference (replace:tick:<mate>:<ref>)", command: None },
1429    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") },
1430    HitKeyDoc { panel: "replace", prefix: "replace:cancel-pick:", meaning: "cancel the running repair pick (replace:cancel-pick:<mate>:<ref>)", command: None },
1431    HitKeyDoc { panel: "replace", prefix: "replace:delete:", meaning: "delete an unresolved mate (replace:delete:<mate>)", command: Some("assembly_remove_constraint") },
1432    HitKeyDoc { panel: "replace", prefix: "replace:apply-ticked", meaning: "apply every ticked suggestion", command: Some("assembly_replace_accept") },
1433    HitKeyDoc { panel: "replace", prefix: "replace:close", meaning: "close the summary (held mates stay listed)", command: None },
1434];
1435