Skip to main content

brep_render/engine_state/
replace_component.rs

1//! REPLACE COMPONENT: swap the part an ACOMP occurrence (or every occurrence
2//! of a part) shows, keeping the occurrence — its id, pose, fixed flag and
3//! attributes — and every mate that still has something to hold on to.
4//!
5//! # Why the mates can survive at all
6//!
7//! A mate references `ACOMP<n>:<part entity name>` (see the kernel's
8//! `assembly::mapping` module doc). The replace keeps the ACOMP id and swaps
9//! only `inputParams.partName`, so every reference re-resolves by NAME
10//! against the new part on the next run. The names come from the part's own
11//! feature ids (`Part_PZ`, `Hole1_S`, split pieces `_1`, `_2`), so a family
12//! member (the family's document with other expression values) and a part
13//! saved as a variation and then edited carry the same names for the same
14//! faces.
15//!
16//! # Lineage: when a name match is trusted
17//!
18//! Two unrelated parts can both have an `Extrude1_top`, so a name match alone
19//! proves nothing. The match is trusted automatically only when the two
20//! parts share LINEAGE ([`lineage_keys`](crate::engine_state::replace_component::lineage_keys)): the same source, members of one
21//! family (`familySource.family`), or one saved from the other
22//! ([`DERIVED_FROM_KEY`](crate::engine_state::replace_component::DERIVED_FROM_KEY), the chain Save As writes). Even then, an entity
23//! whose geometry class changed (a plane that became a cylinder) is not kept.
24//!
25//! Without shared lineage, every healthy mate on the replaced occurrences is
26//! written DISABLED before the run, with a `replace` marker. Nothing resolves
27//! against the new part and nothing moves until the user confirms. The
28//! summary offers the same-name entity (pre-ticked when its geometry agrees
29//! with the old frame) or the nearest entity of the same class. A suggestion
30//! is never applied silently.
31//!
32//! # The marker and the report
33//!
34//! An unresolved mate is KEPT, disabled, with
35//! `persistentData.replace = {from, to, wasEnabled, refs: [{ref, reason,
36//! suggestion}]}` (it rides save/reopen). [`EngineState::replace_repair`]
37//! points a reference at a picked entity and, once nothing is left
38//! unresolved, re-enables the mate (if it was enabled before) and drops the
39//! marker. Deleting goes through the ordinary constraint delete.
40//!
41//! # Part edits
42//!
43//! A PART EDIT (Update components, a Work part push) is the same check with
44//! the part replaced by its own next version: [`EngineState::commit_part_edit`]
45//! adopts the edited document and classifies the mates of every occurrence of
46//! the edited parts once its run lands, with lineage trusted. One rule differs:
47//! a reference whose name is gone (or now names another class of geometry) is
48//! RE-ATTACHED automatically when exactly one entity of the edited part lies on
49//! the same carrier ([`geometry_agrees`](crate::engine_state::replace_component::geometry_agrees)); the report lists each one, old ref →
50//! new ref. Anything else is held with the same marker (`origin: "edit"`), so
51//! the Repair pick and Accept work unchanged. A replace never auto-attaches.
52//!
53//! The replace itself is ONE undo step: one document edit (library block +
54//! partName + disabled mates), adopted with one checkpoint. The
55//! classification runs once that edit's run has landed
56//! (`EngineState::finish_component_replace`, from `pump`), and its marker
57//! write is silent (no second undo entry).
58
59use super::*;
60use serde_json::{json, Value};
61use std::borrow::Borrow;
62use std::collections::{BTreeMap, BTreeSet};
63
64/// The top-level document key Save As appends the source's identity to.
65pub const DERIVED_FROM_KEY: &str = "derivedFrom";
66
67/// The persistentData key an unresolved mate carries.
68pub const REPLACE_MARKER: &str = "replace";
69
70/// Statuses that mean a mate was already broken before the replace (it stays
71/// out of the summary and is left as it was).
72const BROKEN: [&str; 3] = ["invalid-selection", "unsupported-selection", "error"];
73
74/// `BROKEN`, for callers outside this module.
75pub const BROKEN_STATUSES: [&str; 3] = BROKEN;
76
77/// The lineage keys of a part: its own source identity, every identity it
78/// was saved from (`derivedFrom`) and its family (`family:<file>`), each
79/// passed through `canonicalize` (stores spell one file several ways). Two
80/// parts share lineage when their key sets intersect.
81pub fn lineage_keys(
82    document: &Value,
83    identity: &str,
84    canonicalize: &dyn Fn(&str) -> String,
85) -> BTreeSet<String> {
86    let mut keys = BTreeSet::new();
87    if !identity.trim().is_empty() {
88        keys.insert(canonicalize(identity.trim()));
89    }
90    for entry in document[DERIVED_FROM_KEY].as_array().into_iter().flatten() {
91        if let Some(entry) = entry.as_str().map(str::trim).filter(|e| !e.is_empty()) {
92            // A family entry is stored already keyed (`family:<file>`).
93            if entry.starts_with("family:") {
94                keys.insert(entry.to_string());
95            } else {
96                keys.insert(canonicalize(entry));
97            }
98        }
99    }
100    if let Some(family) = family_key(document) {
101        keys.insert(family);
102    }
103    keys
104}
105
106/// `family:<file>` for a family member (its `familySource.family`).
107pub fn family_key(document: &Value) -> Option<String> {
108    document["familySource"]["family"]
109        .as_str()
110        .map(str::trim)
111        .filter(|family| !family.is_empty())
112        .map(|family| format!("family:{family}"))
113}
114
115/// How two parts are related, or `None` when they share nothing.
116pub fn shared_lineage(old: &BTreeSet<String>, new: &BTreeSet<String>) -> Option<&'static str> {
117    let common: Vec<&String> = old.intersection(new).collect();
118    if common.is_empty() {
119        None
120    } else if common.iter().any(|key| !key.starts_with("family:")) {
121        // One saved from the other (or the same source): the closer relation.
122        Some("derived")
123    } else {
124        Some("family")
125    }
126}
127
128/// Append `source_identity` (and, for a family member, its family) to the
129/// document's [`DERIVED_FROM_KEY`] chain — what Save As writes so a copy
130/// edited later is still recognised as a variation of its original. Keeps
131/// the existing chain, no duplicates.
132pub fn stamp_derived_from(document: &mut Value, source_identity: &str) {
133    let Some(object) = document.as_object_mut() else { return };
134    let family = object
135        .get("familySource")
136        .and_then(|source| source.get("family"))
137        .and_then(Value::as_str)
138        .map(str::trim)
139        .filter(|family| !family.is_empty())
140        .map(|family| format!("family:{family}"));
141    let chain = object
142        .entry(DERIVED_FROM_KEY)
143        .or_insert_with(|| Value::Array(Vec::new()));
144    if !chain.is_array() {
145        *chain = Value::Array(Vec::new());
146    }
147    let list = chain.as_array_mut().expect("array");
148    for entry in [Some(source_identity.trim().to_string()), family].into_iter().flatten() {
149        if !entry.is_empty() && !list.iter().any(|e| e.as_str() == Some(&entry)) {
150            list.push(Value::String(entry));
151        }
152    }
153}
154
155// ---------------------------------------------------------------------------
156// Geometry comparison (rows from the kernel's assembly_resolve_refs_json /
157// assembly_component_entities_json: {geom, point, direction, radius}, all in
158// the component's local frame)
159// ---------------------------------------------------------------------------
160
161fn triple(value: &Value) -> Option<[f64; 3]> {
162    let list = value.as_array()?;
163    if list.len() != 3 {
164        return None;
165    }
166    Some([list[0].as_f64()?, list[1].as_f64()?, list[2].as_f64()?])
167}
168
169use crate::geometry3d::sub3 as sub;
170
171use crate::geometry3d::dot3 as dot;
172
173use crate::geometry3d::len3 as norm;
174
175/// Distance from `p` to the line through `origin` along unit `direction`.
176fn line_distance(p: [f64; 3], origin: [f64; 3], direction: [f64; 3]) -> f64 {
177    let d = sub(p, origin);
178    let along = dot(d, direction);
179    (dot(d, d) - along * along).max(0.0).sqrt()
180}
181
182/// Relative length tolerance for "the same carrier / the same point": the
183/// kernel's own vertex-resolution tolerance (`VERTEX_SNAP_TOLERANCE`, 1e-6 of
184/// the solid's scale), so a corner rebuilt through another boolean path,
185/// a few ulps off, still counts as the same point.
186const POSITION_TOLERANCE: f64 = 1e-6;
187const ANGLE_TOLERANCE: f64 = 1e-9;
188
189/// Whether `new` is the same analytic carrier as `old`: the same class, and
190/// the same plane / axis / circle / line / point / sphere within tolerance
191/// (a plane must keep its sense: a flipped face is a different face). This is
192/// what a mate actually resolves against, so a split face's piece agrees.
193pub fn geometry_agrees(old: &Value, new: &Value) -> bool {
194    if old["geom"] != new["geom"] {
195        return false;
196    }
197    let (Some(p_old), Some(p_new)) = (triple(&old["point"]), triple(&new["point"])) else {
198        return false;
199    };
200    let directions = (triple(&old["direction"]), triple(&new["direction"]));
201    let radius_agrees = match (old["radius"].as_f64(), new["radius"].as_f64()) {
202        (Some(a), Some(b)) => (a - b).abs() <= POSITION_TOLERANCE * (1.0 + a.abs()),
203        (None, None) => true,
204        _ => false,
205    };
206    let scale = POSITION_TOLERANCE * (1.0 + norm(p_old));
207    match old["geom"].as_str().unwrap_or("") {
208        "plane" => match directions {
209            (Some(n_old), Some(n_new)) => {
210                dot(n_old, n_new) >= 1.0 - ANGLE_TOLERANCE
211                    && dot(sub(p_old, p_new), n_new).abs() <= scale
212            }
213            _ => false,
214        },
215        "axis" | "line" => match directions {
216            (Some(d_old), Some(d_new)) => {
217                dot(d_old, d_new).abs() >= 1.0 - ANGLE_TOLERANCE
218                    && line_distance(p_old, p_new, d_new) <= scale
219                    && radius_agrees
220            }
221            _ => false,
222        },
223        "circle" => match directions {
224            (Some(d_old), Some(d_new)) => {
225                dot(d_old, d_new).abs() >= 1.0 - ANGLE_TOLERANCE
226                    && norm(sub(p_old, p_new)) <= scale
227                    && radius_agrees
228            }
229            _ => false,
230        },
231        "sphere" | "point" => norm(sub(p_old, p_new)) <= scale && radius_agrees,
232        _ => false,
233    }
234}
235
236/// How far `new` sits from `old` for ranking suggestions of one class: the
237/// anchor distance plus a direction penalty (a unit of misalignment weighs
238/// like 1000 length units, so a parallel face always beats a tilted one).
239fn suggestion_score(old: &Value, new: &Value) -> f64 {
240    let distance = match (triple(&old["point"]), triple(&new["point"])) {
241        (Some(a), Some(b)) => norm(sub(a, b)),
242        _ => f64::INFINITY,
243    };
244    let misalignment = match (triple(&old["direction"]), triple(&new["direction"])) {
245        (Some(a), Some(b)) => {
246            if old["geom"] == "plane" {
247                1.0 - dot(a, b)
248            } else {
249                1.0 - dot(a, b).abs()
250            }
251        }
252        _ => 0.0,
253    };
254    distance + 1000.0 * misalignment
255}
256
257/// The part-local name of a namespaced ref (`ACOMP2:Part_PZ` → `Part_PZ`).
258fn local_name<'a>(component: &str, reference: &'a str) -> &'a str {
259    reference
260        .strip_prefix(component)
261        .and_then(|rest| rest.strip_prefix(':'))
262        .unwrap_or(reference)
263}
264
265/// What the replace concluded about one reference.
266#[derive(Debug, Clone, PartialEq)]
267pub enum RefOutcome {
268    /// Shared lineage, the name exists, the class is unchanged.
269    Kept { split: bool },
270    /// Needs the user: `reason` is `missing` (no entity of that name),
271    /// `type-changed` (the name now names another class of geometry) or
272    /// `unconfirmed` (no shared lineage). `suggestion` is a namespaced ref.
273    Unresolved {
274        reason: &'static str,
275        suggestion: Option<Suggestion>,
276    },
277}
278
279#[derive(Debug, Clone, PartialEq)]
280pub struct Suggestion {
281    pub reference: String,
282    /// `same-name` or `nearest`.
283    pub basis: &'static str,
284    /// The geometry agrees with the old frame: shown ticked, still applied
285    /// only on the user's confirmation.
286    pub pre_ticked: bool,
287}
288
289impl Suggestion {
290    fn to_json(&self) -> Value {
291        json!({ "ref": self.reference, "basis": self.basis, "preTicked": self.pre_ticked })
292    }
293}
294
295/// Classify one reference of a replaced component. `pre` is the reference's
296/// resolution BEFORE the replace (the kernel row, `ok: true`); `candidates`
297/// the new part's entities; `old_names` the old part's local entity names
298/// (for the split note); `trusted` = shared lineage.
299pub fn classify_ref<V: std::borrow::Borrow<Value>>(
300    component: &str,
301    reference: &str,
302    pre: &Value,
303    candidates: &[V],
304    old_names: &BTreeSet<String>,
305    trusted: bool,
306) -> RefOutcome {
307    let name = local_name(component, reference);
308    let by_name = candidates
309        .iter()
310        .map(Borrow::borrow)
311        .find(|candidate| candidate["ref"].as_str().map(|r| local_name(component, r)) == Some(name));
312    if trusted {
313        match by_name {
314            Some(candidate) if candidate["geom"] == pre["geom"] => {
315                let split_piece = format!("{name}_1");
316                let split = !old_names.contains(&split_piece)
317                    && candidates.iter().map(Borrow::borrow).any(|c: &Value| {
318                        c["ref"].as_str().map(|r| local_name(component, r)) == Some(split_piece.as_str())
319                    });
320                return RefOutcome::Kept { split };
321            }
322            Some(_) => {
323                return RefOutcome::Unresolved {
324                    reason: "type-changed",
325                    suggestion: nearest(pre, candidates),
326                }
327            }
328            None => {
329                return RefOutcome::Unresolved { reason: "missing", suggestion: nearest(pre, candidates) }
330            }
331        }
332    }
333    let suggestion = match by_name {
334        Some(candidate) if candidate["geom"] == pre["geom"] => Some(Suggestion {
335            reference: candidate["ref"].as_str().unwrap_or_default().to_string(),
336            basis: "same-name",
337            pre_ticked: geometry_agrees(pre, candidate),
338        }),
339        _ => nearest(pre, candidates),
340    };
341    RefOutcome::Unresolved { reason: "unconfirmed", suggestion }
342}
343
344/// Classify a VERTEX reference (`{solid}@x,y,z`, a position in the
345/// component's frame): kept only when, with shared lineage, the new part has
346/// a vertex at exactly the old point (the resolver snaps within a tolerance,
347/// so the point is compared, never trusted). Otherwise unresolved; the same
348/// ref is offered when it resolves, pre-ticked when it is the same point.
349pub fn classify_vertex_ref(reference: &str, pre: &Value, post: Option<&Value>, trusted: bool) -> RefOutcome {
350    let found = post.filter(|row| row["ok"] == true);
351    let same_point = found.is_some_and(|row| geometry_agrees(pre, row));
352    if trusted && same_point {
353        return RefOutcome::Kept { split: false };
354    }
355    let suggestion = found.map(|_| Suggestion {
356        reference: reference.to_string(),
357        basis: "same-name",
358        pre_ticked: same_point,
359    });
360    let reason = match (trusted, found) {
361        (false, _) => "unconfirmed",
362        (true, Some(_)) => "moved",
363        (true, None) => "missing",
364    };
365    RefOutcome::Unresolved { reason, suggestion }
366}
367
368/// The nearest entity of the same geometry class, pre-ticked only when it
369/// IS the old carrier (same plane / axis / …).
370fn nearest<V: std::borrow::Borrow<Value>>(pre: &Value, candidates: &[V]) -> Option<Suggestion> {
371    candidates
372        .iter()
373        .map(Borrow::borrow)
374        .filter(|candidate| candidate["geom"] == pre["geom"])
375        .map(|candidate| (suggestion_score(pre, candidate), candidate))
376        .filter(|(score, _)| score.is_finite())
377        .min_by(|a, b| a.0.total_cmp(&b.0))
378        .map(|(_, candidate)| Suggestion {
379            reference: candidate["ref"].as_str().unwrap_or_default().to_string(),
380            basis: "nearest",
381            pre_ticked: geometry_agrees(pre, candidate),
382        })
383}
384
385/// The marker an unresolved mate carries (`persistentData.replace`). `origin`
386/// is `replace` or `edit`; a marker saved before origins existed has none and
387/// reads as `replace`.
388pub fn marker_json(origin: &str, from: &str, to: &str, was_enabled: bool, refs: Vec<Value>) -> Value {
389    json!({
390        "origin": origin,
391        "from": from,
392        "to": to,
393        "wasEnabled": was_enabled,
394        "refs": refs,
395    })
396}
397
398/// The one entity of `candidates` on the same carrier as `pre`
399/// ([`geometry_agrees`]), or `None` when there is none or more than one: a
400/// part edit re-attaches only what is unambiguous.
401fn unique_carrier(pre: &Value, candidates: &[&Value]) -> Option<String> {
402    let mut agreeing = candidates.iter().filter(|candidate| geometry_agrees(pre, candidate));
403    let only = agreeing.next()?;
404    if agreeing.next().is_some() {
405        return None;
406    }
407    only["ref"].as_str().map(str::to_string)
408}
409
410// ---------------------------------------------------------------------------
411// The engine flow
412// ---------------------------------------------------------------------------
413
414/// One mate touching a replaced component, as it stood before the swap.
415#[derive(Debug, Clone)]
416pub(crate) struct AffectedMate {
417    id: String,
418    constraint_type: String,
419    was_enabled: bool,
420    /// (component id, ref, pre-replace kernel row).
421    refs: Vec<(String, String, Value)>,
422    /// For a sub-assembly's OWN mate (a part edit inside it): the occurrence
423    /// path of that sub-assembly and the mate's id in its document. `id` and
424    /// the refs are then namespaced under that path, as the session shows them.
425    home: Option<(String, String)>,
426}
427
428/// A replace whose run has not landed yet.
429#[derive(Debug, Clone)]
430pub(crate) struct PendingReplace {
431    generation: u64,
432    components: Vec<String>,
433    from: BTreeMap<String, String>,
434    to: String,
435    /// Component id → lineage word (`family` / `derived`), absent = none.
436    lineage: BTreeMap<String, &'static str>,
437    affected: Vec<AffectedMate>,
438    /// Component id → the OLD part's local entity names.
439    old_names: BTreeMap<String, BTreeSet<String>>,
440    /// Lines the caller adds to the report (a nested replace's held mates).
441    notes: Vec<String>,
442    /// `replace`, or `edit` for a part edit ([`EngineState::commit_part_edit`]).
443    origin: &'static str,
444    /// A part edit's parts → the `sourceSignature` each must still carry when
445    /// the run lands (an undo before it lands restores the old one).
446    signatures: BTreeMap<String, String>,
447}
448
449
450/// What [`EngineState::capture_replace`] read before the swap.
451#[derive(Debug, Clone)]
452pub struct ReplaceCapture {
453    components: Vec<String>,
454    affected: Vec<AffectedMate>,
455    old_names: BTreeMap<String, BTreeSet<String>>,
456}
457
458/// The lineage word of two parts-library entries (`{sourceKey, document}`),
459/// or `None` when they share nothing.
460pub fn entries_lineage(old: &Value, new: &Value, canonicalize: &dyn Fn(&str) -> String) -> Option<&'static str> {
461    let old_keys = lineage_keys(&old["document"], old["sourceKey"].as_str().unwrap_or(""), canonicalize);
462    let new_keys = lineage_keys(&new["document"], new["sourceKey"].as_str().unwrap_or(""), canonicalize);
463    shared_lineage(&old_keys, &new_keys)
464}
465
466
467
468/// [`entities_of`] for many paths under the same top components: each top
469/// component's listing is asked for once.
470#[derive(Default)]
471struct EntityListings {
472    by_top: BTreeMap<String, Vec<Value>>,
473    /// Each top component's local names by occurrence path under it (every
474    /// prefix of a row's ref), indexed once: a capture asks for the names of
475    /// every nested owner, and filtering the whole listing per owner cost
476    /// ~7 ms per module on l500.
477    names_by_owner: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
478    /// Each top listing's rows by occurrence path under it, as indices in
479    /// listing order: the top holds every row, each deeper component prefix
480    /// the rows whose ref starts with it. A part edit's check asks for every
481    /// nested owner (555 on l500), and filtering and copying the whole
482    /// listing per owner was ~65 ms of the frame its run landed in.
483    rows_by_owner: BTreeMap<String, BTreeMap<String, Vec<usize>>>,
484}
485
486/// Every occurrence path a listed ref sits under, below `top`: `TOP:A`,
487/// `TOP:A:B`, … (the segments before its name).
488fn owners_below<'a>(top: &'a str, reference: &'a str) -> impl Iterator<Item = String> + 'a {
489    let rest = reference.strip_prefix(top).and_then(|r| r.strip_prefix(':')).unwrap_or("");
490    rest.match_indices(':').map(move |(at, _)| format!("{top}:{}", &rest[..at]))
491}
492
493impl EntityListings {
494    /// The top component's listing, asked for once.
495    fn listed(&mut self, top: &str) -> &[Value] {
496        self.by_top.entry(top.to_string()).or_insert_with(|| {
497            entities_of(top)
498        })
499    }
500
501    /// Index the listing `path` sits in by owner, once per top.
502    fn index(&mut self, path: &str) {
503        let top = path.split(':').next().unwrap_or(path).to_string();
504        if self.rows_by_owner.contains_key(&top) {
505            return;
506        }
507        let rows = self.listed(&top);
508        let mut index: BTreeMap<String, Vec<usize>> = BTreeMap::new();
509        index.insert(top.clone(), (0..rows.len()).collect());
510        for (at, row) in rows.iter().enumerate() {
511            let Some(reference) = row["ref"].as_str() else { continue };
512            for owner in owners_below(&top, reference) {
513                index.entry(owner).or_default().push(at);
514            }
515        }
516        self.rows_by_owner.insert(top, index);
517    }
518
519    /// The entities under an occurrence path, in listing order: the top
520    /// component's whole listing, or the rows whose ref starts with `path:`.
521    /// [`Self::index`] it first.
522    fn under(&self, path: &str) -> Vec<&Value> {
523        let top = path.split(':').next().unwrap_or(path);
524        let (Some(rows), Some(index)) = (self.by_top.get(top), self.rows_by_owner.get(top)) else {
525            return Vec::new();
526        };
527        index.get(path).into_iter().flatten().map(|&at| &rows[at]).collect()
528    }
529
530    /// The local names (`local_name(path, ref)`) of every entity under an
531    /// occurrence path: what `of(path)` lists, as names, from the index.
532    fn names(&mut self, path: &str) -> BTreeSet<String> {
533        let top = path.split(':').next().unwrap_or(path).to_string();
534        if !self.names_by_owner.contains_key(&top) {
535            self.listed(&top);
536            let mut index: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
537            for row in self.by_top.get(&top).into_iter().flatten() {
538                let Some(reference) = row["ref"].as_str() else { continue };
539                let Some(rest) = reference.strip_prefix(&top).and_then(|r| r.strip_prefix(':')) else {
540                    index.entry(top.clone()).or_default().insert(reference.to_string());
541                    continue;
542                };
543                // Every owner the ref sits under: the top, then each deeper
544                // component prefix (`TOP:A`, `TOP:A:B`, … before the name).
545                let mut owner = top.clone();
546                index.entry(owner.clone()).or_default().insert(rest.to_string());
547                let mut tail = rest;
548                while let Some((segment, after)) = tail.split_once(':') {
549                    owner = format!("{owner}:{segment}");
550                    index.entry(owner.clone()).or_default().insert(after.to_string());
551                    tail = after;
552                }
553            }
554            self.names_by_owner.insert(top.clone(), index);
555        }
556        self.names_by_owner[&top].get(path).cloned().unwrap_or_default()
557    }
558}
559
560/// The named entities of an occurrence path: the top component's listing,
561/// narrowed to the nested occurrence for an `ACOMP3:ACOMP1` path.
562fn entities_of(path: &str) -> Vec<Value> {
563    let top = path.split(':').next().unwrap_or(path);
564    let prefix = format!("{path}:");
565    parse(&brep_kernel::assembly_component_entities_json(top))
566        .as_array()
567        .cloned()
568        .unwrap_or_default()
569        .into_iter()
570        .filter(|row| top == path || row["ref"].as_str().is_some_and(|r| r.starts_with(&prefix)))
571        .collect()
572}
573
574/// The document at the end of a parts-library chain (outermost part first)
575/// inside `document`, for writing.
576fn document_in_chain_mut<'a>(document: &'a mut Value, chain: &[String]) -> Option<&'a mut Value> {
577    let mut current = document;
578    for part in chain {
579        current = current.get_mut("partsLibrary")?.get_mut(part.as_str())?.get_mut("document")?;
580    }
581    Some(current)
582}
583
584/// The component a ref belongs to among `ids` (bare id or `id:` prefix).
585fn owner_among<'a>(ids: &'a [String], reference: &str) -> Option<&'a String> {
586    ids.iter().find(|id| {
587        reference == id.as_str()
588            || reference
589                .strip_prefix(id.as_str())
590                .is_some_and(|rest| rest.starts_with(':'))
591    })
592}
593
594/// The report's notes for the Pattern Components seeded by a replaced
595/// component in `document`: one per pattern, how many of its instances
596/// follow. Nothing about them is edited: the instances re-emit from the
597/// seed's new part in the same run, and a seed given as a face of the
598/// component (`ACOMP2:Pin_S`, a pick) resolves by its component prefix, so it
599/// survives a part without that face. `instances` is every Pattern Component
600/// instance the last run placed.
601pub fn follow_pattern_seeds(
602    document: &Value,
603    from: &BTreeMap<String, String>,
604    to: &str,
605    instances: &[String],
606) -> Vec<String> {
607    let mut notes = Vec::new();
608    for feature in document["features"].as_array().into_iter().flatten() {
609        if feature["type"] != "APAT" {
610            continue;
611        }
612        let pattern = feature["inputParams"]["id"].as_str().unwrap_or_default().to_string();
613        let mut seeded = BTreeSet::new();
614        for name in feature["inputParams"]["seeds"].as_array().into_iter().flatten().filter_map(Value::as_str) {
615            let owner = name.split(':').next().unwrap_or(name).to_string();
616            if from.contains_key(&owner) {
617                seeded.insert(owner);
618            }
619        }
620        for owner in seeded {
621            let count = instances
622                .iter()
623                .filter_map(|id| brep_kernel::pattern_component_instance(id))
624                .filter(|(p, s, _)| *p == pattern && *s == owner)
625                .count();
626            notes.push(format!("{pattern}: its {count} instance(s) of {owner} follow the seed and now show '{to}'"));
627        }
628    }
629    notes
630}
631
632fn parse(json: &str) -> Value {
633    serde_json::from_str(json).unwrap_or(Value::Null)
634}
635
636impl EngineState {
637    /// Replace the part shown by the ACOMP occurrences `component_ids` with
638    /// `insert` (an existing parts-library entry or a new part payload).
639    /// `canonicalize` spells a store identity one way (lineage comparison).
640    /// One undo step. Returns the new part's library entry name; the
641    /// classification of the mates lands with the run
642    /// ([`Self::replace_report`]).
643    pub fn replace_components(
644        &mut self,
645        component_ids: &[String],
646        insert: ComponentInsert<'_>,
647        canonicalize: &dyn Fn(&str) -> String,
648    ) -> Result<String, String> {
649        if component_ids.is_empty() {
650            return Err("replace: no component given".into());
651        }
652        // A Pattern Component instance has no feature of its own: it shows
653        // whatever its seed shows, so replacing it replaces the seed (and
654        // with it every instance), and the report says so.
655        let mut ids: Vec<String> = Vec::new();
656        let mut instance_notes = Vec::new();
657        for id in component_ids {
658            let id = match brep_kernel::pattern_component_instance(id) {
659                Some((pattern, seed, n)) => {
660                    instance_notes.push(format!(
661                        "{id} is instance {n} of {pattern} and follows its seed {seed}: the seed was replaced"
662                    ));
663                    seed
664                }
665                None => id.clone(),
666            };
667            if !ids.contains(&id) {
668                ids.push(id);
669            }
670        }
671        let component_ids = ids.as_slice();
672        let mut from = BTreeMap::new();
673        for id in component_ids {
674            let info = self
675                .component_info(id)
676                .ok_or_else(|| format!("replace: '{id}' is not an assembly component"))?;
677            from.insert(id.clone(), info.part_name);
678        }
679        let capture = self.capture_replace(component_ids);
680
681        // The new part's library entry (added or reused), and lineage.
682        let old_library = self.history.parts_library().clone();
683        let to = self.resolve_component_insert(insert)?;
684        let library = parse(&brep_kernel::parts_library_json());
685        let new_entry = &library[&to];
686        let mut lineage = BTreeMap::new();
687        for (id, part) in &from {
688            if part == &to {
689                continue;
690            }
691            let old_entry = if old_library[part].is_object() { &old_library[part] } else { &library[part] };
692            if let Some(word) = entries_lineage(old_entry, new_entry, canonicalize) {
693                lineage.insert(id.clone(), word);
694            }
695        }
696        if from.values().all(|part| part == &to) {
697            return Err(format!("replace: the component already shows '{to}'"));
698        }
699
700        // ONE document edit: the library and the partNames (the commit adds
701        // the held mates). A Pattern Component seeded by a replaced
702        // component follows by itself (see [`follow_pattern_seeds`]).
703        let mut document = parse(&self.history_request_json());
704        document["partsLibrary"] = library.clone();
705        for feature in document["features"].as_array_mut().into_iter().flatten() {
706            let id = feature["inputParams"]["id"].as_str().unwrap_or_default().to_string();
707            if from.contains_key(&id) {
708                feature["inputParams"]["partName"] = Value::String(to.clone());
709            }
710        }
711        let mut notes = instance_notes;
712        notes.extend(follow_pattern_seeds(&document, &from, &to, &self.pattern_instance_ids()));
713        self.commit_replace(capture, document, from, to.clone(), lineage, notes)?;
714        Ok(to)
715    }
716
717    /// The live state a replace of `paths` must compare against, read BEFORE
718    /// anything moves: every healthy mate touching them with each
719    /// reference's local frame, and the old parts' entity names. A path is an
720    /// ACOMP id, or a NESTED occurrence path (`ACOMP3:ACOMP1`) for a part
721    /// inside a sub-assembly (frames are then in the top occurrence's frame,
722    /// which the replace does not move).
723    pub fn capture_replace(&mut self, paths: &[String]) -> ReplaceCapture {
724        self.capture_replace_listed(paths, &mut EntityListings::default())
725    }
726
727    /// [`Self::capture_replace`] reading the components' names through
728    /// `listings`, which the caller can go on sharing (a part edit's nested
729    /// owners sit under these same top components).
730    fn capture_replace_listed(&mut self, paths: &[String], listings: &mut EntityListings) -> ReplaceCapture {
731        self.ensure_assembly_session();
732        let state = parse(&brep_kernel::assembly_state_json());
733        let mut affected = Vec::new();
734        for entry in state["constraints"].as_array().into_iter().flatten() {
735            let refs: Vec<(String, String)> = entry["inputParams"]["elements"]
736                .as_array()
737                .into_iter()
738                .flatten()
739                .filter_map(Value::as_str)
740                .filter_map(|r| owner_among(paths, r).map(|owner| (owner.clone(), r.to_string())))
741                .collect();
742            if refs.is_empty() {
743                continue;
744            }
745            let enabled = entry["enabled"].as_bool().unwrap_or(true);
746            let status = entry["persistentData"]["status"].as_str().unwrap_or("");
747            if enabled && BROKEN.contains(&status) {
748                continue; // already broken: left exactly as it was
749            }
750            let names: Vec<&String> = refs.iter().map(|(_, r)| r).collect();
751            let rows = parse(&brep_kernel::assembly_resolve_refs_json(
752                &serde_json::to_string(&names).unwrap_or_default(),
753            ));
754            let rows = rows.as_array().cloned().unwrap_or_default();
755            affected.push(AffectedMate {
756                id: entry["inputParams"]["id"].as_str().unwrap_or_default().to_string(),
757                constraint_type: entry["type"].as_str().unwrap_or_default().to_string(),
758                was_enabled: enabled,
759                refs: refs
760                    .into_iter()
761                    .zip(rows.into_iter().chain(std::iter::repeat(Value::Null)))
762                    .map(|((owner, r), row)| (owner, r, row))
763                    .collect(),
764                home: None,
765            });
766        }
767        let mut old_names = BTreeMap::new();
768        for path in paths {
769            old_names.insert(path.clone(), listings.names(path));
770        }
771        ReplaceCapture { components: paths.to_vec(), affected, old_names }
772    }
773
774    /// [`Self::capture_replace`] for a PART EDIT: also the sub-assemblies' OWN
775    /// mates below `paths`, at every depth. Those live in the sub-assembly's
776    /// document, not this one, and a renamed face breaks them just the same;
777    /// [`Self::commit_part_edit`] classifies them with the rest and writes the
778    /// outcome back into that document. (A replace takes the old sub-assembly's
779    /// mates away with it, so it captures none.)
780    pub fn capture_part_edit(&mut self, paths: &[String]) -> ReplaceCapture {
781        // One listing per top component, shared by the components' own names
782        // and every nested owner under them (a listing resolves every name
783        // the component has).
784        let mut listings = EntityListings::default();
785        let mut capture = self.capture_replace_listed(paths, &mut listings);
786        // One sub-assembly definition placed twice holds one set of mates,
787        // and the same definitions below it: its subtree is walked once.
788        let mut seen: BTreeSet<Vec<String>> = BTreeSet::new();
789        let mut stack: Vec<String> = paths.to_vec();
790        while let Some(path) = stack.pop() {
791            let Some(chain) = self.part_chain(&path) else { continue };
792            if !seen.insert(chain.clone()) {
793                continue;
794            }
795            let Some(document) = self.document_in_chain(&chain) else { continue };
796            for feature in document["features"].as_array().into_iter().flatten() {
797                if super::components::is_acomp_feature_type(feature["type"].as_str().unwrap_or("")) {
798                    if let Some(id) = feature["inputParams"]["id"].as_str() {
799                        stack.push(format!("{path}:{id}"));
800                    }
801                }
802            }
803            for entry in document["assembly"]["constraints"].as_array().into_iter().flatten() {
804                let inner_id = entry["inputParams"]["id"].as_str().unwrap_or_default();
805                let refs: Vec<(String, String)> = entry["inputParams"]["elements"]
806                    .as_array()
807                    .into_iter()
808                    .flatten()
809                    .filter_map(Value::as_str)
810                    .map(|r| {
811                        let occurrence = r.split(':').next().unwrap_or(r);
812                        (format!("{path}:{occurrence}"), format!("{path}:{r}"))
813                    })
814                    .collect();
815                if inner_id.is_empty() || refs.is_empty() {
816                    continue;
817                }
818                let enabled = entry["enabled"].as_bool().unwrap_or(true);
819                let status = entry["persistentData"]["status"].as_str().unwrap_or("");
820                if enabled && BROKEN.contains(&status) {
821                    continue; // already broken: left exactly as it was
822                }
823                let names: Vec<&String> = refs.iter().map(|(_, r)| r).collect();
824                let rows = parse(&brep_kernel::assembly_resolve_refs_json(
825                    &serde_json::to_string(&names).unwrap_or_default(),
826                ));
827                let rows = rows.as_array().cloned().unwrap_or_default();
828                // A ref this session cannot resolve now cannot be judged after
829                // the edit either: the mate is left exactly as it is, never
830                // held on a guess.
831                if rows.len() != refs.len() || rows.iter().any(|row| row["ok"] != true) {
832                    continue;
833                }
834                for (owner, _) in &refs {
835                    if !capture.old_names.contains_key(owner) {
836                        let names = listings.names(owner);
837                        capture.old_names.insert(owner.clone(), names);
838                    }
839                }
840                capture.affected.push(AffectedMate {
841                    id: format!("{path}:{inner_id}"),
842                    constraint_type: entry["type"].as_str().unwrap_or_default().to_string(),
843                    was_enabled: enabled,
844                    refs: refs
845                        .into_iter()
846                        .zip(rows.into_iter().chain(std::iter::repeat(Value::Null)))
847                        .map(|((owner, r), row)| (owner, r, row))
848                        .collect(),
849                    home: Some((path.clone(), inner_id.to_string())),
850                });
851            }
852        }
853        capture
854    }
855
856    /// Write a part edit's outcome for the sub-assemblies' OWN mates into
857    /// their documents, in this document's parts library: a re-attached ref is
858    /// rewritten, a held mate is disabled and marked, with every ref in the
859    /// sub-assembly's own frame (Repair runs there). Each rewritten entry, and
860    /// every entry above it, is re-signed and loses its snapshot, as the edit
861    /// that caused it did. No checkpoint: it rides the edit's undo step.
862    fn write_back_inner_mates(
863        &mut self,
864        affected: &[AffectedMate],
865        reattached: &[(String, String, String)],
866        markers: &BTreeMap<String, Value>,
867    ) {
868        let touched: Vec<&AffectedMate> = affected
869            .iter()
870            .filter(|mate| mate.home.is_some())
871            .filter(|mate| markers.contains_key(&mate.id) || reattached.iter().any(|(id, _, _)| *id == mate.id))
872            .collect();
873        if touched.is_empty() {
874            return;
875        }
876        let Ok(mut document) = serde_json::from_str::<Value>(&self.history.request_json()) else { return };
877        let mut chains: BTreeSet<Vec<String>> = BTreeSet::new();
878        for mate in touched {
879            let (home, inner_id) = mate.home.as_ref().expect("filtered");
880            let Some(chain) = self.part_chain(home) else { continue };
881            let Some(inner) = document_in_chain_mut(&mut document, &chain) else { continue };
882            let prefix = format!("{home}:");
883            let local = |reference: &str| reference.strip_prefix(&prefix).unwrap_or(reference).to_string();
884            for entry in inner["assembly"]["constraints"].as_array_mut().into_iter().flatten() {
885                if entry["inputParams"]["id"].as_str() != Some(inner_id.as_str()) {
886                    continue;
887                }
888                for (_, old_ref, new_ref) in reattached.iter().filter(|(id, _, _)| *id == mate.id) {
889                    for element in entry["inputParams"]["elements"].as_array_mut().into_iter().flatten() {
890                        if element == local(old_ref).as_str() {
891                            *element = Value::String(local(new_ref));
892                        }
893                    }
894                }
895                if let Some(marker) = markers.get(&mate.id) {
896                    let mut marker = marker.clone();
897                    for row in marker["refs"].as_array_mut().into_iter().flatten() {
898                        if let Some(reference) = row["ref"].as_str().map(|r| local(r)) {
899                            row["ref"] = Value::String(reference);
900                        }
901                        if let Some(reference) = row["suggestion"]["ref"].as_str().map(|r| local(r)) {
902                            row["suggestion"]["ref"] = Value::String(reference);
903                        }
904                    }
905                    entry["enabled"] = Value::Bool(false);
906                    if !entry["persistentData"].is_object() {
907                        entry["persistentData"] = json!({});
908                    }
909                    entry["persistentData"][REPLACE_MARKER] = marker;
910                }
911            }
912            chains.insert(chain);
913        }
914        // Deepest first, so each signature covers the rewrites below it.
915        let mut prefixes: BTreeSet<Vec<String>> = BTreeSet::new();
916        for chain in &chains {
917            for depth in 1..=chain.len() {
918                prefixes.insert(chain[..depth].to_vec());
919            }
920        }
921        let mut prefixes: Vec<Vec<String>> = prefixes.into_iter().collect();
922        prefixes.sort_by_key(|chain| std::cmp::Reverse(chain.len()));
923        for chain in prefixes {
924            let (last, parent) = chain.split_last().expect("non-empty");
925            let library = if parent.is_empty() {
926                Some(&mut document["partsLibrary"])
927            } else {
928                document_in_chain_mut(&mut document, parent).map(|doc| &mut doc["partsLibrary"])
929            };
930            let Some(entry) = library.and_then(|library| library.get_mut(last.as_str())) else { continue };
931            let signature = super::assembly_ops::document_signature(&entry["document"].to_string());
932            entry["sourceSignature"] = Value::String(signature);
933            entry["snapshot"] = Value::String(String::new());
934            if let Some(object) = entry.as_object_mut() {
935                object.remove("nestedRows");
936            }
937        }
938        // As `edit_document_json`: the run reads the kernel's resident store,
939        // not the document's block.
940        let written = serde_json::from_value::<brep_kernel::PartsLibraryMap>(document["partsLibrary"].clone())
941            .map_err(|error| error.to_string())
942            .and_then(|library| self.history.adopt_document(&document.to_string()).map(|()| library));
943        match written {
944            Ok(library) => {
945                brep_kernel::install_parts_library(&library);
946            }
947            Err(error) => {
948                self.push_notice(format!("part edit: the sub-assembly mates could not be written: {error}"));
949            }
950        }
951    }
952
953    /// The document of the part an occurrence path places (see
954    /// [`Self::part_at_path`]).
955    fn document_in_chain(&self, chain: &[String]) -> Option<&Value> {
956        let mut library = self.history.parts_library();
957        let mut document = None;
958        for part in chain {
959            let entry = library.get(part.as_str())?;
960            document = Some(&entry["document"]);
961            library = &entry["document"]["partsLibrary"];
962        }
963        document
964    }
965
966    /// The parts-library names along an occurrence path, outermost first: the
967    /// top ACOMP's part, then each placement's inside the one before. (Walked
968    /// by reference: the library can be megabytes.)
969    fn part_chain(&self, path: &str) -> Option<Vec<String>> {
970        let mut segments = path.split(':');
971        let top = segments.next()?;
972        let index = self.history.index_of(top)?;
973        let mut part = self.history.feature_params(index)?["partName"].as_str()?.to_string();
974        let mut chain = vec![part.clone()];
975        let mut library = self.history.parts_library();
976        for id in segments {
977            let document = &library.get(part.as_str())?["document"];
978            part = document["features"]
979                .as_array()?
980                .iter()
981                .find(|f| f["inputParams"]["id"].as_str() == Some(id))?["inputParams"]["partName"]
982                .as_str()?
983                .to_string();
984            chain.push(part.clone());
985            library = &document["partsLibrary"];
986        }
987        Some(chain)
988    }
989
990    /// Adopt `document` (the caller's replace edit) as ONE undo step, holding
991    /// every captured mate on an occurrence without shared lineage
992    /// (disabled, marked once the run lands), and classify the rest when the
993    /// run lands. `from` / `to` / `lineage` are keyed by the captured paths;
994    /// `notes` join the report.
995    pub fn commit_replace(
996        &mut self,
997        capture: ReplaceCapture,
998        mut document: Value,
999        from: BTreeMap<String, String>,
1000        to: String,
1001        lineage: BTreeMap<String, &'static str>,
1002        notes: Vec<String>,
1003    ) -> Result<(), String> {
1004        let untrusted: BTreeSet<&String> =
1005            capture.components.iter().filter(|id| !lineage.contains_key(*id)).collect();
1006        if let Some(constraints) = document["assembly"]["constraints"].as_array_mut() {
1007            for entry in constraints.iter_mut() {
1008                let id = entry["inputParams"]["id"].as_str().unwrap_or_default();
1009                let Some(mate) = capture.affected.iter().find(|m| m.id == id) else { continue };
1010                if mate.refs.iter().any(|(owner, _, _)| untrusted.contains(owner)) {
1011                    entry["enabled"] = Value::Bool(false);
1012                }
1013            }
1014        }
1015        self.edit_document(document)?;
1016        self.replace_pending = Some(PendingReplace {
1017            generation: self.run_generation,
1018            components: capture.components,
1019            from,
1020            to,
1021            lineage,
1022            affected: capture.affected,
1023            old_names: capture.old_names,
1024            notes,
1025            origin: "replace",
1026            signatures: BTreeMap::new(),
1027        });
1028        self.last_replace_report = None;
1029        if !self.run_pending() {
1030            self.finish_component_replace();
1031        }
1032        Ok(())
1033    }
1034
1035    /// Adopt `document` (the caller's part edit: `parts` rewritten in its
1036    /// parts library) as ONE undo step, and classify the mates of the
1037    /// captured occurrences once its run lands: a reference that still
1038    /// resolves is kept, one whose name is gone is re-attached when exactly
1039    /// one entity of the edited part lies on its carrier, and the rest are
1040    /// held for Repair (module doc, *Part edits*).
1041    pub fn commit_part_edit(&mut self, capture: ReplaceCapture, document: Value, parts: &[String]) -> Result<(), String> {
1042        // One pending check at a time: a replace (or an earlier edit) whose
1043        // run has not landed would otherwise lose its classification here.
1044        self.finish_component_replace();
1045        if self.replace_pending.is_some() {
1046            return Err("a component replace is still landing; try again in a moment".into());
1047        }
1048        let signatures: BTreeMap<String, String> = parts
1049            .iter()
1050            .map(|part| {
1051                let signature = document["partsLibrary"][part]["sourceSignature"].as_str().unwrap_or_default();
1052                (part.clone(), signature.to_string())
1053            })
1054            .collect();
1055        let from: BTreeMap<String, String> = capture
1056            .components
1057            .iter()
1058            .filter_map(|path| Some((path.clone(), self.part_at_path(path)?)))
1059            .collect();
1060        self.edit_document(document)?;
1061        if capture.affected.is_empty() {
1062            return Ok(());
1063        }
1064        self.replace_pending = Some(PendingReplace {
1065            generation: self.run_generation,
1066            lineage: capture.components.iter().map(|path| (path.clone(), "edit")).collect(),
1067            components: capture.components,
1068            to: parts.join(", "),
1069            from,
1070            affected: capture.affected,
1071            old_names: capture.old_names,
1072            notes: Vec::new(),
1073            origin: "edit",
1074            signatures,
1075        });
1076        self.last_replace_report = None;
1077        if !self.run_pending() {
1078            self.finish_component_replace();
1079        }
1080        Ok(())
1081    }
1082
1083    /// The ACOMP ids (top level) that place any of `parts`.
1084    pub fn components_of_parts(&self, parts: &[String]) -> Vec<String> {
1085        self.history
1086            .features()
1087            .iter()
1088            .filter(|feature| super::components::is_acomp_feature_type(feature["type"].as_str().unwrap_or("")))
1089            .filter(|feature| {
1090                feature["inputParams"]["partName"].as_str().is_some_and(|part| parts.iter().any(|p| p == part))
1091            })
1092            .filter_map(|feature| feature["inputParams"]["id"].as_str().map(str::to_string))
1093            .collect()
1094    }
1095
1096    /// Classify the mates of the replace whose run just landed, mark the
1097    /// unresolved ones (silently: the replace is already one undo step) and
1098    /// publish the report. Called from `pump` once the run is applied.
1099    pub(crate) fn finish_component_replace(&mut self) {
1100        let Some(pending) = self.replace_pending.as_ref() else { return };
1101        if self.run_pending() || self.applied_generation < pending.generation {
1102            return;
1103        }
1104        let pending = self.replace_pending.take().expect("checked above");
1105        // The run that landed must be the replace's own document: an undo (or
1106        // any document change) before it landed means there is nothing of
1107        // this replace to classify, and marking would hold the OLD part's
1108        // mates.
1109        let landed = if pending.origin == "edit" {
1110            pending.signatures.iter().all(|(part, signature)| {
1111                self.history.parts_library()[part.as_str()]["sourceSignature"].as_str() == Some(signature.as_str())
1112            })
1113        } else {
1114            pending.components.iter().all(|path| self.part_at_path(path).as_deref() == Some(pending.to.as_str()))
1115        };
1116        if !landed {
1117            self.last_replace_report = None;
1118            return;
1119        }
1120        self.ensure_assembly_session();
1121        let statuses = parse(&brep_kernel::assembly_statuses_json());
1122        // A sub-assembly's own mates report on the instance, namespaced.
1123        let nested = parse(&brep_kernel::assembly_dof_json())["nestedConstraints"].clone();
1124        let status_of = |id: &str| -> String {
1125            statuses
1126                .as_array()
1127                .into_iter()
1128                .flatten()
1129                .chain(nested.as_array().into_iter().flatten())
1130                .find(|row| row["id"] == id)
1131                .and_then(|row| row["status"].as_str())
1132                .unwrap_or("")
1133                .to_string()
1134        };
1135        // Vertex refs (`{solid}@x,y,z`) carry a POSITION, not a name, and no
1136        // vertex is listed as an entity: they are resolved again here.
1137        let vertex_refs: Vec<&String> = pending
1138            .affected
1139            .iter()
1140            .flat_map(|mate| mate.refs.iter().map(|(_, r, _)| r))
1141            .filter(|r| r.contains('@'))
1142            .collect();
1143        let post_vertex: BTreeMap<String, Value> = if vertex_refs.is_empty() {
1144            BTreeMap::new()
1145        } else {
1146            parse(&brep_kernel::assembly_resolve_refs_json(&serde_json::to_string(&vertex_refs).unwrap_or_default()))
1147                .as_array()
1148                .cloned()
1149                .unwrap_or_default()
1150                .into_iter()
1151                .filter_map(|row| Some((row["ref"].as_str()?.to_string(), row)))
1152                .collect()
1153        };
1154        // Only an affected mate's owner is ever looked up: list those, each
1155        // top component once. An owner among the replaced components is
1156        // listed; a sub-assembly's own mate is owned by the nested
1157        // occurrence, listed too; any other owner has nothing to compare.
1158        let mut listings = EntityListings::default();
1159        let owners: BTreeSet<&String> = pending
1160            .affected
1161            .iter()
1162            .flat_map(|mate| mate.refs.iter().map(move |(owner, _, _)| (mate, owner)))
1163            .filter(|(mate, owner)| mate.home.is_some() || pending.components.contains(owner))
1164            .map(|(_, owner)| owner)
1165            .collect();
1166        for owner in &owners {
1167            listings.index(owner);
1168        }
1169        let entities: BTreeMap<&String, Vec<&Value>> = owners.iter().map(|owner| (*owner, listings.under(owner))).collect();
1170
1171        let mut kept = Vec::new();
1172        let mut unresolved = Vec::new();
1173        let mut notes = pending.notes.clone();
1174        let mut markers: BTreeMap<String, Value> = BTreeMap::new();
1175        // A part edit's automatic re-attachments: (mate id, old ref, new ref).
1176        let mut reattached: Vec<(String, String, String)> = Vec::new();
1177        for mate in &pending.affected {
1178            let mut ref_rows = Vec::new();
1179            for (owner, reference, pre) in &mate.refs {
1180                // Below an edited occurrence every part is the one it was,
1181                // or the edited part's own next version.
1182                let trusted = pending.lineage.contains_key(owner)
1183                    || (mate.home.is_some() && owner_among(&pending.components, owner).is_some_and(|top| pending.lineage.contains_key(top)));
1184                let outcome = if pre["ok"] != true {
1185                    // The reference did not resolve before either (a disabled
1186                    // mate's dangling ref): nothing to compare, so the user
1187                    // decides.
1188                    RefOutcome::Unresolved { reason: "missing", suggestion: None }
1189                } else if reference.contains('@') {
1190                    classify_vertex_ref(reference, pre, post_vertex.get(reference.as_str()), trusted)
1191                } else {
1192                    classify_ref(
1193                        owner,
1194                        reference,
1195                        pre,
1196                        entities.get(owner).map(Vec::as_slice).unwrap_or(&[]),
1197                        pending.old_names.get(owner).unwrap_or(&BTreeSet::new()),
1198                        trusted,
1199                    )
1200                };
1201                match outcome {
1202                    RefOutcome::Kept { split } => {
1203                        if split {
1204                            notes.push(format!(
1205                                "{}: '{}' was split by the new part; the mate keeps the piece that kept the name (same carrier)",
1206                                mate.id, reference
1207                            ));
1208                        }
1209                    }
1210                    RefOutcome::Unresolved { reason, suggestion } => {
1211                        let only = (pending.origin == "edit" && !reference.contains('@'))
1212                            .then(|| unique_carrier(pre, entities.get(owner).map(Vec::as_slice).unwrap_or(&[])))
1213                            .flatten();
1214                        match only {
1215                            Some(new_ref) => reattached.push((mate.id.clone(), reference.clone(), new_ref)),
1216                            None => ref_rows.push(json!({
1217                                "ref": reference,
1218                                "reason": reason,
1219                                "suggestion": suggestion.as_ref().map(Suggestion::to_json),
1220                            })),
1221                        }
1222                    }
1223                }
1224            }
1225            // Shared lineage and every name kept, but the mate no longer maps
1226            // (e.g. a plane–plane distance whose partner face is now a cone):
1227            // unresolved too, never silently half-working.
1228            let status = status_of(&mate.id);
1229            // A mate re-attached here was broken in the run that just landed
1230            // (its old name was gone): the rerun below judges it again.
1231            let mate_reattached = reattached.iter().any(|(id, _, _)| *id == mate.id);
1232            if ref_rows.is_empty() && !mate_reattached && mate.was_enabled && BROKEN.contains(&status.as_str()) {
1233                for (_, reference, _) in &mate.refs {
1234                    ref_rows.push(json!({ "ref": reference, "reason": "no-longer-maps", "suggestion": null }));
1235                }
1236            }
1237            if ref_rows.is_empty() {
1238                kept.push(json!({ "id": mate.id, "type": mate.constraint_type }));
1239                continue;
1240            }
1241            let owner = &mate.refs[0].0;
1242            let marker = marker_json(
1243                pending.origin,
1244                &pending.from.get(owner).cloned().or_else(|| self.part_at_path(owner)).unwrap_or_default(),
1245                &pending.to,
1246                mate.was_enabled,
1247                ref_rows,
1248            );
1249            unresolved.push(json!({
1250                "id": mate.id,
1251                "type": mate.constraint_type,
1252                "refs": marker["refs"],
1253            }));
1254            markers.insert(mate.id.clone(), marker);
1255        }
1256
1257        if !markers.is_empty() || !reattached.is_empty() {
1258            let mut block = self.history.assembly_block().cloned().unwrap_or(Value::Null);
1259            let mut disabled_now = false;
1260            for entry in block["constraints"].as_array_mut().into_iter().flatten() {
1261                let id = entry["inputParams"]["id"].as_str().unwrap_or_default().to_string();
1262                for (mate, old_ref, new_ref) in &reattached {
1263                    if *mate != id {
1264                        continue;
1265                    }
1266                    for element in entry["inputParams"]["elements"].as_array_mut().into_iter().flatten() {
1267                        if element == old_ref.as_str() {
1268                            *element = Value::String(new_ref.clone());
1269                        }
1270                    }
1271                }
1272                let Some(marker) = markers.get(&id) else { continue };
1273                if entry["enabled"] != false {
1274                    entry["enabled"] = Value::Bool(false);
1275                    disabled_now = true;
1276                }
1277                if !entry["persistentData"].is_object() {
1278                    entry["persistentData"] = json!({});
1279                }
1280                entry["persistentData"][REPLACE_MARKER] = marker.clone();
1281            }
1282            self.history.set_assembly_block(block, false);
1283            self.write_back_inner_mates(&pending.affected, &reattached, &markers);
1284            // The session must see the markers (a later constraint edit
1285            // folds the SESSION back into the document); a mate disabled
1286            // only now also changes the solve.
1287            let _ = disabled_now;
1288            self.rerun_history();
1289        }
1290
1291        // A part edit is not a dialog the user is looking at: say what it did
1292        // to the mates, never silently.
1293        if pending.origin == "edit" && (!reattached.is_empty() || !unresolved.is_empty()) {
1294            let mut lines = Vec::new();
1295            if !reattached.is_empty() {
1296                let moves: Vec<String> = reattached.iter().map(|(id, from, to)| format!("{id} {from} → {to}")).collect();
1297                lines.push(format!("re-attached {}", moves.join(", ")));
1298            }
1299            if !unresolved.is_empty() {
1300                let ids: Vec<&str> = unresolved.iter().filter_map(|row| row["id"].as_str()).collect();
1301                lines.push(format!(
1302                    "held for Repair (Constraints panel): {} — the face or edge it used is gone and no single one replaces it",
1303                    ids.join(", ")
1304                ));
1305            }
1306            let severity = if unresolved.is_empty() { NoticeSeverity::Info } else { NoticeSeverity::Warning };
1307            self.push_notice_as(severity, format!("Mates after editing {}: {}.", pending.to, lines.join("; ")));
1308        }
1309        let report = json!({
1310            "ok": true,
1311            "origin": pending.origin,
1312            "reattached": reattached.iter().map(|(id, from, to)| json!({ "id": id, "from": from, "to": to })).collect::<Vec<_>>(),
1313            "components": pending.components,
1314            "from": pending.from,
1315            "to": pending.to,
1316            "lineage": pending.components.iter().map(|id| {
1317                (id.clone(), Value::String(pending.lineage.get(id).copied().unwrap_or("none").to_string()))
1318            }).collect::<serde_json::Map<String, Value>>(),
1319            "kept": kept,
1320            "unresolved": unresolved,
1321            "notes": notes,
1322        });
1323        self.last_replace_report = Some(report);
1324    }
1325
1326    /// The named faces and edges of an occurrence path (`ACOMP3` or a nested
1327    /// `ACOMP3:ACOMP1`), each with its frame in the TOP occurrence's local
1328    /// space: what a held mate's suggestion is chosen from.
1329    pub fn occurrence_entities(&mut self, path: &str) -> Vec<Value> {
1330        self.ensure_assembly_session();
1331        entities_of(path)
1332    }
1333
1334    /// Resolve namespaced refs against the live assembly (frames in the
1335    /// owning top occurrence's local space; `{ref, ok, geom, point, …}` rows).
1336    pub fn resolve_refs(&mut self, refs: &[String]) -> Vec<Value> {
1337        self.ensure_assembly_session();
1338        parse(&brep_kernel::assembly_resolve_refs_json(&serde_json::to_string(refs).unwrap_or_default()))
1339            .as_array()
1340            .cloned()
1341            .unwrap_or_default()
1342    }
1343
1344    /// The last replace's report (`null` while its run is in flight or when
1345    /// nothing was replaced this session): `{components, from, to, lineage,
1346    /// kept, unresolved: [{id, type, refs: [{ref, reason, suggestion}]}],
1347    /// notes}`.
1348    pub fn replace_report(&mut self) -> Value {
1349        self.finish_component_replace();
1350        // A report speaks for its replace only while the document shows it:
1351        // after an undo it is gone, after a redo it is back.
1352        match &self.last_replace_report {
1353            Some(report) if self.report_in_effect(report) => report.clone(),
1354            _ => Value::Null,
1355        }
1356    }
1357
1358    fn report_in_effect(&self, report: &Value) -> bool {
1359        let to = report["to"].as_str().unwrap_or_default();
1360        report["components"]
1361            .as_array()
1362            .is_some_and(|paths| paths.iter().filter_map(Value::as_str).all(|path| self.part_at_path(path).as_deref() == Some(to)))
1363    }
1364
1365    /// The parts-library name an occurrence path shows in the document: a
1366    /// top ACOMP's `partName`, or (`ACOMP3:ACOMP1`) the placement's inside
1367    /// the sub-assembly definitions. `None` when the path does not exist.
1368    pub fn part_at_path(&self, path: &str) -> Option<String> {
1369        let mut segments = path.split(':');
1370        let top = segments.next()?;
1371        let index = self.history.index_of(top)?;
1372        let mut part = self.history.feature_params(index)?["partName"].as_str()?.to_string();
1373        let mut library = self.history.parts_library();
1374        for id in segments {
1375            let document = &library[&part]["document"];
1376            part = document["features"]
1377                .as_array()?
1378                .iter()
1379                .find(|f| f["inputParams"]["id"].as_str() == Some(id))?["inputParams"]["partName"]
1380                .as_str()?
1381                .to_string();
1382            library = &document["partsLibrary"];
1383        }
1384        Some(part)
1385    }
1386
1387    /// Every mate still carrying a replace marker (survives save/reopen):
1388    /// `[{id, type, from, to, refs}]`.
1389    pub fn replace_unresolved(&self) -> Value {
1390        let rows: Vec<Value> = self
1391            .history
1392            .assembly_block()
1393            .and_then(|block| block["constraints"].as_array().cloned())
1394            .unwrap_or_default()
1395            .into_iter()
1396            .filter(|entry| entry["persistentData"][REPLACE_MARKER].is_object())
1397            .map(|entry| {
1398                let marker = &entry["persistentData"][REPLACE_MARKER];
1399                json!({
1400                    "id": entry["inputParams"]["id"],
1401                    "type": entry["type"],
1402                    "origin": marker["origin"].as_str().unwrap_or("replace"),
1403                    "from": marker["from"],
1404                    "to": marker["to"],
1405                    "refs": marker["refs"],
1406                })
1407            })
1408            .collect();
1409        Value::Array(rows)
1410    }
1411
1412    /// Start the REPAIR PICK for one held reference of mate `mate`: the
1413    /// standard reference selection ([`RefSelectTarget::ReplaceRepair`]),
1414    /// filtered to faces and edges, ONE pick. Nothing rolls: the repair names
1415    /// the assembly as it stands. The finished pick waits for
1416    /// [`Self::take_replace_repair_pick`].
1417    pub fn begin_ref_select_for_replace_repair(&mut self, mate: &str, old_ref: &str) {
1418        let filter = vec!["FACE".to_string(), "EDGE".to_string()];
1419        let restore_index = self.history.rollback();
1420        self.selection_filter = SelectionFilter::from_ref_filter(&filter);
1421        self.replace_repair_pick = None;
1422        self.ref_select = Some(RefSelectState {
1423            feature_id: mate.to_string(),
1424            path: vec![old_ref.to_string()],
1425            label: format!("Replacement for {old_ref}"),
1426            filter,
1427            multiple: false,
1428            names: Vec::new(),
1429            restore_index,
1430            target: RefSelectTarget::ReplaceRepair,
1431            picks: 0,
1432            claimed: false,
1433            finish_requested: false,
1434        });
1435        self.sync_ref_select_emphasis();
1436    }
1437
1438    /// The finished repair pick `(mate, old ref, picked name)`, once: the
1439    /// replace dialog takes it and runs the repair.
1440    pub fn take_replace_repair_pick(&mut self) -> Option<(String, String, String)> {
1441        self.replace_repair_pick.take()
1442    }
1443
1444    /// Repair unresolved references, as ONE undo step: each `(constraint id,
1445    /// old ref, new ref)` rewrites that element and clears the reference from
1446    /// the marker; a mate with nothing left unresolved drops its marker and
1447    /// is re-enabled when it was enabled before the replace. `new ref` equal
1448    /// to `old ref` confirms the same-name match.
1449    pub fn replace_repair(&mut self, repairs: &[(String, String, String)]) -> Result<(), String> {
1450        let mut block = self
1451            .history
1452            .assembly_block()
1453            .cloned()
1454            .ok_or("replace repair: the document has no assembly")?;
1455        for (id, old_ref, new_ref) in repairs {
1456            let entry = block["constraints"]
1457                .as_array_mut()
1458                .into_iter()
1459                .flatten()
1460                .find(|entry| entry["inputParams"]["id"] == id.as_str())
1461                .ok_or_else(|| format!("replace repair: no constraint '{id}'"))?;
1462            let marker = &mut entry["persistentData"][REPLACE_MARKER];
1463            let refs = marker["refs"]
1464                .as_array_mut()
1465                .ok_or_else(|| format!("replace repair: '{id}' has nothing unresolved"))?;
1466            let before = refs.len();
1467            refs.retain(|row| row["ref"] != old_ref.as_str());
1468            if refs.len() == before {
1469                return Err(format!("replace repair: '{id}' has no unresolved ref '{old_ref}'"));
1470            }
1471            let done = refs.is_empty();
1472            let was_enabled = marker["wasEnabled"].as_bool().unwrap_or(true);
1473            for element in entry["inputParams"]["elements"].as_array_mut().into_iter().flatten() {
1474                if element == old_ref.as_str() {
1475                    *element = Value::String(new_ref.clone());
1476                }
1477            }
1478            if done {
1479                if let Some(data) = entry["persistentData"].as_object_mut() {
1480                    data.remove(REPLACE_MARKER);
1481                }
1482                entry["enabled"] = Value::Bool(was_enabled);
1483            }
1484        }
1485        self.history.set_assembly_block(block, true);
1486        self.rerun_history();
1487        Ok(())
1488    }
1489
1490    /// Accept every PRE-TICKED suggestion of the given mates (all marked
1491    /// mates when `ids` is empty) as one undo step. Returns how many
1492    /// references were repaired.
1493    pub fn replace_accept_suggestions(&mut self, ids: &[String]) -> Result<usize, String> {
1494        let unresolved = self.replace_unresolved();
1495        let mut repairs = Vec::new();
1496        for mate in unresolved.as_array().into_iter().flatten() {
1497            let id = mate["id"].as_str().unwrap_or_default().to_string();
1498            if !ids.is_empty() && !ids.contains(&id) {
1499                continue;
1500            }
1501            for row in mate["refs"].as_array().into_iter().flatten() {
1502                if row["suggestion"]["preTicked"] == true {
1503                    repairs.push((
1504                        id.clone(),
1505                        row["ref"].as_str().unwrap_or_default().to_string(),
1506                        row["suggestion"]["ref"].as_str().unwrap_or_default().to_string(),
1507                    ));
1508                }
1509            }
1510        }
1511        if repairs.is_empty() {
1512            return Ok(0);
1513        }
1514        self.replace_repair(&repairs)?;
1515        Ok(repairs.len())
1516    }
1517}
1518