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}