Skip to main content

brep_kernel/feature_pipeline/
parts_library.rs

1//! The per-document PARTS LIBRARY — assemblies build-spec §2.1 / §10 item 3.
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, spec §2.2), and frees every handle it registered before
58//! returning.
59
60use serde::{Deserialize, Serialize};
61use std::cell::RefCell;
62use std::collections::BTreeMap;
63
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 (spec §2.1). `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    /// Stable content hash of `document`, precomputed at insert/refresh/ingest
115    /// so the per-feature cache hook never re-hashes a large document.
116    #[serde(skip)]
117    pub doc_hash: u64,
118}
119
120/// The library map shape as it travels in the history request / save file.
121pub type PartsLibraryMap = BTreeMap<String, PartsLibraryEntry>;
122
123/// This build's identity for [`PartsLibraryEntry::snapshot_producer`]: the hash
124/// of the kernel source that `build.rs` computes. `None` when the build could
125/// not compute it, which disables stale-snapshot invalidation rather than
126/// making every snapshot look stale on every run.
127pub(crate) fn kernel_source_stamp() -> Option<&'static str> {
128    option_env!("BREP_KERNEL_SOURCE_HASH")
129}
130
131thread_local! {
132    /// The open document's library (the ROOT store).
133    static ROOT: RefCell<PartsLibraryMap> = RefCell::new(BTreeMap::new());
134    /// Active-library stack for isolated sub-document runs: a nested ACOMP
135    /// resolves against ITS document's library, never the parent's.
136    static STACK: RefCell<Vec<PartsLibraryMap>> = const { RefCell::new(Vec::new()) };
137    /// Monotonic REVISION of the ROOT store, bumped by every mutation that
138    /// actually CHANGES it (insert, refresh, heal, GC drop, install, clear).
139    /// The app polls it to decide whether a background runner's resident copy
140    /// is stale — the library no longer rides on every history request, so
141    /// this counter is what tells the app when to re-send it. Thread-local
142    /// like the store itself: main and each runner count their own store.
143    static REVISION: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
144}
145
146/// Record that the ROOT store changed. Call it from EVERY mutation that
147/// actually alters content — never speculatively: `ingest` and
148/// `gc_after_rebuild` run on every history run, so an unconditional bump there
149/// would make the app re-send the whole library every edit, which is the cost
150/// this counter exists to avoid.
151fn bump_revision() {
152    REVISION.with(|revision| revision.set(revision.get().wrapping_add(1)));
153}
154
155/// The ROOT store's current revision (see [`REVISION`]). Two reads that differ
156/// mean the library changed in between; two that agree mean it did not.
157pub fn parts_library_revision() -> u64 {
158    REVISION.with(std::cell::Cell::get)
159}
160
161/// Stable content hash of a JSON value: sorted-key walk (never trusts a
162/// serializer's map ordering), string leaves verbatim.
163pub fn stable_json_hash(value: &serde_json::Value) -> u64 {
164    use std::hash::{Hash, Hasher};
165    fn walk(value: &serde_json::Value, hasher: &mut impl Hasher) {
166        match value {
167            serde_json::Value::Null => 0u8.hash(hasher),
168            serde_json::Value::Bool(flag) => {
169                1u8.hash(hasher);
170                flag.hash(hasher);
171            }
172            serde_json::Value::Number(number) => {
173                2u8.hash(hasher);
174                number.to_string().hash(hasher);
175            }
176            serde_json::Value::String(text) => {
177                3u8.hash(hasher);
178                text.hash(hasher);
179            }
180            serde_json::Value::Array(items) => {
181                4u8.hash(hasher);
182                for item in items {
183                    walk(item, hasher);
184                }
185            }
186            serde_json::Value::Object(map) => {
187                5u8.hash(hasher);
188                let mut keys: Vec<&String> = map.keys().collect();
189                keys.sort();
190                for key in keys {
191                    key.hash(hasher);
192                    walk(&map[key], hasher);
193                }
194            }
195        }
196    }
197    let mut hasher = std::collections::hash_map::DefaultHasher::new();
198    walk(value, &mut hasher);
199    hasher.finish()
200}
201
202/// Read the entry for a part name from the ACTIVE library: the innermost
203/// isolated-run library when one is pushed, else the root store.
204pub(crate) fn active_entry(part_name: &str) -> Option<PartsLibraryEntry> {
205    STACK.with(|stack| {
206        let stack = stack.borrow();
207        if let Some(top) = stack.last() {
208            return top.get(part_name).cloned();
209        }
210        ROOT.with(|root| root.borrow().get(part_name).cloned())
211    })
212}
213
214/// Write a healed snapshot back into the ACTIVE library entry and clear its
215/// dirty flag. A heal inside an isolated run updates the pushed (temporary)
216/// library only — the parent entry's embedded document is NOT rewritten (its
217/// re-snapshot bakes the healed geometry anyway; the stale inner snapshot heals
218/// again on the next full sub-document run).
219pub(crate) fn heal_entry(part_name: &str, snapshot: String, ports: BTreeMap<String, PortRecord>) {
220    let heal = |map: &mut PartsLibraryMap| {
221        if let Some(entry) = map.get_mut(part_name) {
222            entry.snapshot = snapshot.clone();
223            entry.snapshot_producer = kernel_source_stamp().unwrap_or_default().to_string();
224            entry.ports = ports.clone();
225            entry.dirty = false;
226        }
227    };
228    STACK.with(|stack| {
229        let mut stack = stack.borrow_mut();
230        if let Some(top) = stack.last_mut() {
231            heal(top);
232        } else {
233            ROOT.with(|root| heal(&mut root.borrow_mut()));
234            // A heal rewrites a ROOT entry's snapshot, so the store changed.
235            // (It does NOT change the entry's content IDENTITY — see
236            // `install` — so re-sending a healed library never clobbers a
237            // runner's own, independently-derived heal.)
238            bump_revision();
239        }
240    });
241}
242
243/// Drop the whole library (document switch — wired into `clear_history_cache`).
244pub(crate) fn clear_all() {
245    let had_entries = ROOT.with(|root| {
246        let mut root = root.borrow_mut();
247        let had = !root.is_empty();
248        root.clear();
249        had
250    });
251    STACK.with(|stack| stack.borrow_mut().clear());
252    if had_entries {
253        bump_revision();
254    }
255}
256
257// ===========================================================================
258// execute_history hooks: ingest, cache fingerprint, GC
259// ===========================================================================
260
261/// The ACOMP dispatch predicate — shared by the fingerprint hook, the GC scan,
262/// and (as adjacent literals) the `execute_feature` match arm, so they can
263/// never disagree on what counts as a component instance.
264pub(crate) fn is_acomp_type(feature_type: &str) -> bool {
265    matches!(feature_type, "ACOMP" | "ASSEMBLY COMPONENT")
266}
267
268/// Merge a request's `partsLibrary` block into the root store (start of every
269/// `execute_history` run — this is how a LOADED document seeds the library).
270/// A RESIDENT entry always wins: mid-session the kernel store is the source of
271/// truth (it carries heals, refreshes, and pending-dirty state the request's
272/// echo of the loaded block predates); the block only ever FILLS missing names
273/// (document load starts from an empty store, cleared by `clear_history_cache`).
274pub(crate) fn ingest(request_map: &PartsLibraryMap) {
275    if request_map.is_empty() {
276        return;
277    }
278    let seeded = ROOT.with(|root| {
279        let mut root = root.borrow_mut();
280        let mut seeded = false;
281        for (name, incoming) in request_map {
282            if root.contains_key(name) {
283                continue;
284            }
285            let mut entry = incoming.clone();
286            // A loaded block's entry gets the same stamp as an inserted one, so a
287            // document saved before per-loop ids existed picks them up on open.
288            stamp_document_loop_ids(&mut entry.document);
289            entry.doc_hash = stable_json_hash(&entry.document);
290            // A document saved before parts carried their ports heals once.
291            entry.dirty = needs_port_heal(&entry);
292            root.insert(name.clone(), entry);
293            seeded = true;
294        }
295        seeded
296    });
297    if seeded {
298        bump_revision();
299    }
300}
301
302/// Whether an entry must heal to pick up its connection points: its document
303/// declares some (at any depth — a nested assembly's parts count) but the entry
304/// carries no records, i.e. it was captured before they existed.
305fn needs_port_heal(entry: &PartsLibraryEntry) -> bool {
306    entry.ports.is_empty() && document_declares_ports(&entry.document)
307}
308
309/// Whether `document` — or a library entry it embeds — declares a connection
310/// point or a WAYPOINT, the two things that put a record in `scene.ports`.
311fn document_declares_ports(document: &serde_json::Value) -> bool {
312    match document {
313        serde_json::Value::Object(map) => {
314            map.get(crate::feature_pipeline::ports::PORTS_BLOCK)
315                .and_then(serde_json::Value::as_array)
316                .is_some_and(|block| !block.is_empty())
317                || matches!(
318                    map.get("type").and_then(serde_json::Value::as_str),
319                    Some("WP" | "WAYPOINT")
320                )
321                || map.values().any(document_declares_ports)
322        }
323        serde_json::Value::Array(items) => items.iter().any(document_declares_ports),
324        _ => false,
325    }
326}
327
328/// The CONTENT IDENTITY of an entry: what makes two entries the same PART.
329/// `snapshot` is deliberately excluded — a heal re-derives the snapshot from
330/// the same `document`, so it must not read as a different part. This is the
331/// same identity [`mix_descriptor_fingerprint`] mixes into an ACOMP's cache
332/// fingerprint, so "install kept the resident entry" and "the instance
333/// replayed from cache" can never disagree.
334fn identity(entry: &PartsLibraryEntry) -> (&str, &str, u64) {
335    (&entry.source_key, &entry.source_signature, entry.doc_hash)
336}
337
338/// INSTALL a library wholesale — the app → history-runner channel (the runner
339/// owns its own thread-local store, and the per-run request no longer carries
340/// the block, so this is how a background thread/worker learns the library).
341///
342/// Unlike [`ingest`] (fill-only, resident-wins) this makes the store MATCH
343/// `incoming` BY CONTENT IDENTITY, which is what a cache channel needs:
344///
345/// * a name missing from the store is inserted (stamped + hashed like ingest);
346/// * a name whose resident entry has the SAME identity is KEPT VERBATIM — that
347///   is what protects a runner-side heal, whose only trace is a rewritten
348///   `snapshot`, from being clobbered by a re-send;
349/// * a name whose resident entry has a DIFFERENT identity but the SAME build
350///   ([`same_build`]: only the symbol, pads or BOM attributes moved) is
351///   REPLACED with the resident snapshot and ports carried over — they still
352///   describe the part, so nothing is rebuilt;
353/// * a name whose resident entry builds DIFFERENTLY is REPLACED and marked
354///   `dirty`, so the ACOMP self-heal lane re-derives every instance (`dirty` is
355///   `#[serde(skip)]` and cannot ride the wire — it is derived HERE, from the
356///   mismatch);
357/// * a resident name ABSENT from `incoming` is dropped.
358///
359/// Returns whether anything changed (and bumps the revision if so).
360pub fn install_parts_library(incoming: &PartsLibraryMap) -> bool {
361    let mut changed = false;
362    ROOT.with(|root| {
363        let mut root = root.borrow_mut();
364        root.retain(|name, _| {
365            let keep = incoming.contains_key(name);
366            changed |= !keep;
367            keep
368        });
369        for (name, entry) in incoming {
370            let mut entry = entry.clone();
371            stamp_document_loop_ids(&mut entry.document);
372            entry.doc_hash = stable_json_hash(&entry.document);
373            entry.dirty = needs_port_heal(&entry);
374            match root.get(name) {
375                // Same part: keep the RESIDENT copy — it may carry a heal this
376                // side derived and the sender never saw.
377                Some(resident) if identity(resident) == identity(&entry) => continue,
378                // A new version that builds the same (a symbol, pads or BOM
379                // attribute edit): take its document and signature, but the
380                // resident snapshot and ports still describe it — keep them, and
381                // any heal they carry, rather than rebuilding the part.
382                Some(resident) if same_stamped_build(&resident.document, &entry.document) => {
383                    entry.snapshot = resident.snapshot.clone();
384                    entry.snapshot_producer = resident.snapshot_producer.clone();
385                    entry.ports = resident.ports.clone();
386                    entry.dirty = resident.dirty || needs_port_heal(&entry);
387                }
388                // Genuinely different content: replace, and force the self-heal
389                // lane so every instance follows the new document.
390                Some(_) => entry.dirty = true,
391                None => {}
392            }
393            root.insert(name.clone(), entry);
394            changed = true;
395        }
396    });
397    if changed {
398        bump_revision();
399    }
400    changed
401}
402
403/// A CLONE of the root store — what the app hands a runner through
404/// [`install_parts_library`]. (`parts_library_json` is the SAVE door; this is
405/// the in-process one, with no JSON round trip on the native paths.)
406pub fn parts_library_map() -> PartsLibraryMap {
407    ROOT.with(|root| root.borrow().clone())
408}
409
410/// The PREFLIGHT for a run: every part name the request's ACOMP features
411/// reference that the root store cannot resolve, in request order.
412///
413/// A background runner calls this BEFORE executing a run and refuses the run
414/// when it is non-empty. That is the drift-proof half of the library channel:
415/// the orphan GC at the end of every run can legitimately drop entries the
416/// sender still believes are resident (undo to zero components GCs the runner's
417/// store while the sender's store, which never ran, keeps them), and no
418/// revision bookkeeping can see that. Missing CONTENT is observable; that is
419/// what this checks.
420pub fn missing_library_parts(request: &HistoryRequest) -> Vec<String> {
421    let mut missing = Vec::new();
422    ROOT.with(|root| {
423        let root = root.borrow();
424        for descriptor in &request.features {
425            if !is_acomp_type(&descriptor.feature_type) {
426                continue;
427            }
428            let Some(name) = descriptor
429                .input_params
430                .get("partName")
431                .and_then(|value| value.as_str())
432                .map(str::trim)
433                .filter(|name| !name.is_empty())
434            else {
435                continue;
436            };
437            if !root.contains_key(name) && !missing.iter().any(|seen| seen == name) {
438                missing.push(name.to_string());
439            }
440        }
441    });
442    missing
443}
444
445/// The history-cache hook for one descriptor: mix the referenced library
446/// entry's CONTENT identity (source key/signature/document hash — snapshot
447/// deliberately excluded, a heal must not dirty the instance) into the
448/// feature fingerprint, and report whether a DIRTY entry forces re-execution.
449/// Non-ACOMP descriptors pass through untouched.
450pub(crate) fn mix_descriptor_fingerprint(
451    descriptor: &FeatureDescriptor,
452    fingerprint: u64,
453) -> (u64, bool) {
454    if !is_acomp_type(&descriptor.feature_type) {
455        return (fingerprint, false);
456    }
457    use std::hash::{Hash, Hasher};
458    let mut hasher = std::collections::hash_map::DefaultHasher::new();
459    fingerprint.hash(&mut hasher);
460    let part_name = descriptor
461        .input_params
462        .get("partName")
463        .and_then(|value| value.as_str())
464        .unwrap_or("");
465    part_name.hash(&mut hasher);
466    let mut force_dirty = false;
467    match active_entry(part_name) {
468        Some(entry) => {
469            1u8.hash(&mut hasher);
470            entry.source_key.hash(&mut hasher);
471            entry.source_signature.hash(&mut hasher);
472            entry.doc_hash.hash(&mut hasher);
473            force_dirty = entry.dirty;
474        }
475        None => 0u8.hash(&mut hasher),
476    }
477    (hasher.finish(), force_dirty)
478}
479
480/// Orphan GC (end of every `execute_history` run): retain only entries some
481/// ACOMP feature in the REQUEST references (the request always carries the full
482/// feature list — editor stop points truncate execution, not the list).
483pub(crate) fn gc_after_rebuild(request: &HistoryRequest) {
484    let referenced: std::collections::BTreeSet<String> = request
485        .features
486        .iter()
487        .filter(|descriptor| is_acomp_type(&descriptor.feature_type))
488        .filter_map(|descriptor| {
489            descriptor
490                .input_params
491                .get("partName")
492                .and_then(|value| value.as_str())
493                .map(|name| name.trim().to_string())
494        })
495        .collect();
496    let dropped = ROOT.with(|root| {
497        let mut root = root.borrow_mut();
498        let before = root.len();
499        root.retain(|name, _| referenced.contains(name));
500        before != root.len()
501    });
502    if dropped {
503        bump_revision();
504    }
505}
506
507// ===========================================================================
508// Isolated sub-part execution (the self-heal / insert lane)
509// ===========================================================================
510
511/// Execute an embedded part document headlessly and return its surviving
512/// solids as PART-LOCAL `(scene name, owned BREP)` pairs in history order.
513/// Never touches the history cache or the parent scene; every handle it
514/// registers is freed before returning. See the module doc for why this must
515/// not recurse into `execute_history`.
516fn run_isolated_document(
517    document: &serde_json::Value,
518    pmi: Option<&mut Option<crate::feature_pipeline::pmi::PmiReport>>,
519) -> Result<IsolatedRun, String> {
520    let mut request: HistoryRequest = serde_json::from_value(document.clone())
521        .map_err(|error| format!("embedded part document does not parse: {error}"))?;
522    // A stale editor stop point saved into a library part must not silently
523    // truncate the part on every heal.
524    request.stop_at_id = None;
525    request.stop_before_id = None;
526
527    // The sub-document's own library is the active one for this run (a nested
528    // assembly's ACOMPs resolve against it; missing entries are clean errors,
529    // never a fallback to the parent's library). Its entries arrive raw from
530    // the JSON, so the port heal is decided here as `ingest` decides it for a
531    // loaded document — an inner part saved before parts carried their ports
532    // must not take the fast lane and publish none.
533    STACK.with(|stack| {
534        let mut library = std::mem::take(&mut request.parts_library);
535        for entry in library.values_mut() {
536            entry.dirty = needs_port_heal(entry);
537        }
538        stack.borrow_mut().push(library);
539    });
540    let electronics_part = ["symbol", "pads"]
541        .iter()
542        .any(|key| document.get(*key).is_some_and(serde_json::Value::is_object))
543        || !request.ports.is_empty();
544    let outcome = run_isolated_features(
545        &request,
546        pmi,
547        electronics_part,
548        crate::feature_pipeline::ports::document_is_boundary(document),
549    );
550    STACK.with(|stack| {
551        stack.borrow_mut().pop();
552    });
553    outcome
554}
555
556/// The feature walk of [`run_isolated_document`], separated so the library
557/// stack push/pop brackets every exit path.
558/// What an isolated part run yields: the surviving solids (part-local names,
559/// history order) and the part's ports (part-local ids, part-local pose).
560type IsolatedRun = (Vec<(String, BrepSolid)>, BTreeMap<String, PortRecord>);
561
562fn run_isolated_features(
563    request: &HistoryRequest,
564    pmi: Option<&mut Option<crate::feature_pipeline::pmi::PmiReport>>,
565    electronics_part: bool,
566    boundary: bool,
567) -> Result<IsolatedRun, String> {
568    let env = Env::build(&request.expressions, &request.configurator).unwrap_or_else(Env::poisoned);
569    let mut scene = SceneMap::default();
570    // The same pre-walk seed `execute_history` does (see `ports.rs`): a part's
571    // own spline must attach to its own connection point on this lane too.
572    // Nothing here caches, so the versions are dropped.
573    crate::feature_pipeline::ports::seed_history_run(request, &mut scene, &env);
574    let mut results = Vec::with_capacity(request.features.len());
575    let mut all_handles: Vec<u32> = Vec::new();
576    let mut error: Option<String> = None;
577    for descriptor in &request.features {
578        let result = execute_feature(descriptor, &env, &scene);
579        scene.apply(&result);
580        all_handles.extend(result.added.iter().map(|added| added.handle));
581        // Replicate the loop's producer stamp so the snapshot carries the same
582        // topology metadata a real history run would have written.
583        if !result.id.is_empty() {
584            let mut seed = serde_json::Map::new();
585            seed.insert(
586                "sourceFeatureId".into(),
587                serde_json::Value::String(result.id.clone()),
588            );
589            for added in &result.added {
590                for (_, face_name) in &added.face_names {
591                    scene_metadata::merge_record(face_name, &seed, false);
592                }
593                for (_, edge_name) in &added.edge_names {
594                    scene_metadata::merge_record(edge_name, &seed, false);
595                }
596            }
597        }
598        if let Some(message) = &result.error {
599            error = Some(format!("feature '{}': {message}", result.id));
600            break;
601        }
602        results.push(result);
603    }
604
605    // Survivors: added solids still scene-resident under their own name, in
606    // history order (a consumed/superseded solid's name no longer maps to it).
607    let mut members = Vec::new();
608    if error.is_none() {
609        for result in &results {
610            for added in &result.added {
611                if scene.resolve_solid(&added.name) == Some(added.handle) {
612                    let solid =
613                        crate::with_registered_solid_str(added.handle, |solid| Ok(solid.clone()))?;
614                    members.push((added.name.clone(), solid));
615                }
616            }
617        }
618    }
619    // The document's own PMI, resolved while its solids are still RESIDENT —
620    // every reference resolves through the registry, so the tail has to run
621    // before the free loop below. Only an exporter asks for it
622    // ([`document_pmi`]); a snapshot rebuild passes `None` and pays nothing.
623    if let Some(out) = pmi {
624        *out = crate::feature_pipeline::pmi::finish_history_run(request, &scene, &env);
625    }
626    // The document's own declared ports, resolved here for the same reason the
627    // PMI is: a point may reference the part's geometry, and the geometry is
628    // only resident until the free loop below. This is the one place a part's
629    // connection points are captured, so the tail HAS to run on this lane too —
630    // without it every placed part would arrive with an empty port map.
631    let declared: Vec<String> = crate::feature_pipeline::ports::finish_history_run(
632        request,
633        &mut scene,
634        &env,
635    )
636    .report
637    .map(|report| report.points.into_iter().map(|row| row.address).collect())
638    .unwrap_or_default();
639    for handle in all_handles {
640        sheet_metal::remove_tree(handle);
641        crate::free_registered_solid(handle);
642    }
643    // The part's connection points: every record the run left in the scene (a
644    // nested assembly's component points are already namespaced by its ACOMPs),
645    // through the ENCAPSULATION filter. A boundary part — a PCB, or anything
646    // flagged one — exports only what it declares itself, so a parent can reach
647    // its connectors and nothing inside it. See `ports::export_ports`.
648    let scene_ports: BTreeMap<String, PortRecord> = scene
649        .ports
650        .iter()
651        .map(|(id, port)| (id.clone(), port.clone()))
652        .collect();
653    let ports = crate::feature_pipeline::ports::export_ports(&scene_ports, &declared, boundary);
654    match error {
655        Some(message) => Err(message),
656        // An electronics part may have a symbol and ports before a 3D model is
657        // available. Keep the occurrence and its endpoints without inventing a
658        // solid. A generic empty modeling document remains an error.
659        None if members.is_empty() && !electronics_part =>
660            Err("embedded part document produced no solids".into()),
661        None => Ok((members, ports)),
662    }
663}
664
665/// Execute an embedded part document and snapshot the result — the ONE
666/// snapshot-(re)build lane ([`add_part_to_library`] insert + ACOMP self-heal).
667/// The scene-metadata store is bracketed around the run: the sub-part's
668/// un-namespaced records feed the snapshot capture and are then discarded, so
669/// the parent document's store is byte-identical afterwards on every path.
670pub(crate) fn rebuild_snapshot(
671    document: &serde_json::Value,
672) -> Result<(String, BTreeMap<String, PortRecord>), String> {
673    let saved = scene_metadata::take_store();
674    let outcome = run_isolated_document(document, None).and_then(|(members, ports)| {
675        let refs: Vec<(&str, &BrepSolid)> = members
676            .iter()
677            .map(|(name, solid)| (name.as_str(), solid))
678            .collect();
679        crate::snapshot_solids(&refs).map(|snapshot| (snapshot, ports))
680    });
681    scene_metadata::restore_store(saved);
682    outcome
683}
684
685/// An embedded part document's OWN PMI, resolved against the geometry the
686/// document builds — the pair the STEP writer needs to put a part's
687/// annotations into the part's own `PRODUCT` rather than into the assembly
688/// that places it (`io/step/assembly.rs`).
689///
690/// `None` when the document carries no PMI block, when it does not run, or
691/// when nothing resolved. The scene-metadata store is bracketed exactly as
692/// [`rebuild_snapshot`] brackets it, so the caller's store is byte-identical
693/// afterwards on every path.
694pub(crate) fn document_pmi(
695    document: &serde_json::Value,
696) -> Option<(crate::feature_pipeline::pmi::PmiState, crate::feature_pipeline::pmi::PmiReport)> {
697    let state: crate::feature_pipeline::pmi::PmiState = serde_json::from_value(document.get("pmi")?.clone()).ok()?;
698    if state.views.is_empty() {
699        return None;
700    }
701    let saved = scene_metadata::take_store();
702    let mut report = None;
703    let outcome = run_isolated_document(document, Some(&mut report));
704    scene_metadata::restore_store(saved);
705    outcome.ok()?;
706    report.map(|report| (state, report))
707}
708
709// ===========================================================================
710// The app-facing insert / update / save surface
711// ===========================================================================
712
713/// A part name not yet used in the root store: `requested`, else
714/// `requested-2`, `requested-3`, …
715fn unique_name(root: &PartsLibraryMap, requested: &str) -> String {
716    if !root.contains_key(requested) {
717        return requested.to_string();
718    }
719    for counter in 2.. {
720        let candidate = format!("{requested}-{counter}");
721        if !root.contains_key(&candidate) {
722            return candidate;
723        }
724    }
725    unreachable!("counter loop is unbounded")
726}
727
728/// Stamp per-loop sketch ids onto an entry's document, in place — run at EVERY
729/// door into the library (insert, refresh, and the loaded-block seeding), so a
730/// part gets durable loop ids no matter how it arrived.
731///
732/// The engine also stamps a document on LOAD
733/// (`EngineState::set_history_json`), but a part inserted or refreshed through
734/// the assembly lanes never passes through that: `add_part_to_library` and
735/// `refresh_library_entry` take a document STRING straight from a caller (the
736/// file panel inserts a saved `.nbrep`; update-components commits an edited
737/// one). Stamping here rather than at those call sites means a future lane that
738/// installs a document cannot silently skip it.
739///
740/// This renames nothing — the kernel derives the same loop ids every run. What
741/// it adds is durability: a stored id survives deleting the edge it was derived
742/// from, which derivation alone cannot. See `features/sketch/loop_ids`.
743fn stamp_document_loop_ids(document: &mut serde_json::Value) {
744    let Some(features) = document
745        .get_mut("features")
746        .and_then(serde_json::Value::as_array_mut)
747    else {
748        return;
749    };
750    for feature in features {
751        if feature.get("type").and_then(serde_json::Value::as_str) != Some("S") {
752            continue;
753        }
754        let Some(sketch) = feature
755            .get_mut("persistentData")
756            .and_then(|data| data.get_mut("sketch"))
757        else {
758            continue;
759        };
760        crate::feature_pipeline::assign_sketch_loop_ids(sketch);
761    }
762}
763
764/// INSERT: hand the opened sub-part document over to the library. Executes the
765/// document once, snapshots, stores the entry, and returns the EFFECTIVE part
766/// name (the ACOMP `partName` the app must reference):
767///
768/// - a live entry with the same `sourceKey` AND `sourceSignature` is reused
769///   verbatim (no re-execution — instant re-insert, spec §2.1);
770/// - same `sourceKey` but a DIFFERENT signature gets a fresh entry under a
771///   disambiguated name (existing instances keep their version; the explicit
772///   refresh lane is [`refresh_library_entry`]);
773/// - a requested name taken by different content is disambiguated (`name-2`).
774///
775/// The wasm export. A NATIVE caller must call [`add_part_to_library_impl`]:
776/// building a `JsValue` off wasm32 panics inside a frame that cannot unwind,
777/// so an error returned through this door aborts the whole app (the audit's
778/// Insert component crash).
779#[wasm_bindgen]
780pub fn add_part_to_library(
781    name: &str,
782    source_key: &str,
783    source_signature: &str,
784    document_json: &str,
785) -> Result<String, JsValue> {
786    add_part_to_library_impl(name, source_key, source_signature, document_json)
787        .map_err(|error| JsValue::from_str(&error))
788}
789
790/// [`add_part_to_library`] for native callers: the same insert, its error as
791/// text.
792pub fn add_part_to_library_impl(
793    name: &str,
794    source_key: &str,
795    source_signature: &str,
796    document_json: &str,
797) -> Result<String, String> {
798    let requested = name.trim();
799    if requested.is_empty() {
800        return Err("add_part_to_library: empty part name".to_string());
801    }
802    let reused = ROOT.with(|root| {
803        root.borrow().iter().find_map(|(entry_name, entry)| {
804            (entry.source_key == source_key && entry.source_signature == source_signature)
805                .then(|| entry_name.clone())
806        })
807    });
808    if let Some(entry_name) = reused {
809        return Ok(entry_name);
810    }
811    let mut document: serde_json::Value = serde_json::from_str(document_json)
812        .map_err(|error| format!("add_part_to_library: {error}"))?;
813    // Stamp BEFORE the hash and the snapshot so both describe the stored form —
814    // a later re-stamp then finds nothing to change and cannot dirty the entry.
815    stamp_document_loop_ids(&mut document);
816    let (snapshot, ports) = rebuild_snapshot(&document)
817        .map_err(|error| format!("add_part_to_library: {error}"))?;
818    let entry = PartsLibraryEntry {
819        source_key: source_key.to_string(),
820        source_signature: source_signature.to_string(),
821        doc_hash: stable_json_hash(&document),
822        document,
823        snapshot,
824        snapshot_producer: kernel_source_stamp().unwrap_or_default().to_string(),
825        ports,
826        dirty: false,
827    };
828    let final_name = ROOT.with(|root| {
829        let mut root = root.borrow_mut();
830        let final_name = unique_name(&root, requested);
831        root.insert(final_name.clone(), entry);
832        final_name
833    });
834    bump_revision();
835    Ok(final_name)
836}
837
838/// UPDATE (update-components / edit-in-place commit): replace an existing
839/// entry's document + signature. When the new document BUILDS differently
840/// ([`same_build`]) the entry is marked DIRTY: the next history run's ACOMP
841/// self-heal lane re-executes the document, re-snapshots, and every instance
842/// of the part follows. When it builds the same — only its symbol, pads or BOM
843/// attributes moved — the snapshot and ports still describe it and are kept,
844/// so the part is not rebuilt for an edit that changed no geometry.
845///
846/// The wasm export; a NATIVE caller must call [`refresh_library_entry_impl`],
847/// for the reason [`add_part_to_library`] gives.
848#[wasm_bindgen]
849pub fn refresh_library_entry(
850    name: &str,
851    source_signature: &str,
852    document_json: &str,
853) -> Result<(), JsValue> {
854    refresh_library_entry_impl(name, source_signature, document_json)
855        .map_err(|error| JsValue::from_str(&error))
856}
857
858/// [`refresh_library_entry`] for native callers: its error as text.
859pub fn refresh_library_entry_impl(
860    name: &str,
861    source_signature: &str,
862    document_json: &str,
863) -> Result<(), String> {
864    let mut document: serde_json::Value = serde_json::from_str(document_json)
865        .map_err(|error| format!("refresh_library_entry: {error}"))?;
866    // Stamped before the hash, as in `add_part_to_library`.
867    stamp_document_loop_ids(&mut document);
868    ROOT.with(|root| {
869        let mut root = root.borrow_mut();
870        let Some(entry) = root.get_mut(name) else {
871            return Err(format!(
872                "refresh_library_entry: no parts-library entry named '{name}'"
873            ));
874        };
875        let rebuild = !same_stamped_build(&entry.document, &document);
876        entry.source_signature = source_signature.to_string();
877        entry.doc_hash = stable_json_hash(&document);
878        entry.document = document;
879        if rebuild {
880            entry.ports.clear(); // the heal re-derives them from the new document
881            entry.dirty = true;
882        }
883        Ok(())
884    })?;
885    bump_revision();
886    Ok(())
887}
888
889/// Whether two part documents build the same solids and ports, so that one's
890/// snapshot serves the other.
891///
892/// A snapshot is what [`run_isolated_document`] makes of a document: the
893/// document read as a [`HistoryRequest`] with its editor stop points cleared.
894/// Everything that request does not read — the eCAD `symbol` and `pads`
895/// blocks, the BOM `partAttributes`, drawing `sheets` — cannot change what the
896/// part builds, so two documents that read as the same request build the same
897/// part. The comparison is of that request, not of a list of keys that do or
898/// do not matter, so a block the request learns to read later counts at once.
899///
900/// Sketch loop ids are stamped on both sides first, as every library door
901/// stamps them. A document that does not read as a request is never the same
902/// build as anything: it has to heal.
903pub fn same_build(a: &serde_json::Value, b: &serde_json::Value) -> bool {
904    let stamped = |document: &serde_json::Value| {
905        let mut document = document.clone();
906        stamp_document_loop_ids(&mut document);
907        document
908    };
909    same_stamped_build(&stamped(a), &stamped(b))
910}
911
912/// [`same_build`] for two documents a library door has already stamped.
913fn same_stamped_build(a: &serde_json::Value, b: &serde_json::Value) -> bool {
914    matches!((build_identity(a), build_identity(b)), (Some(a), Some(b)) if a == b)
915}
916
917/// The hash of `document` as [`run_isolated_document`] reads it; `None` when it
918/// does not read as a request.
919fn build_identity(document: &serde_json::Value) -> Option<u64> {
920    let mut request: HistoryRequest = serde_json::from_value(document.clone()).ok()?;
921    request.stop_at_id = None;
922    request.stop_before_id = None;
923    Some(stable_json_hash(&serde_json::to_value(&request).ok()?))
924}
925
926/// The current library in the `partsLibrary` block shape (spec §2.1) — what
927/// SAVE must serialize into the document (never the loaded block: this copy
928/// carries healed snapshots, refreshes, and GC).
929#[wasm_bindgen]
930pub fn parts_library_json() -> String {
931    ROOT.with(|root| {
932        serde_json::to_string(&*root.borrow()).unwrap_or_else(|_| "{}".to_string())
933    })
934}
935