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