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