Skip to main content

brep_kernel/feature_pipeline/
parts_library.rs

1//! The per-document PARTS LIBRARY.
2//!
3//! Each unique part is stored ONCE per document: `{ sourceKey, sourceSignature,
4//! document (the embedded full sub-part history JSON), snapshot (the io/snapshot
5//! exact-BREP payload) }`, keyed by a document-unique part name. ACOMP instance
6//! features (`features/assembly_component.rs`) reference entries by name and
7//! carry NO payload of their own.
8//!
9//! # Residency and round-trip
10//!
11//! The library is KERNEL-RESIDENT state (thread-local, like the history cache):
12//! it is seeded through [`ingest`] (the `partsLibrary` block of a history
13//! request) or [`install_parts_library`] (the app → runner channel), mutated
14//! through [`add_part_to_library`] / [`refresh_library_entry`], and serialized
15//! for SAVE via [`parts_library_json`] — save must serialize THIS, never echo
16//! the loaded block, or healed snapshots and refreshes are lost. It clears
17//! with `clear_history_cache` (document-switch semantics).
18//!
19//! Ingest rule (stale request blocks must never undo kernel-side state): a
20//! RESIDENT entry always wins — a request echoing the block the document
21//! LOADED with predates any heal/refresh, so the block only FILLS names
22//! missing from the store.
23//!
24//! # Two seeding doors, and why they differ
25//!
26//! [`ingest`] is fill-only because it is fed by a *possibly stale echo*. The
27//! app's history runner runs OFF the caller's thread (a native thread or a
28//! browser worker), so it owns a SEPARATE store that must be kept in step with
29//! the app's — and re-sending the whole block on every run is what made a
30//! large assembly freeze the browser UI (each run stringified megabytes of
31//! payload on the main thread). The app therefore sends the library only when
32//! it CHANGES, over [`install_parts_library`], which is a *replace by content
33//! identity*: it may drop and replace, not merely fill. Two guards keep that
34//! safe — [`parts_library_revision`] tells the sender when to re-send, and
35//! [`missing_library_parts`] lets the receiver refuse a run whose parts it
36//! cannot resolve (the orphan GC below can drop entries the sender still
37//! believes are resident).
38//!
39//! # Orphan GC
40//!
41//! At the end of every `execute_history` run, entries whose part name is
42//! referenced by NO ACOMP feature in the request are dropped — orphaned
43//! payloads never accumulate in the file. (The insert flow must therefore add
44//! the ACOMP feature to the history before triggering another rebuild.)
45//!
46//! # Isolated sub-part execution
47//!
48//! [`rebuild_snapshot`] executes an embedded part document HEADLESSLY to
49//! (re)produce its snapshot — the [`add_part_to_library`] insert path and the
50//! ACOMP self-heal lane. It deliberately does NOT recurse into
51//! `execute_history`: the history cache's `retain(request_ids)` prologue would
52//! free every PARENT feature's cached handles mid-run. Instead it walks the
53//! sub-document's features through `execute_feature` with a local scene,
54//! brackets the scene-metadata store (the sub-part's un-namespaced records must
55//! never touch the parent document's), pushes the sub-document's OWN
56//! `partsLibrary` as the active library (parent and child libraries are
57//! independent), and frees every handle it registered before returning.
58
59use serde::{Deserialize, Serialize};
60use std::cell::RefCell;
61use std::collections::BTreeMap;
62
63use crate::feature_pipeline::component::NestedConstraint;
64use crate::feature_pipeline::{
65    execute_feature, scene_metadata, sheet_metal, Env, FeatureDescriptor, HistoryRequest,
66    PortRecord, SceneMap,
67};
68use crate::BrepSolid;
69use wasm_bindgen::prelude::*;
70
71// ===========================================================================
72// The entry + the resident stores
73// ===========================================================================
74
75/// One unique part payload. `dirty` is RESIDENT-ONLY state: set by
76/// [`refresh_library_entry`] (edit-in-context / update-components) when the
77/// new document builds differently, it forces the ACOMP self-heal lane on the
78/// next run even with a readable snapshot, and clears when the heal rewrites
79/// the snapshot.
80#[derive(Debug, Clone, Default, Serialize, Deserialize)]
81pub struct PartsLibraryEntry {
82    #[serde(rename = "sourceKey", default)]
83    pub source_key: String,
84    #[serde(rename = "sourceSignature", default)]
85    pub source_signature: String,
86    /// The embedded full sub-part history JSON — the durable source the
87    /// self-heal lane re-executes when the snapshot cache fails.
88    #[serde(default)]
89    pub document: serde_json::Value,
90    /// The evaluated part as an `io/snapshot` payload (the fast lane).
91    #[serde(default)]
92    pub snapshot: String,
93    /// The kernel build that produced `snapshot` ([`kernel_source_stamp`]).
94    ///
95    /// A snapshot is a CACHE of evaluated geometry, and it is only as current as
96    /// the code that evaluated it. Before this stamp an entry was served for as
97    /// long as it decoded, so a document kept the geometry of whichever kernel
98    /// first inserted the part, whatever the kernel had learned since: the
99    /// `AssemblyTest` fixture decoded 1859.122725 where a rebuild of its own
100    /// document gives 1859.151146, and the stale body failed the shell
101    /// vector-area bar at 4.7x. An entry whose stamp differs from the running
102    /// build — including one with no stamp — is rebuilt by the ACOMP lane.
103    /// Carried beside the snapshot rather than inside the `BREPSNAP` container,
104    /// whose layout is a durable format (see `io/snapshot.rs`).
105    #[serde(rename = "snapshotProducer", default, skip_serializing_if = "String::is_empty")]
106    pub snapshot_producer: String,
107    /// The part's harness ports (part-local id -> record, part-local pose),
108    /// captured from the same run as the snapshot. Derived like the snapshot:
109    /// an entry whose document declares a PORT but carries none is healed.
110    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
111    pub ports: BTreeMap<String, PortRecord>,
112    #[serde(skip)]
113    pub dirty: bool,
114    /// The statuses of the part's own assembly constraints and of every
115    /// sub-assembly below it, from the run that made `snapshot` (see
116    /// `NestedConstraint`). `None` means no run has happened in this session:
117    /// the rows are not saved, so an entry whose document carries constraints
118    /// is rebuilt once after a load (`needs_constraint_rows`) — the parent
119    /// must report how its sub-assembly's mates ended, not assume they solved.
120    #[serde(skip)]
121    pub nested_constraints: Option<Vec<NestedConstraint>>,
122    /// The solved occurrence poses of the same run, part-local frame (see
123    /// [`crate::feature_pipeline::ComponentRecord::nested_poses`]); kept and
124    /// cleared with `nested_constraints`.
125    #[serde(skip)]
126    pub nested_poses: BTreeMap<String, crate::AffineTransform>,
127    /// `nested_constraints` and `nested_poses` as SAVED beside the snapshot
128    /// (`SavedRows`): a loaded entry adopts them only under the snapshot's
129    /// own rule, so reopening a saved assembly takes the snapshot lane instead
130    /// of rebuilding every sub-assembly with mates to learn its rows again.
131    #[serde(rename = "nestedRows", default, skip_serializing_if = "Option::is_none")]
132    pub nested_rows: Option<SavedRows>,
133    /// Stable content hash of `document`, precomputed at insert/refresh/ingest
134    /// so the per-feature cache hook never re-hashes a large document.
135    #[serde(skip)]
136    pub doc_hash: u64,
137}
138
139/// The constraint rows and solved poses of the run that made an entry's
140/// snapshot, saved with it. Valid exactly when the snapshot is: the same
141/// `producer` (the kernel build that ran it, as `snapshot_producer`) and the
142/// same `document` (the stable hash, hex, of the entry's document then). An
143/// entry whose document was edited since, or that a different build snapshot,
144/// rebuilds as before. Additive and defaulted: an older file has none and
145/// rebuilds once, as before; an older build ignores the field.
146#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
147pub struct SavedRows {
148    pub producer: String,
149    pub document: String,
150    #[serde(default)]
151    pub constraints: Vec<NestedConstraint>,
152    /// Occurrence path -> part-local pose, row-major 4x4, each element as
153    /// its IEEE bits in hex: the parse here is not round-trip exact
154    /// (`serde_json` without `float_roundtrip` can land a 17-digit value one
155    /// ulp off), and a pose must come back bit for bit.
156    #[serde(default)]
157    pub poses: BTreeMap<String, Vec<String>>,
158}
159
160fn document_stamp(document: &serde_json::Value) -> String {
161    format!("{:016x}", stable_json_hash(document))
162}
163
164impl PartsLibraryEntry {
165    /// The rows of the run that just made this entry's snapshot, to save.
166    fn save_rows(&mut self) {
167        self.nested_rows = self.nested_constraints.as_ref().map(|constraints| SavedRows {
168            producer: self.snapshot_producer.clone(),
169            document: document_stamp(&self.document),
170            constraints: constraints.clone(),
171            poses: self
172                .nested_poses
173                .iter()
174                .map(|(path, pose)| (path.clone(), pose.elements.iter().map(|e| format!("{:016x}", e.to_bits())).collect()))
175                .collect(),
176        });
177    }
178
179    /// Take the saved rows of a loaded entry when they are still its
180    /// snapshot's: same producer as the snapshot and as this build, and the
181    /// document they were taken from.
182    fn adopt_saved_rows(&mut self) {
183        if self.nested_constraints.is_some() || self.snapshot.is_empty() {
184            return;
185        }
186        let Some(rows) = &self.nested_rows else { return };
187        // An unstamped build cannot tell one solver from another: rows it
188        // saved are rebuilt rather than trusted.
189        let same_build = !rows.producer.is_empty()
190            && rows.producer == self.snapshot_producer
191            && kernel_source_stamp().is_none_or(|stamp| stamp == rows.producer);
192        // `doc_hash` is the stable hash of `document`: every load door sets
193        // it just before adopting.
194        if !same_build || rows.document != format!("{:016x}", self.doc_hash) {
195            return;
196        }
197        let mut poses = BTreeMap::new();
198        for (path, hex) in &rows.poses {
199            let bits: Option<Vec<f64>> =
200                hex.iter().map(|h| u64::from_str_radix(h, 16).ok().map(f64::from_bits)).collect();
201            let Some(Ok(elements)) = bits.map(|b| <[f64; 16]>::try_from(b)) else { return };
202            let Ok(pose) = crate::AffineTransform::new(elements) else { return };
203            poses.insert(path.clone(), pose);
204        }
205        self.nested_constraints = Some(rows.constraints.clone());
206        self.nested_poses = poses;
207    }
208
209    /// Invalidate derived data when the document builds a different part.
210    /// `dirty` alone cannot carry this across a save or runner transfer: it is
211    /// serde-skipped, and a fresh receiver has no previous document to compare.
212    /// A blank snapshot makes that receiver take the self-heal lane too.
213    fn invalidate_snapshot(&mut self) {
214        self.snapshot.clear();
215        self.snapshot_producer.clear();
216        self.ports.clear();
217        self.dirty = true;
218        self.nested_constraints = None;
219        self.nested_poses.clear();
220        self.nested_rows = None;
221    }
222}
223
224/// The library map shape as it travels in the history request / save file.
225pub type PartsLibraryMap = BTreeMap<String, PartsLibraryEntry>;
226
227/// This build's identity for [`PartsLibraryEntry::snapshot_producer`]: the hash
228/// of the kernel source that `build.rs` computes. `None` when the build could
229/// not compute it, which disables stale-snapshot invalidation rather than
230/// making every snapshot look stale on every run.
231pub fn kernel_source_stamp() -> Option<&'static str> {
232    option_env!("BREP_KERNEL_SOURCE_HASH")
233}
234
235thread_local! {
236    /// The open document's library (the ROOT store).
237    static ROOT: RefCell<PartsLibraryMap> = RefCell::new(BTreeMap::new());
238    /// Active-library stack for isolated sub-document runs: a nested ACOMP
239    /// resolves against ITS document's library, never the parent's.
240    static STACK: RefCell<Vec<PartsLibraryMap>> = const { RefCell::new(Vec::new()) };
241    /// Monotonic REVISION of the ROOT store, bumped by every mutation that
242    /// actually CHANGES it (insert, refresh, heal, GC drop, install, clear).
243    /// The app polls it to decide whether a background runner's resident copy
244    /// is stale — the library no longer rides on every history request, so
245    /// this counter is what tells the app when to re-send it. Thread-local
246    /// like the store itself: main and each runner count their own store.
247    static REVISION: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
248}
249
250/// Record that the ROOT store changed. Call it from EVERY mutation that
251/// actually alters content — never speculatively: `ingest` and
252/// `gc_after_rebuild` run on every history run, so an unconditional bump there
253/// would make the app re-send the whole library every edit, which is the cost
254/// this counter exists to avoid.
255fn bump_revision() {
256    REVISION.with(|revision| revision.set(revision.get().wrapping_add(1)));
257}
258
259/// The ROOT store's current revision (see `REVISION`). Two reads that differ
260/// mean the library changed in between; two that agree mean it did not.
261pub fn parts_library_revision() -> u64 {
262    REVISION.with(std::cell::Cell::get)
263}
264
265/// Stable content hash of a JSON value: sorted-key walk (never trusts a
266/// serializer's map ordering), string leaves verbatim.
267pub fn stable_json_hash(value: &serde_json::Value) -> u64 {
268    use std::hash::{Hash, Hasher};
269    fn walk(value: &serde_json::Value, hasher: &mut impl Hasher) {
270        match value {
271            serde_json::Value::Null => 0u8.hash(hasher),
272            serde_json::Value::Bool(flag) => {
273                1u8.hash(hasher);
274                flag.hash(hasher);
275            }
276            serde_json::Value::Number(number) => {
277                2u8.hash(hasher);
278                number.to_string().hash(hasher);
279            }
280            serde_json::Value::String(text) => {
281                3u8.hash(hasher);
282                text.hash(hasher);
283            }
284            serde_json::Value::Array(items) => {
285                4u8.hash(hasher);
286                for item in items {
287                    walk(item, hasher);
288                }
289            }
290            serde_json::Value::Object(map) => {
291                5u8.hash(hasher);
292                let mut keys: Vec<&String> = map.keys().collect();
293                keys.sort();
294                for key in keys {
295                    key.hash(hasher);
296                    walk(&map[key], hasher);
297                }
298            }
299        }
300    }
301    let mut hasher = std::collections::hash_map::DefaultHasher::new();
302    walk(value, &mut hasher);
303    hasher.finish()
304}
305
306/// Read the entry for a part name from the ACTIVE library: the innermost
307/// isolated-run library when one is pushed, else the root store.
308pub(crate) fn active_entry(part_name: &str) -> Option<PartsLibraryEntry> {
309    STACK.with(|stack| {
310        let stack = stack.borrow();
311        if let Some(top) = stack.last() {
312            return top.get(part_name).cloned();
313        }
314        ROOT.with(|root| root.borrow().get(part_name).cloned())
315    })
316}
317
318/// Write a healed snapshot back into the ACTIVE library entry and clear its
319/// dirty flag. A heal inside an isolated run updates the pushed (temporary)
320/// library only — the parent entry's embedded document is NOT rewritten (its
321/// re-snapshot bakes the healed geometry anyway; the stale inner snapshot heals
322/// again on the next full sub-document run).
323pub(crate) fn heal_entry(part_name: &str, rebuilt: &RebuiltPart) {
324    let heal = |map: &mut PartsLibraryMap| {
325        if let Some(entry) = map.get_mut(part_name) {
326            entry.snapshot = rebuilt.snapshot.clone();
327            entry.snapshot_producer = kernel_source_stamp().unwrap_or_default().to_string();
328            entry.ports = rebuilt.ports.clone();
329            entry.nested_constraints = Some(rebuilt.nested_constraints.clone());
330            entry.nested_poses = rebuilt.nested_poses.clone();
331            entry.save_rows();
332            entry.dirty = false;
333        }
334    };
335    STACK.with(|stack| {
336        let mut stack = stack.borrow_mut();
337        if let Some(top) = stack.last_mut() {
338            heal(top);
339        } else {
340            ROOT.with(|root| heal(&mut root.borrow_mut()));
341            // A heal rewrites a ROOT entry's snapshot, so the store changed.
342            // (It does NOT change the entry's content IDENTITY — see
343            // `install` — so re-sending a healed library never clobbers a
344            // runner's own, independently-derived heal.)
345            bump_revision();
346        }
347    });
348}
349
350/// Drop the whole library (document switch — wired into `clear_history_cache`).
351pub(crate) fn clear_all() {
352    let had_entries = ROOT.with(|root| {
353        let mut root = root.borrow_mut();
354        let had = !root.is_empty();
355        root.clear();
356        had
357    });
358    STACK.with(|stack| stack.borrow_mut().clear());
359    if had_entries {
360        bump_revision();
361    }
362}
363
364// ===========================================================================
365// execute_history hooks: ingest, cache fingerprint, GC
366// ===========================================================================
367
368/// The ACOMP dispatch predicate — shared by the fingerprint hook, the GC scan,
369/// and (as adjacent literals) the `execute_feature` match arm, so they can
370/// never disagree on what counts as a component instance.
371pub(crate) fn is_acomp_type(feature_type: &str) -> bool {
372    matches!(feature_type, "ACOMP" | "ASSEMBLY COMPONENT")
373}
374
375/// Merge a request's `partsLibrary` block into the root store (start of every
376/// `execute_history` run — this is how a LOADED document seeds the library).
377/// A RESIDENT entry always wins: mid-session the kernel store is the source of
378/// truth (it carries heals, refreshes, and pending-dirty state the request's
379/// echo of the loaded block predates); the block only ever FILLS missing names
380/// (document load starts from an empty store, cleared by `clear_history_cache`).
381pub(crate) fn ingest(request_map: &PartsLibraryMap) {
382    if request_map.is_empty() {
383        return;
384    }
385    let seeded = ROOT.with(|root| {
386        let mut root = root.borrow_mut();
387        let mut seeded = false;
388        for (name, incoming) in request_map {
389            if root.contains_key(name) {
390                continue;
391            }
392            let mut entry = incoming.clone();
393            // A loaded block's entry gets the same stamp as an inserted one, so a
394            // document saved before per-loop ids existed picks them up on open.
395            stamp_document_loop_ids(&mut entry.document);
396            entry.doc_hash = stable_json_hash(&entry.document);
397            // A document saved before parts carried their ports heals once.
398            entry.dirty = needs_port_heal(&entry);
399            entry.adopt_saved_rows();
400            root.insert(name.clone(), entry);
401            seeded = true;
402        }
403        seeded
404    });
405    if seeded {
406        bump_revision();
407    }
408}
409
410/// Whether an entry must heal to pick up its connection points: its document
411/// declares some (at any depth — a nested assembly's parts count) but the entry
412/// carries no records, i.e. it was captured before they existed.
413fn needs_port_heal(entry: &PartsLibraryEntry) -> bool {
414    entry.ports.is_empty() && document_declares_ports(&entry.document)
415}
416
417/// Whether an entry must be rebuilt for its constraint rows: its document — or
418/// a sub-assembly it embeds — carries assembly constraints, and no run in this
419/// session has reported them ([`PartsLibraryEntry::nested_constraints`]).
420pub(crate) fn needs_constraint_rows(entry: &PartsLibraryEntry) -> bool {
421    entry.nested_constraints.is_none() && document_has_constraints(&entry.document)
422}
423
424/// Whether `document`, or a library entry it embeds at any depth, has a
425/// non-empty `assembly.constraints` list.
426fn document_has_constraints(document: &serde_json::Value) -> bool {
427    let own = document
428        .get("assembly")
429        .and_then(|block| block.get("constraints"))
430        .and_then(serde_json::Value::as_array)
431        .is_some_and(|constraints| !constraints.is_empty());
432    own || document
433        .get("partsLibrary")
434        .and_then(serde_json::Value::as_object)
435        .is_some_and(|library| {
436            library
437                .values()
438                .filter_map(|entry| entry.get("document"))
439                .any(document_has_constraints)
440        })
441}
442
443/// Whether `document` — or a library entry it embeds — declares a connection
444/// point or a WAYPOINT, the two things that put a record in `scene.ports`.
445fn document_declares_ports(document: &serde_json::Value) -> bool {
446    match document {
447        serde_json::Value::Object(map) => {
448            map.get(crate::feature_pipeline::ports::PORTS_BLOCK)
449                .and_then(serde_json::Value::as_array)
450                .is_some_and(|block| !block.is_empty())
451                || matches!(
452                    map.get("type").and_then(serde_json::Value::as_str),
453                    Some("WP" | "WAYPOINT")
454                )
455                || map.values().any(document_declares_ports)
456        }
457        serde_json::Value::Array(items) => items.iter().any(document_declares_ports),
458        _ => false,
459    }
460}
461
462/// The CONTENT IDENTITY of an entry: what makes two entries the same PART.
463/// `snapshot` is deliberately excluded — a heal re-derives the snapshot from
464/// the same `document`, so it must not read as a different part. This is the
465/// same identity [`mix_descriptor_fingerprint`] mixes into an ACOMP's cache
466/// fingerprint, so "install kept the resident entry" and "the instance
467/// replayed from cache" can never disagree.
468fn identity(entry: &PartsLibraryEntry) -> (&str, &str, u64) {
469    (&entry.source_key, &entry.source_signature, entry.doc_hash)
470}
471
472/// INSTALL a library wholesale — the app → history-runner channel (the runner
473/// owns its own thread-local store, and the per-run request no longer carries
474/// the block, so this is how a background thread/worker learns the library).
475///
476/// Unlike `ingest` (fill-only, resident-wins) this makes the store MATCH
477/// `incoming` BY CONTENT IDENTITY, which is what a cache channel needs:
478///
479/// * a name missing from the store is inserted (stamped + hashed like ingest);
480/// * a name whose resident entry has the SAME identity is KEPT VERBATIM — that
481///   is what protects a runner-side heal, whose only trace is a rewritten
482///   `snapshot`, from being clobbered by a re-send;
483/// * a name whose resident entry has a DIFFERENT identity but the SAME build
484///   ([`same_build`]: only the symbol, pads or BOM attributes moved) is
485///   REPLACED with the resident snapshot and ports carried over — they still
486///   describe the part, so nothing is rebuilt;
487/// * a name whose resident entry builds DIFFERENTLY is REPLACED and marked
488///   `dirty`, so the ACOMP self-heal lane re-derives every instance (`dirty` is
489///   `#[serde(skip)]` and cannot ride the wire — it is derived HERE, from the
490///   mismatch);
491/// * a resident name ABSENT from `incoming` is dropped.
492///
493/// Returns whether anything changed (and bumps the revision if so).
494pub fn install_parts_library(incoming: &PartsLibraryMap) -> bool {
495    let mut changed = false;
496    ROOT.with(|root| {
497        let mut root = root.borrow_mut();
498        root.retain(|name, _| {
499            let keep = incoming.contains_key(name);
500            changed |= !keep;
501            keep
502        });
503        for (name, entry) in incoming {
504            let mut document = entry.document.clone();
505            stamp_document_loop_ids(&mut document);
506            let doc_hash = stable_json_hash(&document);
507            // Same part: keep the RESIDENT copy — it may carry a heal this
508            // side derived and the sender never saw. Decided on the document
509            // alone, so a kept entry is neither cloned nor derived.
510            if root.get(name).is_some_and(|resident| {
511                identity(resident) == (entry.source_key.as_str(), entry.source_signature.as_str(), doc_hash)
512            }) {
513                continue;
514            }
515            let mut entry = PartsLibraryEntry { document, doc_hash, ..entry.clone() };
516            entry.dirty = needs_port_heal(&entry);
517            entry.adopt_saved_rows();
518            match root.get(name) {
519                // A new version that builds the same (a symbol, pads or BOM
520                // attribute edit): take its document and signature, but the
521                // resident snapshot and ports still describe it — keep them, and
522                // any heal they carry, rather than rebuilding the part.
523                Some(resident) if same_stamped_build(&resident.document, &entry.document) => {
524                    entry.snapshot = resident.snapshot.clone();
525                    entry.snapshot_producer = resident.snapshot_producer.clone();
526                    entry.ports = resident.ports.clone();
527                    // The resident's rows, else the ones this entry just adopted.
528                    if resident.nested_constraints.is_some() {
529                        entry.nested_constraints = resident.nested_constraints.clone();
530                        entry.nested_poses = resident.nested_poses.clone();
531                    }
532                    // The same build of a new document: the rows still describe it.
533                    entry.save_rows();
534                    entry.dirty = resident.dirty || needs_port_heal(&entry);
535                }
536                // Genuinely different content: replace, and force the self-heal
537                // lane so every instance follows the new document.
538                Some(_) => entry.invalidate_snapshot(),
539                None => {}
540            }
541            root.insert(name.clone(), entry);
542            changed = true;
543        }
544    });
545    if changed {
546        bump_revision();
547    }
548    changed
549}
550
551/// A CLONE of the root store — what the app hands a runner through
552/// [`install_parts_library`]. (`parts_library_json` is the SAVE door; this is
553/// the in-process one, with no JSON round trip on the native paths.)
554pub fn parts_library_map() -> PartsLibraryMap {
555    ROOT.with(|root| root.borrow().clone())
556}
557
558/// The PREFLIGHT for a run: every part name the request's ACOMP features
559/// reference that the root store cannot resolve, in request order.
560///
561/// A background runner calls this BEFORE executing a run and refuses the run
562/// when it is non-empty. That is the drift-proof half of the library channel:
563/// the orphan GC at the end of every run can legitimately drop entries the
564/// sender still believes are resident (undo to zero components GCs the runner's
565/// store while the sender's store, which never ran, keeps them), and no
566/// revision bookkeeping can see that. Missing CONTENT is observable; that is
567/// what this checks.
568pub fn missing_library_parts(request: &HistoryRequest) -> Vec<String> {
569    let mut missing = Vec::new();
570    ROOT.with(|root| {
571        let root = root.borrow();
572        for descriptor in &request.features {
573            if !is_acomp_type(&descriptor.feature_type) {
574                continue;
575            }
576            let Some(name) = descriptor
577                .input_params
578                .get("partName")
579                .and_then(|value| value.as_str())
580                .map(str::trim)
581                .filter(|name| !name.is_empty())
582            else {
583                continue;
584            };
585            if !root.contains_key(name) && !missing.iter().any(|seen| seen == name) {
586                missing.push(name.to_string());
587            }
588        }
589    });
590    missing
591}
592
593/// The history-cache hook for one descriptor: mix the referenced library
594/// entry's CONTENT identity (source key/signature/document hash — snapshot
595/// deliberately excluded, a heal must not dirty the instance) into the
596/// feature fingerprint, and report whether a DIRTY entry forces re-execution.
597/// Non-ACOMP descriptors pass through untouched.
598pub(crate) fn mix_descriptor_fingerprint(
599    descriptor: &FeatureDescriptor,
600    fingerprint: u64,
601) -> (u64, bool) {
602    if !is_acomp_type(&descriptor.feature_type) {
603        return (fingerprint, false);
604    }
605    use std::hash::{Hash, Hasher};
606    let mut hasher = std::collections::hash_map::DefaultHasher::new();
607    fingerprint.hash(&mut hasher);
608    let part_name = descriptor
609        .input_params
610        .get("partName")
611        .and_then(|value| value.as_str())
612        .unwrap_or("");
613    part_name.hash(&mut hasher);
614    let mut force_dirty = false;
615    match active_entry(part_name) {
616        Some(entry) => {
617            1u8.hash(&mut hasher);
618            entry.source_key.hash(&mut hasher);
619            entry.source_signature.hash(&mut hasher);
620            entry.doc_hash.hash(&mut hasher);
621            force_dirty = entry.dirty || super::extension::document_uses_extensions(&entry.document);
622        }
623        None => 0u8.hash(&mut hasher),
624    }
625    (hasher.finish(), force_dirty)
626}
627
628/// Orphan GC (end of every `execute_history` run): retain only entries some
629/// ACOMP feature in the REQUEST references (the request always carries the full
630/// feature list — editor stop points truncate execution, not the list).
631pub(crate) fn gc_after_rebuild(request: &HistoryRequest) {
632    let referenced: std::collections::BTreeSet<String> = request
633        .features
634        .iter()
635        .filter(|descriptor| is_acomp_type(&descriptor.feature_type))
636        .filter_map(|descriptor| {
637            descriptor
638                .input_params
639                .get("partName")
640                .and_then(|value| value.as_str())
641                .map(|name| name.trim().to_string())
642        })
643        .collect();
644    let dropped = ROOT.with(|root| {
645        let mut root = root.borrow_mut();
646        let before = root.len();
647        root.retain(|name, _| referenced.contains(name));
648        before != root.len()
649    });
650    if dropped {
651        bump_revision();
652    }
653}
654
655// ===========================================================================
656// Isolated sub-part execution (the self-heal / insert lane)
657// ===========================================================================
658
659/// Execute an embedded part document headlessly and return its surviving
660/// solids as PART-LOCAL `(scene name, owned BREP)` pairs in history order.
661/// Never touches the history cache or the parent scene; every handle it
662/// registers is freed before returning. See the module doc for why this must
663/// not recurse into `execute_history`.
664fn run_isolated_document(
665    document: &serde_json::Value,
666    pmi: Option<&mut Option<crate::feature_pipeline::pmi::PmiReport>>,
667) -> Result<IsolatedRun, String> {
668    let mut request: HistoryRequest = serde_json::from_value(document.clone())
669        .map_err(|error| format!("embedded part document does not parse: {error}"))?;
670    // A stale editor stop point saved into a library part must not silently
671    // truncate the part on every heal.
672    request.stop_at_id = None;
673    request.stop_before_id = None;
674
675    // The sub-document's own library is the active one for this run (a nested
676    // assembly's ACOMPs resolve against it; missing entries are clean errors,
677    // never a fallback to the parent's library). Its entries arrive raw from
678    // the JSON, so the port heal is decided here as `ingest` decides it for a
679    // loaded document — an inner part saved before parts carried their ports
680    // must not take the fast lane and publish none.
681    STACK.with(|stack| {
682        let mut library = std::mem::take(&mut request.parts_library);
683        for entry in library.values_mut() {
684            entry.dirty = needs_port_heal(entry);
685        }
686        stack.borrow_mut().push(library);
687    });
688    let electronics_part = ["symbol", "pads"]
689        .iter()
690        .any(|key| document.get(*key).is_some_and(serde_json::Value::is_object))
691        || !request.ports.is_empty();
692    let outcome = run_isolated_features(
693        &request,
694        pmi,
695        electronics_part,
696        crate::feature_pipeline::ports::document_is_boundary(document),
697    );
698    STACK.with(|stack| {
699        stack.borrow_mut().pop();
700    });
701    outcome
702}
703
704/// The feature walk of [`run_isolated_document`], separated so the library
705/// stack push/pop brackets every exit path.
706/// What an isolated part run yields: the surviving solids (part-local names,
707/// history order), the part's ports (part-local ids, part-local pose) and the
708/// statuses of its assembly constraints (part-local ids, every level below
709/// included) and the solved pose of every occurrence it places, at any depth
710/// (part-local frame).
711type IsolatedRun = (
712    Vec<(String, BrepSolid)>,
713    BTreeMap<String, PortRecord>,
714    Vec<NestedConstraint>,
715    BTreeMap<String, crate::AffineTransform>,
716);
717
718fn run_isolated_features(
719    request: &HistoryRequest,
720    pmi: Option<&mut Option<crate::feature_pipeline::pmi::PmiReport>>,
721    electronics_part: bool,
722    boundary: bool,
723) -> Result<IsolatedRun, String> {
724    let _plugin_scope = super::extension::enter(&request.plugins);
725    let env = Env::build(&request.expressions, &request.configurator).unwrap_or_else(Env::poisoned);
726    let mut scene = SceneMap::default();
727    // The same pre-walk seed `execute_history` does (see `ports.rs`): a part's
728    // own spline must attach to its own connection point on this lane too.
729    // Nothing here caches, so the versions are dropped.
730    crate::feature_pipeline::ports::seed_history_run(request, &mut scene, &env);
731    let mut results = Vec::with_capacity(request.features.len());
732    let mut all_handles: Vec<u32> = Vec::new();
733    let mut error: Option<String> = None;
734    for descriptor in &request.features {
735        let result = execute_feature(descriptor, &env, &scene);
736        scene.apply(&result);
737        all_handles.extend(result.added.iter().map(|added| added.handle));
738        // Replicate the loop's producer stamp so the snapshot carries the same
739        // topology metadata a real history run would have written.
740        if !result.id.is_empty() {
741            let mut seed = serde_json::Map::new();
742            seed.insert(
743                "sourceFeatureId".into(),
744                serde_json::Value::String(result.id.clone()),
745            );
746            for added in &result.added {
747                for (_, face_name) in &added.face_names {
748                    scene_metadata::merge_record(face_name, &seed, false);
749                }
750                for (_, edge_name) in &added.edge_names {
751                    scene_metadata::merge_record(edge_name, &seed, false);
752                }
753            }
754        }
755        if let Some(message) = &result.error {
756            error = Some(format!("feature '{}': {message}", result.id));
757            break;
758        }
759        results.push(result);
760    }
761
762    // A sub-assembly is RIGID to its parent: its own constraints solve here,
763    // inside its own run, so the snapshot bakes the solved poses and the
764    // parent's mates then place it as one body (the NX / SolidWorks default).
765    // Same lifecycle `execute_history` runs, NOT its `finish_history_run`,
766    // which would replace the live assembly session in the middle of the
767    // parent's run. It re-poses members under their existing handles, so it
768    // has to run before the survivors are cloned out of the registry, and
769    // before the ports tail so the declared points follow the solved poses.
770    let mut nested_constraints = Vec::new();
771    let mut nested_poses = BTreeMap::new();
772    if error.is_none() {
773        if let Some(state) = request.assembly.as_ref().filter(|state| !state.constraints.is_empty()) {
774            let mut state = state.clone();
775            crate::feature_pipeline::assembly::lifecycle::run_constraints(&mut state, &mut scene, &env);
776            nested_constraints.extend(state.constraints.iter().map(NestedConstraint::from_entry));
777        }
778        // Every level below arrives on the component records, namespaced by
779        // the ACOMP that placed it.
780        for record in scene.components.values() {
781            nested_constraints.extend(record.nested_constraints.iter().cloned());
782            // Where every occurrence ended up, in THIS document's frame: the
783            // solved record pose, and each deeper occurrence composed onto it.
784            nested_poses.insert(record.id.clone(), record.transform);
785            // (No `?`: the registered handles are freed below on every path;
786            // both poses are rigid, so the compose cannot fail.)
787            for (path, pose) in &record.nested_poses {
788                if let Ok(posed) = crate::feature_pipeline::component::compose(&record.transform, pose) {
789                    nested_poses.insert(crate::feature_pipeline::component::namespaced(&record.id, path), posed);
790                }
791            }
792        }
793    }
794
795    // Survivors: added solids still scene-resident under their own name, in
796    // history order (a consumed/superseded solid's name no longer maps to it).
797    let mut members = Vec::new();
798    if error.is_none() {
799        for result in &results {
800            for added in &result.added {
801                if scene.resolve_solid(&added.name) == Some(added.handle) {
802                    let solid =
803                        crate::with_registered_solid_str(added.handle, |solid| Ok(solid.clone()))?;
804                    members.push((added.name.clone(), solid));
805                }
806            }
807        }
808    }
809    // The document's own PMI, resolved while its solids are still RESIDENT —
810    // every reference resolves through the registry, so the tail has to run
811    // before the free loop below. Plugin annotations must also resolve during
812    // snapshot rebuild: unavailable providers cannot silently pass as fresh parts.
813    let has_plugin_annotations = request.pmi.as_ref().is_some_and(|state|
814        state.views.iter().flat_map(|v| &v.annotations).any(|a| a.enabled && a.kind.contains('/')));
815    if pmi.is_some() || has_plugin_annotations {
816        let report = crate::feature_pipeline::pmi::finish_history_run(request, &scene, &env);
817        if let Some(row) = report.as_ref().into_iter().flat_map(|r| &r.views)
818            .flat_map(|v| &v.annotations)
819            .find(|a| a.enabled && a.kind.contains('/') && a.status == crate::feature_pipeline::pmi::PmiStatus::Error)
820        {
821            if error.is_none() { error = Some(format!("annotation '{}': {}", row.id, row.message)); }
822        }
823        if let Some(out) = pmi { *out = report; }
824    }
825    // The document's own declared ports, resolved here for the same reason the
826    // PMI is: a point may reference the part's geometry, and the geometry is
827    // only resident until the free loop below. This is the one place a part's
828    // connection points are captured, so the tail HAS to run on this lane too —
829    // without it every placed part would arrive with an empty port map.
830    let declared: Vec<String> = crate::feature_pipeline::ports::finish_history_run(
831        request,
832        &mut scene,
833        &env,
834    )
835    .report
836    .map(|report| report.points.into_iter().map(|row| row.address).collect())
837    .unwrap_or_default();
838    for handle in all_handles {
839        sheet_metal::remove_tree(handle);
840        crate::free_registered_solid(handle);
841    }
842    // The part's connection points: every record the run left in the scene (a
843    // nested assembly's component points are already namespaced by its ACOMPs),
844    // through the ENCAPSULATION filter. A boundary part — a PCB, or anything
845    // flagged one — exports only what it declares itself, so a parent can reach
846    // its connectors and nothing inside it. See `ports::export_ports`.
847    let scene_ports: BTreeMap<String, PortRecord> = scene
848        .ports
849        .iter()
850        .map(|(id, port)| (id.clone(), port.clone()))
851        .collect();
852    let ports = crate::feature_pipeline::ports::export_ports(&scene_ports, &declared, boundary);
853    match error {
854        Some(message) => Err(message),
855        // An electronics part may have a symbol and ports before a 3D model is
856        // available. Keep the occurrence and its endpoints without inventing a
857        // solid. A generic empty modeling document remains an error.
858        None if members.is_empty() && !electronics_part =>
859            Err("embedded part document produced no solids".into()),
860        None => Ok((members, ports, nested_constraints, nested_poses)),
861    }
862}
863
864/// Execute an embedded part document and snapshot the result — the ONE
865/// snapshot-(re)build lane ([`add_part_to_library`] insert + ACOMP self-heal).
866/// The scene-metadata store is bracketed around the run: the sub-part's
867/// un-namespaced records feed the snapshot capture and are then discarded, so
868/// the parent document's store is byte-identical afterwards on every path.
869pub(crate) fn rebuild_snapshot(
870    document: &serde_json::Value,
871) -> Result<(String, BTreeMap<String, PortRecord>), String> {
872    rebuild_part(document).map(|rebuilt| (rebuilt.snapshot, rebuilt.ports))
873}
874
875/// What [`rebuild_part`] makes of a part document: the snapshot, the ports and
876/// the constraint rows of the one run, which the library entry keeps together.
877#[derive(Debug, Clone)]
878pub(crate) struct RebuiltPart {
879    pub snapshot: String,
880    pub ports: BTreeMap<String, PortRecord>,
881    pub nested_constraints: Vec<NestedConstraint>,
882    pub nested_poses: BTreeMap<String, crate::AffineTransform>,
883}
884
885/// How many rebuilt documents [`rebuild_part`] keeps.
886const REBUILT_MEMO_LEN: usize = 32;
887
888thread_local! {
889    /// Documents [`rebuild_part`] executed on this thread, most recent first,
890    /// with what they built: `(content hash, the document, the result)`. One
891    /// definition placed by several sub-assemblies (on l500 every module
892    /// places the same cells) is a copy per placing library, identical in
893    /// content: an edit used to rebuild it once per copy. A hit needs the
894    /// SAME document, compared in full, so a stale hit is impossible; the
895    /// isolated run reads nothing else (its own library rides inside the
896    /// document, and the scene-metadata store is bracketed around it). The
897    /// one outside input is the plugin provider, so a document that uses
898    /// extensions is never kept: it replays against the active provider.
899    static REBUILT: RefCell<Vec<(u64, serde_json::Value, RebuiltPart)>> = const { RefCell::new(Vec::new()) };
900}
901
902
903/// Forget every memoized rebuild (with the history cache).
904pub(crate) fn clear_rebuilt_memo() {
905    REBUILT.with(|memo| memo.borrow_mut().clear());
906}
907
908/// [`rebuild_snapshot`] with the run's constraint rows and solved poses.
909pub(crate) fn rebuild_part(document: &serde_json::Value) -> Result<RebuiltPart, String> {
910    let hash = stable_json_hash(document);
911    let memoized = !super::extension::document_uses_extensions(document);
912    let hit = REBUILT.with(|memo| {
913        let mut memo = memo.borrow_mut();
914        let index = memo.iter().position(|(h, d, _)| memoized && *h == hash && d == document)?;
915        let entry = memo.remove(index);
916        let rebuilt = entry.2.clone();
917        memo.insert(0, entry);
918        Some(rebuilt)
919    });
920    if let Some(rebuilt) = hit {
921        return Ok(rebuilt);
922    }
923    let outcome = rebuild_part_uncached(document);
924    match &outcome {
925        Ok(rebuilt) if memoized => REBUILT.with(|memo| {
926            let mut memo = memo.borrow_mut();
927            memo.insert(0, (hash, document.clone(), rebuilt.clone()));
928            memo.truncate(REBUILT_MEMO_LEN);
929        }),
930        _ => {}
931    }
932    outcome
933}
934
935fn rebuild_part_uncached(document: &serde_json::Value) -> Result<RebuiltPart, String> {
936    let saved = scene_metadata::take_store();
937    let outcome = run_isolated_document(document, None).and_then(|(members, ports, nested_constraints, nested_poses)| {
938        let refs: Vec<(&str, &BrepSolid)> = members
939            .iter()
940            .map(|(name, solid)| (name.as_str(), solid))
941            .collect();
942        crate::snapshot_solids(&refs).map(|snapshot| RebuiltPart {
943            snapshot,
944            ports,
945            nested_constraints,
946            nested_poses,
947        })
948    });
949    scene_metadata::restore_store(saved);
950    outcome
951}
952
953/// Whether an isolated part run is in progress (a library is pushed). Its
954/// component ids are the SUB-document's, and collide with the parent's.
955pub(crate) fn in_isolated_run() -> bool {
956    STACK.with(|stack| !stack.borrow().is_empty())
957}
958
959/// An embedded part document's OWN PMI, resolved against the geometry the
960/// document builds — the pair the STEP writer needs to put a part's
961/// annotations into the part's own `PRODUCT` rather than into the assembly
962/// that places it (`io/step/assembly.rs`).
963///
964/// `None` when the document carries no PMI block, when it does not run, or
965/// when nothing resolved. The scene-metadata store is bracketed exactly as
966/// [`rebuild_snapshot`] brackets it, so the caller's store is byte-identical
967/// afterwards on every path.
968pub(crate) fn document_pmi(
969    document: &serde_json::Value,
970) -> Option<(crate::feature_pipeline::pmi::PmiState, crate::feature_pipeline::pmi::PmiReport)> {
971    let state: crate::feature_pipeline::pmi::PmiState = serde_json::from_value(document.get("pmi")?.clone()).ok()?;
972    if state.views.is_empty() {
973        return None;
974    }
975    let saved = scene_metadata::take_store();
976    let mut report = None;
977    let outcome = run_isolated_document(document, Some(&mut report));
978    scene_metadata::restore_store(saved);
979    outcome.ok()?;
980    report.map(|report| (state, report))
981}
982
983// ===========================================================================
984// The app-facing insert / update / save surface
985// ===========================================================================
986
987/// A part name not yet used in the root store: `requested`, else
988/// `requested-2`, `requested-3`, …
989fn unique_name(root: &PartsLibraryMap, requested: &str) -> String {
990    if !root.contains_key(requested) {
991        return requested.to_string();
992    }
993    for counter in 2.. {
994        let candidate = format!("{requested}-{counter}");
995        if !root.contains_key(&candidate) {
996            return candidate;
997        }
998    }
999    unreachable!("counter loop is unbounded")
1000}
1001
1002/// Stamp per-loop sketch ids onto an entry's document, in place — run at EVERY
1003/// door into the library (insert, refresh, and the loaded-block seeding), so a
1004/// part gets durable loop ids no matter how it arrived.
1005///
1006/// The engine also stamps a document on LOAD
1007/// (`EngineState::set_history_json`), but a part inserted or refreshed through
1008/// the assembly lanes never passes through that: `add_part_to_library` and
1009/// `refresh_library_entry` take a document STRING straight from a caller (the
1010/// file panel inserts a saved `.nbrep`; update-components commits an edited
1011/// one). Stamping here rather than at those call sites means a future lane that
1012/// installs a document cannot silently skip it.
1013///
1014/// This renames nothing — the kernel derives the same loop ids every run. What
1015/// it adds is durability: a stored id survives deleting the edge it was derived
1016/// from, which derivation alone cannot. See `features/sketch/loop_ids`.
1017fn stamp_document_loop_ids(document: &mut serde_json::Value) {
1018    let Some(features) = document
1019        .get_mut("features")
1020        .and_then(serde_json::Value::as_array_mut)
1021    else {
1022        return;
1023    };
1024    for feature in features {
1025        if feature.get("type").and_then(serde_json::Value::as_str) != Some("S") {
1026            continue;
1027        }
1028        let Some(sketch) = feature
1029            .get_mut("persistentData")
1030            .and_then(|data| data.get_mut("sketch"))
1031        else {
1032            continue;
1033        };
1034        crate::feature_pipeline::assign_sketch_loop_ids(sketch);
1035    }
1036}
1037
1038/// INSERT: hand the opened sub-part document over to the library. Executes the
1039/// document once, snapshots, stores the entry, and returns the EFFECTIVE part
1040/// name (the ACOMP `partName` the app must reference):
1041///
1042/// - a live entry with the same `sourceKey` AND `sourceSignature` is reused
1043///   verbatim (no re-execution, instant re-insert);
1044/// - same `sourceKey` but a DIFFERENT signature gets a fresh entry under a
1045///   disambiguated name (existing instances keep their version; the explicit
1046///   refresh lane is [`refresh_library_entry`]);
1047/// - a requested name taken by different content is disambiguated (`name-2`).
1048///
1049/// The wasm export. A NATIVE caller must call [`add_part_to_library_impl`]:
1050/// building a `JsValue` off wasm32 panics inside a frame that cannot unwind,
1051/// so an error returned through this door aborts the whole app (the audit's
1052/// Insert component crash).
1053#[wasm_bindgen]
1054pub fn add_part_to_library(
1055    name: &str,
1056    source_key: &str,
1057    source_signature: &str,
1058    document_json: &str,
1059) -> Result<String, JsValue> {
1060    add_part_to_library_impl(name, source_key, source_signature, document_json)
1061        .map_err(|error| JsValue::from_str(&error))
1062}
1063
1064/// [`add_part_to_library`] for native callers: the same insert, its error as
1065/// text.
1066pub fn add_part_to_library_impl(
1067    name: &str,
1068    source_key: &str,
1069    source_signature: &str,
1070    document_json: &str,
1071) -> Result<String, String> {
1072    let requested = name.trim();
1073    if requested.is_empty() {
1074        return Err("add_part_to_library: empty part name".to_string());
1075    }
1076    let reused = ROOT.with(|root| {
1077        root.borrow().iter().find_map(|(entry_name, entry)| {
1078            (entry.source_key == source_key && entry.source_signature == source_signature)
1079                .then(|| entry_name.clone())
1080        })
1081    });
1082    if let Some(entry_name) = reused {
1083        return Ok(entry_name);
1084    }
1085    let mut document: serde_json::Value = serde_json::from_str(document_json)
1086        .map_err(|error| format!("add_part_to_library: {error}"))?;
1087    // Stamp BEFORE the hash and the snapshot so both describe the stored form —
1088    // a later re-stamp then finds nothing to change and cannot dirty the entry.
1089    stamp_document_loop_ids(&mut document);
1090    let RebuiltPart { snapshot, ports, nested_constraints, nested_poses } = rebuild_part(&document)
1091        .map_err(|error| format!("add_part_to_library: {error}"))?;
1092    let mut entry = PartsLibraryEntry {
1093        source_key: source_key.to_string(),
1094        source_signature: source_signature.to_string(),
1095        doc_hash: stable_json_hash(&document),
1096        document,
1097        snapshot,
1098        snapshot_producer: kernel_source_stamp().unwrap_or_default().to_string(),
1099        ports,
1100        dirty: false,
1101        nested_constraints: Some(nested_constraints),
1102        nested_poses,
1103        nested_rows: None,
1104    };
1105    entry.save_rows();
1106    let final_name = ROOT.with(|root| {
1107        let mut root = root.borrow_mut();
1108        let final_name = unique_name(&root, requested);
1109        root.insert(final_name.clone(), entry);
1110        final_name
1111    });
1112    bump_revision();
1113    Ok(final_name)
1114}
1115
1116/// UPDATE (update-components / edit-in-place commit): replace an existing
1117/// entry's document + signature. When the new document BUILDS differently
1118/// ([`same_build`]) the entry is marked DIRTY: the next history run's ACOMP
1119/// self-heal lane re-executes the document, re-snapshots, and every instance
1120/// of the part follows. When it builds the same — only its symbol, pads or BOM
1121/// attributes moved — the snapshot and ports still describe it and are kept,
1122/// so the part is not rebuilt for an edit that changed no geometry.
1123///
1124/// The wasm export; a NATIVE caller must call [`refresh_library_entry_impl`],
1125/// for the reason [`add_part_to_library`] gives.
1126#[wasm_bindgen]
1127pub fn refresh_library_entry(
1128    name: &str,
1129    source_signature: &str,
1130    document_json: &str,
1131) -> Result<(), JsValue> {
1132    refresh_library_entry_impl(name, source_signature, document_json)
1133        .map_err(|error| JsValue::from_str(&error))
1134}
1135
1136/// [`refresh_library_entry`] for native callers: its error as text.
1137pub fn refresh_library_entry_impl(
1138    name: &str,
1139    source_signature: &str,
1140    document_json: &str,
1141) -> Result<(), String> {
1142    let mut document: serde_json::Value = serde_json::from_str(document_json)
1143        .map_err(|error| format!("refresh_library_entry: {error}"))?;
1144    // Stamped before the hash, as in `add_part_to_library`.
1145    stamp_document_loop_ids(&mut document);
1146    ROOT.with(|root| {
1147        let mut root = root.borrow_mut();
1148        let Some(entry) = root.get_mut(name) else {
1149            return Err(format!(
1150                "refresh_library_entry: no parts-library entry named '{name}'"
1151            ));
1152        };
1153        let rebuild = !same_stamped_build(&entry.document, &document);
1154        entry.source_signature = source_signature.to_string();
1155        entry.doc_hash = stable_json_hash(&document);
1156        entry.document = document;
1157        if rebuild {
1158            entry.invalidate_snapshot();
1159        } else {
1160            // The same build: the snapshot and its rows still describe the new
1161            // document, so the saved rows follow it (else a reopen refuses them).
1162            entry.save_rows();
1163        }
1164        Ok(())
1165    })?;
1166    bump_revision();
1167    Ok(())
1168}
1169
1170/// Whether two part documents build the same solids and ports, so that one's
1171/// snapshot serves the other.
1172///
1173/// A snapshot is what `run_isolated_document` makes of a document: the
1174/// document read as a [`HistoryRequest`] with its editor stop points cleared.
1175/// Everything that request does not read — the eCAD `symbol` and `pads`
1176/// blocks, the BOM `partAttributes`, drawing `sheets` — cannot change what the
1177/// part builds, so two documents that read as the same request build the same
1178/// part. The comparison is of that request, not of a list of keys that do or
1179/// do not matter, so a block the request learns to read later counts at once.
1180///
1181/// Sketch loop ids are stamped on both sides first, as every library door
1182/// stamps them. A document that does not read as a request is never the same
1183/// build as anything: it has to heal.
1184pub fn same_build(a: &serde_json::Value, b: &serde_json::Value) -> bool {
1185    let stamped = |document: &serde_json::Value| {
1186        let mut document = document.clone();
1187        stamp_document_loop_ids(&mut document);
1188        document
1189    };
1190    same_stamped_build(&stamped(a), &stamped(b))
1191}
1192
1193/// The rows rule of [`refresh_library_entry_impl`] for a door that rewrites an
1194/// entry's `document` in the `partsLibrary` block itself (the app's Update
1195/// components): that block is what Save writes. Rows taken from `old` follow
1196/// the same build of `new`; any other rows are dropped, as no reopen could
1197/// take them.
1198pub fn follow_saved_rows(entry: &mut serde_json::Value, old: &serde_json::Value, new: &serde_json::Value) {
1199    follow_rows(entry, old, new, || same_build(old, new));
1200}
1201
1202/// [`follow_saved_rows`] for a caller that has already asked
1203/// [`same_build`]`(old, new)`: it is not asked again (each asks parses both
1204/// whole documents as requests, ~5 ms for an l500 module).
1205pub fn follow_saved_rows_known(
1206    entry: &mut serde_json::Value,
1207    old: &serde_json::Value,
1208    new: &serde_json::Value,
1209    same_build: bool,
1210) {
1211    follow_rows(entry, old, new, || same_build);
1212}
1213
1214fn follow_rows(
1215    entry: &mut serde_json::Value,
1216    old: &serde_json::Value,
1217    new: &serde_json::Value,
1218    same_build: impl FnOnce() -> bool,
1219) {
1220    let stamp = |document: &serde_json::Value| {
1221        let mut document = document.clone();
1222        stamp_document_loop_ids(&mut document);
1223        document_stamp(&document)
1224    };
1225    let Some(object) = entry.as_object_mut() else { return };
1226    let taken_from_old = object
1227        .get("nestedRows")
1228        .and_then(|rows| rows.get("document"))
1229        .and_then(serde_json::Value::as_str)
1230        .is_some_and(|document| document == stamp(old));
1231    if taken_from_old && same_build() {
1232        object["nestedRows"]["document"] = serde_json::Value::String(stamp(new));
1233    } else {
1234        object.remove("nestedRows");
1235    }
1236}
1237
1238/// [`same_build`] for two documents a library door has already stamped.
1239fn same_stamped_build(a: &serde_json::Value, b: &serde_json::Value) -> bool {
1240    matches!((build_identity(a), build_identity(b)), (Some(a), Some(b)) if a == b)
1241}
1242
1243/// The hash of `document` as [`run_isolated_document`] reads it; `None` when it
1244/// does not read as a request.
1245fn build_identity(document: &serde_json::Value) -> Option<u64> {
1246    let mut request = <HistoryRequest as serde::Deserialize>::deserialize(document).ok()?;
1247    request.stop_at_id = None;
1248    request.stop_before_id = None;
1249    Some(stable_json_hash(&serde_json::to_value(&request).ok()?))
1250}
1251
1252/// The current library in the `partsLibrary` block shape — what SAVE must
1253/// serialize into the document (never the loaded block: this copy carries
1254/// healed snapshots, refreshes, and GC).
1255#[wasm_bindgen]
1256pub fn parts_library_json() -> String {
1257    ROOT.with(|root| {
1258        serde_json::to_string(&*root.borrow()).unwrap_or_else(|_| "{}".to_string())
1259    })
1260}
1261
1262