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), it forces
77/// the ACOMP self-heal lane on the next run even with a readable snapshot, and
78/// clears when the heal rewrites the snapshot.
79#[derive(Debug, Clone, Default, Serialize, Deserialize)]
80pub struct PartsLibraryEntry {
81    #[serde(rename = "sourceKey", default)]
82    pub source_key: String,
83    #[serde(rename = "sourceSignature", default)]
84    pub source_signature: String,
85    /// The embedded full sub-part history JSON — the durable source the
86    /// self-heal lane re-executes when the snapshot cache fails.
87    #[serde(default)]
88    pub document: serde_json::Value,
89    /// The evaluated part as an `io/snapshot` payload (the fast lane).
90    #[serde(default)]
91    pub snapshot: String,
92    /// The part's harness ports (part-local id -> record, part-local pose),
93    /// captured from the same run as the snapshot. Derived like the snapshot:
94    /// an entry whose document declares a PORT but carries none is healed.
95    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
96    pub ports: BTreeMap<String, PortRecord>,
97    #[serde(skip)]
98    pub dirty: bool,
99    /// Stable content hash of `document`, precomputed at insert/refresh/ingest
100    /// so the per-feature cache hook never re-hashes a large document.
101    #[serde(skip)]
102    pub doc_hash: u64,
103}
104
105/// The library map shape as it travels in the history request / save file.
106pub type PartsLibraryMap = BTreeMap<String, PartsLibraryEntry>;
107
108thread_local! {
109    /// The open document's library (the ROOT store).
110    static ROOT: RefCell<PartsLibraryMap> = RefCell::new(BTreeMap::new());
111    /// Active-library stack for isolated sub-document runs: a nested ACOMP
112    /// resolves against ITS document's library, never the parent's.
113    static STACK: RefCell<Vec<PartsLibraryMap>> = const { RefCell::new(Vec::new()) };
114    /// Monotonic REVISION of the ROOT store, bumped by every mutation that
115    /// actually CHANGES it (insert, refresh, heal, GC drop, install, clear).
116    /// The app polls it to decide whether a background runner's resident copy
117    /// is stale — the library no longer rides on every history request, so
118    /// this counter is what tells the app when to re-send it. Thread-local
119    /// like the store itself: main and each runner count their own store.
120    static REVISION: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
121}
122
123/// Record that the ROOT store changed. Call it from EVERY mutation that
124/// actually alters content — never speculatively: `ingest` and
125/// `gc_after_rebuild` run on every history run, so an unconditional bump there
126/// would make the app re-send the whole library every edit, which is the cost
127/// this counter exists to avoid.
128fn bump_revision() {
129    REVISION.with(|revision| revision.set(revision.get().wrapping_add(1)));
130}
131
132/// The ROOT store's current revision (see [`REVISION`]). Two reads that differ
133/// mean the library changed in between; two that agree mean it did not.
134pub fn parts_library_revision() -> u64 {
135    REVISION.with(std::cell::Cell::get)
136}
137
138/// Stable content hash of a JSON value: sorted-key walk (never trusts a
139/// serializer's map ordering), string leaves verbatim.
140pub fn stable_json_hash(value: &serde_json::Value) -> u64 {
141    use std::hash::{Hash, Hasher};
142    fn walk(value: &serde_json::Value, hasher: &mut impl Hasher) {
143        match value {
144            serde_json::Value::Null => 0u8.hash(hasher),
145            serde_json::Value::Bool(flag) => {
146                1u8.hash(hasher);
147                flag.hash(hasher);
148            }
149            serde_json::Value::Number(number) => {
150                2u8.hash(hasher);
151                number.to_string().hash(hasher);
152            }
153            serde_json::Value::String(text) => {
154                3u8.hash(hasher);
155                text.hash(hasher);
156            }
157            serde_json::Value::Array(items) => {
158                4u8.hash(hasher);
159                for item in items {
160                    walk(item, hasher);
161                }
162            }
163            serde_json::Value::Object(map) => {
164                5u8.hash(hasher);
165                let mut keys: Vec<&String> = map.keys().collect();
166                keys.sort();
167                for key in keys {
168                    key.hash(hasher);
169                    walk(&map[key], hasher);
170                }
171            }
172        }
173    }
174    let mut hasher = std::collections::hash_map::DefaultHasher::new();
175    walk(value, &mut hasher);
176    hasher.finish()
177}
178
179/// Read the entry for a part name from the ACTIVE library: the innermost
180/// isolated-run library when one is pushed, else the root store.
181pub(crate) fn active_entry(part_name: &str) -> Option<PartsLibraryEntry> {
182    STACK.with(|stack| {
183        let stack = stack.borrow();
184        if let Some(top) = stack.last() {
185            return top.get(part_name).cloned();
186        }
187        ROOT.with(|root| root.borrow().get(part_name).cloned())
188    })
189}
190
191/// Write a healed snapshot back into the ACTIVE library entry and clear its
192/// dirty flag. A heal inside an isolated run updates the pushed (temporary)
193/// library only — the parent entry's embedded document is NOT rewritten (its
194/// re-snapshot bakes the healed geometry anyway; the stale inner snapshot heals
195/// again on the next full sub-document run).
196pub(crate) fn heal_entry(part_name: &str, snapshot: String, ports: BTreeMap<String, PortRecord>) {
197    let heal = |map: &mut PartsLibraryMap| {
198        if let Some(entry) = map.get_mut(part_name) {
199            entry.snapshot = snapshot.clone();
200            entry.ports = ports.clone();
201            entry.dirty = false;
202        }
203    };
204    STACK.with(|stack| {
205        let mut stack = stack.borrow_mut();
206        if let Some(top) = stack.last_mut() {
207            heal(top);
208        } else {
209            ROOT.with(|root| heal(&mut root.borrow_mut()));
210            // A heal rewrites a ROOT entry's snapshot, so the store changed.
211            // (It does NOT change the entry's content IDENTITY — see
212            // `install` — so re-sending a healed library never clobbers a
213            // runner's own, independently-derived heal.)
214            bump_revision();
215        }
216    });
217}
218
219/// Drop the whole library (document switch — wired into `clear_history_cache`).
220pub(crate) fn clear_all() {
221    let had_entries = ROOT.with(|root| {
222        let mut root = root.borrow_mut();
223        let had = !root.is_empty();
224        root.clear();
225        had
226    });
227    STACK.with(|stack| stack.borrow_mut().clear());
228    if had_entries {
229        bump_revision();
230    }
231}
232
233// ===========================================================================
234// execute_history hooks: ingest, cache fingerprint, GC
235// ===========================================================================
236
237/// The ACOMP dispatch predicate — shared by the fingerprint hook, the GC scan,
238/// and (as adjacent literals) the `execute_feature` match arm, so they can
239/// never disagree on what counts as a component instance.
240pub(crate) fn is_acomp_type(feature_type: &str) -> bool {
241    matches!(feature_type, "ACOMP" | "ASSEMBLY COMPONENT")
242}
243
244/// Merge a request's `partsLibrary` block into the root store (start of every
245/// `execute_history` run — this is how a LOADED document seeds the library).
246/// A RESIDENT entry always wins: mid-session the kernel store is the source of
247/// truth (it carries heals, refreshes, and pending-dirty state the request's
248/// echo of the loaded block predates); the block only ever FILLS missing names
249/// (document load starts from an empty store, cleared by `clear_history_cache`).
250pub(crate) fn ingest(request_map: &PartsLibraryMap) {
251    if request_map.is_empty() {
252        return;
253    }
254    let seeded = ROOT.with(|root| {
255        let mut root = root.borrow_mut();
256        let mut seeded = false;
257        for (name, incoming) in request_map {
258            if root.contains_key(name) {
259                continue;
260            }
261            let mut entry = incoming.clone();
262            // A loaded block's entry gets the same stamp as an inserted one, so a
263            // document saved before per-loop ids existed picks them up on open.
264            stamp_document_loop_ids(&mut entry.document);
265            entry.doc_hash = stable_json_hash(&entry.document);
266            // A document saved before parts carried their ports heals once.
267            entry.dirty = needs_port_heal(&entry);
268            root.insert(name.clone(), entry);
269            seeded = true;
270        }
271        seeded
272    });
273    if seeded {
274        bump_revision();
275    }
276}
277
278/// Whether an entry must heal to pick up its ports: its document declares a
279/// PORT feature (at any depth — a nested assembly's parts count) but the entry
280/// carries no port records, i.e. it was saved before parts carried them.
281fn needs_port_heal(entry: &PartsLibraryEntry) -> bool {
282    entry.ports.is_empty() && document_declares_ports(&entry.document)
283}
284
285/// Whether any feature in `document` — or in a library entry it embeds — is
286/// a PORT.
287fn document_declares_ports(document: &serde_json::Value) -> bool {
288    match document {
289        serde_json::Value::Object(map) => {
290            map.get("type").and_then(serde_json::Value::as_str) == Some("PORT")
291                || map.values().any(document_declares_ports)
292        }
293        serde_json::Value::Array(items) => items.iter().any(document_declares_ports),
294        _ => false,
295    }
296}
297
298/// The CONTENT IDENTITY of an entry: what makes two entries the same PART.
299/// `snapshot` is deliberately excluded — a heal re-derives the snapshot from
300/// the same `document`, so it must not read as a different part. This is the
301/// same identity [`mix_descriptor_fingerprint`] mixes into an ACOMP's cache
302/// fingerprint, so "install kept the resident entry" and "the instance
303/// replayed from cache" can never disagree.
304fn identity(entry: &PartsLibraryEntry) -> (&str, &str, u64) {
305    (&entry.source_key, &entry.source_signature, entry.doc_hash)
306}
307
308/// INSTALL a library wholesale — the app → history-runner channel (the runner
309/// owns its own thread-local store, and the per-run request no longer carries
310/// the block, so this is how a background thread/worker learns the library).
311///
312/// Unlike [`ingest`] (fill-only, resident-wins) this makes the store MATCH
313/// `incoming` BY CONTENT IDENTITY, which is what a cache channel needs:
314///
315/// * a name missing from the store is inserted (stamped + hashed like ingest);
316/// * a name whose resident entry has the SAME identity is KEPT VERBATIM — that
317///   is what protects a runner-side heal, whose only trace is a rewritten
318///   `snapshot`, from being clobbered by a re-send;
319/// * a name whose resident entry has a DIFFERENT identity is REPLACED and
320///   marked `dirty`, so the ACOMP self-heal lane re-derives every instance
321///   (`dirty` is `#[serde(skip)]` and cannot ride the wire — it is derived
322///   HERE, from the identity mismatch);
323/// * a resident name ABSENT from `incoming` is dropped.
324///
325/// Returns whether anything changed (and bumps the revision if so).
326pub fn install_parts_library(incoming: &PartsLibraryMap) -> bool {
327    let mut changed = false;
328    ROOT.with(|root| {
329        let mut root = root.borrow_mut();
330        root.retain(|name, _| {
331            let keep = incoming.contains_key(name);
332            changed |= !keep;
333            keep
334        });
335        for (name, entry) in incoming {
336            let mut entry = entry.clone();
337            stamp_document_loop_ids(&mut entry.document);
338            entry.doc_hash = stable_json_hash(&entry.document);
339            entry.dirty = needs_port_heal(&entry);
340            match root.get(name) {
341                // Same part: keep the RESIDENT copy — it may carry a heal this
342                // side derived and the sender never saw.
343                Some(resident) if identity(resident) == identity(&entry) => continue,
344                // Genuinely different content: replace, and force the self-heal
345                // lane so every instance follows the new document.
346                Some(_) => entry.dirty = true,
347                None => {}
348            }
349            root.insert(name.clone(), entry);
350            changed = true;
351        }
352    });
353    if changed {
354        bump_revision();
355    }
356    changed
357}
358
359/// A CLONE of the root store — what the app hands a runner through
360/// [`install_parts_library`]. (`parts_library_json` is the SAVE door; this is
361/// the in-process one, with no JSON round trip on the native paths.)
362pub fn parts_library_map() -> PartsLibraryMap {
363    ROOT.with(|root| root.borrow().clone())
364}
365
366/// The PREFLIGHT for a run: every part name the request's ACOMP features
367/// reference that the root store cannot resolve, in request order.
368///
369/// A background runner calls this BEFORE executing a run and refuses the run
370/// when it is non-empty. That is the drift-proof half of the library channel:
371/// the orphan GC at the end of every run can legitimately drop entries the
372/// sender still believes are resident (undo to zero components GCs the runner's
373/// store while the sender's store, which never ran, keeps them), and no
374/// revision bookkeeping can see that. Missing CONTENT is observable; that is
375/// what this checks.
376pub fn missing_library_parts(request: &HistoryRequest) -> Vec<String> {
377    let mut missing = Vec::new();
378    ROOT.with(|root| {
379        let root = root.borrow();
380        for descriptor in &request.features {
381            if !is_acomp_type(&descriptor.feature_type) {
382                continue;
383            }
384            let Some(name) = descriptor
385                .input_params
386                .get("partName")
387                .and_then(|value| value.as_str())
388                .map(str::trim)
389                .filter(|name| !name.is_empty())
390            else {
391                continue;
392            };
393            if !root.contains_key(name) && !missing.iter().any(|seen| seen == name) {
394                missing.push(name.to_string());
395            }
396        }
397    });
398    missing
399}
400
401/// The history-cache hook for one descriptor: mix the referenced library
402/// entry's CONTENT identity (source key/signature/document hash — snapshot
403/// deliberately excluded, a heal must not dirty the instance) into the
404/// feature fingerprint, and report whether a DIRTY entry forces re-execution.
405/// Non-ACOMP descriptors pass through untouched.
406pub(crate) fn mix_descriptor_fingerprint(
407    descriptor: &FeatureDescriptor,
408    fingerprint: u64,
409) -> (u64, bool) {
410    if !is_acomp_type(&descriptor.feature_type) {
411        return (fingerprint, false);
412    }
413    use std::hash::{Hash, Hasher};
414    let mut hasher = std::collections::hash_map::DefaultHasher::new();
415    fingerprint.hash(&mut hasher);
416    let part_name = descriptor
417        .input_params
418        .get("partName")
419        .and_then(|value| value.as_str())
420        .unwrap_or("");
421    part_name.hash(&mut hasher);
422    let mut force_dirty = false;
423    match active_entry(part_name) {
424        Some(entry) => {
425            1u8.hash(&mut hasher);
426            entry.source_key.hash(&mut hasher);
427            entry.source_signature.hash(&mut hasher);
428            entry.doc_hash.hash(&mut hasher);
429            force_dirty = entry.dirty;
430        }
431        None => 0u8.hash(&mut hasher),
432    }
433    (hasher.finish(), force_dirty)
434}
435
436/// Orphan GC (end of every `execute_history` run): retain only entries some
437/// ACOMP feature in the REQUEST references (the request always carries the full
438/// feature list — editor stop points truncate execution, not the list).
439pub(crate) fn gc_after_rebuild(request: &HistoryRequest) {
440    let referenced: std::collections::BTreeSet<String> = request
441        .features
442        .iter()
443        .filter(|descriptor| is_acomp_type(&descriptor.feature_type))
444        .filter_map(|descriptor| {
445            descriptor
446                .input_params
447                .get("partName")
448                .and_then(|value| value.as_str())
449                .map(|name| name.trim().to_string())
450        })
451        .collect();
452    let dropped = ROOT.with(|root| {
453        let mut root = root.borrow_mut();
454        let before = root.len();
455        root.retain(|name, _| referenced.contains(name));
456        before != root.len()
457    });
458    if dropped {
459        bump_revision();
460    }
461}
462
463// ===========================================================================
464// Isolated sub-part execution (the self-heal / insert lane)
465// ===========================================================================
466
467/// Execute an embedded part document headlessly and return its surviving
468/// solids as PART-LOCAL `(scene name, owned BREP)` pairs in history order.
469/// Never touches the history cache or the parent scene; every handle it
470/// registers is freed before returning. See the module doc for why this must
471/// not recurse into `execute_history`.
472fn run_isolated_document(document: &serde_json::Value) -> Result<IsolatedRun, String> {
473    let mut request: HistoryRequest = serde_json::from_value(document.clone())
474        .map_err(|error| format!("embedded part document does not parse: {error}"))?;
475    // A stale editor stop point saved into a library part must not silently
476    // truncate the part on every heal.
477    request.stop_at_id = None;
478    request.stop_before_id = None;
479
480    // The sub-document's own library is the active one for this run (a nested
481    // assembly's ACOMPs resolve against it; missing entries are clean errors,
482    // never a fallback to the parent's library). Its entries arrive raw from
483    // the JSON, so the port heal is decided here as `ingest` decides it for a
484    // loaded document — an inner part saved before parts carried their ports
485    // must not take the fast lane and publish none.
486    STACK.with(|stack| {
487        let mut library = std::mem::take(&mut request.parts_library);
488        for entry in library.values_mut() {
489            entry.dirty = needs_port_heal(entry);
490        }
491        stack.borrow_mut().push(library);
492    });
493    let outcome = run_isolated_features(&request);
494    STACK.with(|stack| {
495        stack.borrow_mut().pop();
496    });
497    outcome
498}
499
500/// The feature walk of [`run_isolated_document`], separated so the library
501/// stack push/pop brackets every exit path.
502/// What an isolated part run yields: the surviving solids (part-local names,
503/// history order) and the part's ports (part-local ids, part-local pose).
504type IsolatedRun = (Vec<(String, BrepSolid)>, BTreeMap<String, PortRecord>);
505
506fn run_isolated_features(request: &HistoryRequest) -> Result<IsolatedRun, String> {
507    let env = Env::build(&request.expressions, &request.configurator).unwrap_or_else(Env::poisoned);
508    let mut scene = SceneMap::default();
509    let mut results = Vec::with_capacity(request.features.len());
510    let mut all_handles: Vec<u32> = Vec::new();
511    let mut error: Option<String> = None;
512    for descriptor in &request.features {
513        let result = execute_feature(descriptor, &env, &scene);
514        scene.apply(&result);
515        all_handles.extend(result.added.iter().map(|added| added.handle));
516        // Replicate the loop's producer stamp so the snapshot carries the same
517        // topology metadata a real history run would have written.
518        if !result.id.is_empty() {
519            let mut seed = serde_json::Map::new();
520            seed.insert(
521                "sourceFeatureId".into(),
522                serde_json::Value::String(result.id.clone()),
523            );
524            for added in &result.added {
525                for (_, face_name) in &added.face_names {
526                    scene_metadata::merge_record(face_name, &seed, false);
527                }
528                for (_, edge_name) in &added.edge_names {
529                    scene_metadata::merge_record(edge_name, &seed, false);
530                }
531            }
532        }
533        if let Some(message) = &result.error {
534            error = Some(format!("feature '{}': {message}", result.id));
535            break;
536        }
537        results.push(result);
538    }
539
540    // Survivors: added solids still scene-resident under their own name, in
541    // history order (a consumed/superseded solid's name no longer maps to it).
542    let mut members = Vec::new();
543    if error.is_none() {
544        for result in &results {
545            for added in &result.added {
546                if scene.resolve_solid(&added.name) == Some(added.handle) {
547                    let solid =
548                        crate::with_registered_solid_str(added.handle, |solid| Ok(solid.clone()))?;
549                    members.push((added.name.clone(), solid));
550                }
551            }
552        }
553    }
554    for handle in all_handles {
555        sheet_metal::remove_tree(handle);
556        crate::free_registered_solid(handle);
557    }
558    // The part's ports: every record the run left in the scene (a nested
559    // assembly's component ports are already namespaced by its ACOMPs).
560    let ports: BTreeMap<String, PortRecord> = scene
561        .ports
562        .iter()
563        .map(|(id, port)| (id.clone(), port.clone()))
564        .collect();
565    match error {
566        Some(message) => Err(message),
567        None if members.is_empty() => Err("embedded part document produced no solids".into()),
568        None => Ok((members, ports)),
569    }
570}
571
572/// Execute an embedded part document and snapshot the result — the ONE
573/// snapshot-(re)build lane ([`add_part_to_library`] insert + ACOMP self-heal).
574/// The scene-metadata store is bracketed around the run: the sub-part's
575/// un-namespaced records feed the snapshot capture and are then discarded, so
576/// the parent document's store is byte-identical afterwards on every path.
577pub(crate) fn rebuild_snapshot(
578    document: &serde_json::Value,
579) -> Result<(String, BTreeMap<String, PortRecord>), String> {
580    let saved = scene_metadata::take_store();
581    let outcome = run_isolated_document(document).and_then(|(members, ports)| {
582        let refs: Vec<(&str, &BrepSolid)> = members
583            .iter()
584            .map(|(name, solid)| (name.as_str(), solid))
585            .collect();
586        crate::snapshot_solids(&refs).map(|snapshot| (snapshot, ports))
587    });
588    scene_metadata::restore_store(saved);
589    outcome
590}
591
592// ===========================================================================
593// The app-facing insert / update / save surface
594// ===========================================================================
595
596/// A part name not yet used in the root store: `requested`, else
597/// `requested-2`, `requested-3`, …
598fn unique_name(root: &PartsLibraryMap, requested: &str) -> String {
599    if !root.contains_key(requested) {
600        return requested.to_string();
601    }
602    for counter in 2.. {
603        let candidate = format!("{requested}-{counter}");
604        if !root.contains_key(&candidate) {
605            return candidate;
606        }
607    }
608    unreachable!("counter loop is unbounded")
609}
610
611/// Stamp per-loop sketch ids onto an entry's document, in place — run at EVERY
612/// door into the library (insert, refresh, and the loaded-block seeding), so a
613/// part gets durable loop ids no matter how it arrived.
614///
615/// The engine also stamps a document on LOAD
616/// (`EngineState::set_history_json`), but a part inserted or refreshed through
617/// the assembly lanes never passes through that: `add_part_to_library` and
618/// `refresh_library_entry` take a document STRING straight from a caller (the
619/// file panel inserts a saved `.BREP.json`; update-components commits an edited
620/// one). Stamping here rather than at those call sites means a future lane that
621/// installs a document cannot silently skip it.
622///
623/// This renames nothing — the kernel derives the same loop ids every run. What
624/// it adds is durability: a stored id survives deleting the edge it was derived
625/// from, which derivation alone cannot. See `features/sketch/loop_ids`.
626fn stamp_document_loop_ids(document: &mut serde_json::Value) {
627    let Some(features) = document
628        .get_mut("features")
629        .and_then(serde_json::Value::as_array_mut)
630    else {
631        return;
632    };
633    for feature in features {
634        if feature.get("type").and_then(serde_json::Value::as_str) != Some("S") {
635            continue;
636        }
637        let Some(sketch) = feature
638            .get_mut("persistentData")
639            .and_then(|data| data.get_mut("sketch"))
640        else {
641            continue;
642        };
643        crate::feature_pipeline::assign_sketch_loop_ids(sketch);
644    }
645}
646
647/// INSERT: hand the opened sub-part document over to the library. Executes the
648/// document once, snapshots, stores the entry, and returns the EFFECTIVE part
649/// name (the ACOMP `partName` the app must reference):
650///
651/// - a live entry with the same `sourceKey` AND `sourceSignature` is reused
652///   verbatim (no re-execution — instant re-insert, spec §2.1);
653/// - same `sourceKey` but a DIFFERENT signature gets a fresh entry under a
654///   disambiguated name (existing instances keep their version; the explicit
655///   refresh lane is [`refresh_library_entry`]);
656/// - a requested name taken by different content is disambiguated (`name-2`).
657#[wasm_bindgen]
658pub fn add_part_to_library(
659    name: &str,
660    source_key: &str,
661    source_signature: &str,
662    document_json: &str,
663) -> Result<String, JsValue> {
664    let requested = name.trim();
665    if requested.is_empty() {
666        return Err(JsValue::from_str("add_part_to_library: empty part name"));
667    }
668    let reused = ROOT.with(|root| {
669        root.borrow().iter().find_map(|(entry_name, entry)| {
670            (entry.source_key == source_key && entry.source_signature == source_signature)
671                .then(|| entry_name.clone())
672        })
673    });
674    if let Some(entry_name) = reused {
675        return Ok(entry_name);
676    }
677    let mut document: serde_json::Value = serde_json::from_str(document_json)
678        .map_err(|error| JsValue::from_str(&format!("add_part_to_library: {error}")))?;
679    // Stamp BEFORE the hash and the snapshot so both describe the stored form —
680    // a later re-stamp then finds nothing to change and cannot dirty the entry.
681    stamp_document_loop_ids(&mut document);
682    let (snapshot, ports) = rebuild_snapshot(&document)
683        .map_err(|error| JsValue::from_str(&format!("add_part_to_library: {error}")))?;
684    let entry = PartsLibraryEntry {
685        source_key: source_key.to_string(),
686        source_signature: source_signature.to_string(),
687        doc_hash: stable_json_hash(&document),
688        document,
689        snapshot,
690        ports,
691        dirty: false,
692    };
693    let final_name = ROOT.with(|root| {
694        let mut root = root.borrow_mut();
695        let final_name = unique_name(&root, requested);
696        root.insert(final_name.clone(), entry);
697        final_name
698    });
699    bump_revision();
700    Ok(final_name)
701}
702
703/// UPDATE (update-components / edit-in-place commit): replace an existing
704/// entry's document + signature and mark it DIRTY. The next history run's
705/// ACOMP self-heal lane re-executes the document, re-snapshots, and every
706/// instance of the part follows.
707#[wasm_bindgen]
708pub fn refresh_library_entry(
709    name: &str,
710    source_signature: &str,
711    document_json: &str,
712) -> Result<(), JsValue> {
713    let mut document: serde_json::Value = serde_json::from_str(document_json)
714        .map_err(|error| JsValue::from_str(&format!("refresh_library_entry: {error}")))?;
715    // Stamped before the hash, as in `add_part_to_library`.
716    stamp_document_loop_ids(&mut document);
717    ROOT.with(|root| {
718        let mut root = root.borrow_mut();
719        let Some(entry) = root.get_mut(name) else {
720            return Err(JsValue::from_str(&format!(
721                "refresh_library_entry: no parts-library entry named '{name}'"
722            )));
723        };
724        entry.source_signature = source_signature.to_string();
725        entry.doc_hash = stable_json_hash(&document);
726        entry.document = document;
727        entry.ports.clear(); // the heal re-derives them from the new document
728        entry.dirty = true;
729        Ok(())
730    })?;
731    bump_revision();
732    Ok(())
733}
734
735/// The current library in the `partsLibrary` block shape (spec §2.1) — what
736/// SAVE must serialize into the document (never the loaded block: this copy
737/// carries healed snapshots, refreshes, and GC).
738#[wasm_bindgen]
739pub fn parts_library_json() -> String {
740    ROOT.with(|root| {
741        serde_json::to_string(&*root.borrow()).unwrap_or_else(|_| "{}".to_string())
742    })
743}