Skip to main content

brep_render/
history.rs

1//! [`History`] — the engine-owned, editable model recipe: the ordered feature
2//! history plus the rollback index (the feature the model is currently built up
3//! to). This is the SINGLE SOURCE OF TRUTH for the model — the UI keeps NO copy;
4//! it mutates and reads the history only through [`crate::engine_state::EngineState`]
5//! methods. That keeps one engine-owned history (UI-agnostic) and converges with
6//! the in-flight "whole history in Rust" pipeline migration — later this sinks
7//! into `brep-kernel-rs` proper without touching the UI.
8//!
9//! Rolling to a step re-runs `features[0..=rollback]` through the SAME kernel
10//! pipeline: the full feature list stays in the request and `stopAtId` (the
11//! editor's "stop at the expanded feature") halts execution AFTER the rolled-to
12//! feature, so the kernel's incremental history cache is RETAINED across rolls
13//! (no thrash) and the viewport shows the model as of that step.
14
15use serde_json::Value;
16
17/// The cap on the undo (and redo) stack depth — old entries fall off the bottom.
18const MAX_UNDO: usize = 100;
19
20/// A restorable model state: the whole document PLUS the rolled-to step, captured
21/// together so an undo returns both the geometry and the view to the state they
22/// were in right before the mutation.
23#[derive(Debug, Clone)]
24struct Snapshot {
25    request: Value,
26    rollback: usize,
27    /// The parts-library block as it stood before the mutation. An `Rc`, so
28    /// capturing it costs a pointer bump however large the library is — undo
29    /// and redo still rewind it exactly as they did when it lived inside
30    /// `request` (a redo of a component insert must restore the entry its
31    /// ACOMP references).
32    parts_library: std::rc::Rc<Value>,
33}
34
35/// The engine-owned mutable history.
36#[derive(Debug, Clone)]
37pub struct History {
38    /// The whole `HistoryRequest` document
39    /// (`{expressions, configurator, features: [...]}`).
40    request: Value,
41    /// Index into `features` the model is rolled to (clamped to the last).
42    rollback: usize,
43    /// Undo/redo over the MODEL document. A snapshot is pushed BEFORE each model
44    /// mutation (edit / add / delete / reorder); roll-to-step is view state and
45    /// is NOT snapshotted. Rapid same-target edits (a slider drag) coalesce into a
46    /// single undo entry via `last_edit_key`. The stacks live here in the engine
47    /// core — the model is engine-owned, so its undo history is too; the UI only
48    /// triggers `undo()` / `redo()`.
49    undo_stack: Vec<Snapshot>,
50    redo_stack: Vec<Snapshot>,
51    /// The coalescing token of the most recently recorded edit (see `checkpoint`).
52    last_edit_key: Option<String>,
53    /// The persistent GLOBAL feature counter: bumped by one on every new-feature
54    /// mint ([`Self::next_feature_id`]), so a new id is `{shortName}{counter}`.
55    /// MONOTONIC and NEVER reused — deleting a feature does not free its number,
56    /// and (deliberately) undo does NOT rewind it, so re-doing an add can't collide
57    /// with a number already handed out. It is kept OFF `self.request` in memory
58    /// (so the undo snapshots that clone `request` never rewind it) and folded into
59    /// the serialized document under `"featureCounter"` so it round-trips save/load
60    /// (see [`Self::request_json`] / [`Self::from_request_json`]).
61    feature_counter: u64,
62    /// The assemblies PARTS LIBRARY block (`partsLibrary`, spec §2.1), kept OFF
63    /// `self.request` for the same reason as `feature_counter`: every per-edit
64    /// pass over the document — the undo checkpoint clone, the kernel request
65    /// built by [`Self::prefix_request`], the assembly pose fold's serialize →
66    /// parse round trip — would otherwise copy the whole library, which for an
67    /// imported STEP assembly is megabytes of embedded part payload PER EDIT
68    /// and froze the browser UI. Folded back in by [`Self::request_json`] so
69    /// the SAVED document is byte-identical to before, and lifted back out by
70    /// [`Self::from_request_json`] / the adopt doors.
71    ///
72    /// `Rc` because undo/redo MUST rewind it (a redo of a component insert has
73    /// to restore the entry its ACOMP references) while a checkpoint must stay
74    /// a pointer copy — the whole point of moving it off `request`.
75    parts_library: std::rc::Rc<Value>,
76    /// Whether `parts_library` is known to MIRROR the kernel store — i.e. it
77    /// was written by [`Self::set_parts_library`] from `parts_library_json()`.
78    /// False after a load, an adopt, or an undo/redo restore, any of which can
79    /// install a block that predates the store's heals and GC.
80    ///
81    /// The engine's assembly sync re-reads the store only when this is false or
82    /// the store's revision moved; serializing the store costs the whole
83    /// embedded part payload, so doing it once per edit is exactly the cost
84    /// this change exists to remove — but skipping it when the block is NOT a
85    /// mirror would save a stale library.
86    parts_library_mirrors_store: bool,
87}
88
89impl Default for History {
90    fn default() -> Self {
91        Self {
92            request: empty_request(),
93            rollback: 0,
94            undo_stack: Vec::new(),
95            redo_stack: Vec::new(),
96            last_edit_key: None,
97            feature_counter: 0,
98            parts_library: std::rc::Rc::new(Value::Null),
99            parts_library_mirrors_store: false,
100        }
101    }
102}
103
104fn empty_request() -> Value {
105    serde_json::json!({ "expressions": "", "configurator": {}, "features": [] })
106}
107
108/// The trailing run of ASCII digits of `id` read as a number (`"P.CU12"` → 12,
109/// `"Box"` → 0). Trailing digits are ASCII (one byte each), so the slice boundary
110/// is always a valid char boundary; a missing/overflowing run yields 0.
111fn trailing_number(id: &str) -> u64 {
112    let digit_bytes = id
113        .bytes()
114        .rev()
115        .take_while(u8::is_ascii_digit)
116        .count();
117    id[id.len() - digit_bytes..].parse().unwrap_or(0)
118}
119
120impl History {
121    /// Load a whole history document (a saved part file parses as one). Rolls to
122    /// the last feature. Ensures a `features` array exists.
123    pub fn from_request_json(json: &str) -> Result<Self, String> {
124        let mut request: Value =
125            serde_json::from_str(json).map_err(|e| format!("history parse: {e}"))?;
126        if !request.get("features").map(Value::is_array).unwrap_or(false) {
127            if let Some(obj) = request.as_object_mut() {
128                obj.insert("features".into(), Value::Array(Vec::new()));
129            } else {
130                request = empty_request();
131            }
132        }
133        // Lift the persistent feature counter OUT of the document so it lives ONLY
134        // in the struct field: kept off `self.request`, the undo snapshots (which
135        // clone `request`) can never rewind it, and it can't be double-folded on
136        // re-serialize. A document with no stored counter (fresh or saved before
137        // this field existed) safe-inits below.
138        let stored = request
139            .as_object_mut()
140            .and_then(|obj| obj.remove("featureCounter"))
141            .and_then(|value| value.as_u64());
142        // Lift the parts-library block out of the document for the same reason
143        // (see the `parts_library` field): every per-edit copy of `request`
144        // would otherwise carry megabytes of embedded part payload.
145        let parts_library = request
146            .as_object_mut()
147            .and_then(|obj| obj.remove("partsLibrary"))
148            .unwrap_or(Value::Null);
149        let mut history = Self {
150            request,
151            rollback: 0,
152            parts_library: std::rc::Rc::new(parts_library),
153            ..Self::default()
154        };
155        history.rollback = history.len().saturating_sub(1);
156        // Safe init when unstored: start ABOVE the largest numeric suffix already
157        // present among feature ids so the next mint (`{shortName}{counter+1}`)
158        // cannot collide with an existing id. This holds because no shortName ends
159        // in a digit (verified — even `IMPORT3D` ends in `D`), so an id's trailing
160        // digits ARE its numeric suffix and `counter+1` strictly exceeds them all.
161        history.feature_counter = stored.unwrap_or_else(|| history.max_id_suffix());
162        Ok(history)
163    }
164
165    /// The largest trailing-integer suffix among all existing feature ids (0 when
166    /// none carry one) — the floor for a safe counter init on a document with no
167    /// stored `featureCounter` (see [`Self::from_request_json`]).
168    fn max_id_suffix(&self) -> u64 {
169        self.features()
170            .iter()
171            .filter_map(|f| {
172                f.get("inputParams")
173                    .and_then(|p| p.get("id"))
174                    .and_then(Value::as_str)
175            })
176            .map(trailing_number)
177            .max()
178            .unwrap_or(0)
179    }
180
181    /// The features slice (empty if none).
182    pub fn features(&self) -> &[Value] {
183        self.request
184            .get("features")
185            .and_then(Value::as_array)
186            .map(Vec::as_slice)
187            .unwrap_or(&[])
188    }
189
190    fn features_mut(&mut self) -> &mut Vec<Value> {
191        let obj = self
192            .request
193            .as_object_mut()
194            .expect("history request is a JSON object");
195        obj.entry("features")
196            .or_insert_with(|| Value::Array(Vec::new()));
197        obj.get_mut("features")
198            .and_then(Value::as_array_mut)
199            .expect("features is a JSON array")
200    }
201
202    pub fn len(&self) -> usize {
203        self.features().len()
204    }
205
206    pub fn is_empty(&self) -> bool {
207        self.len() == 0
208    }
209
210    /// The rolled-to index, clamped to a valid feature (0 when empty).
211    pub fn rollback(&self) -> usize {
212        self.rollback.min(self.len().saturating_sub(1))
213    }
214
215    pub fn set_rollback(&mut self, index: usize) {
216        self.rollback = if self.is_empty() {
217            0
218        } else {
219            index.min(self.len() - 1)
220        };
221        // Rolling to a step is a view move, not a model edit: it records NO undo
222        // snapshot, but it DOES break the edit-coalescing run so the next edit
223        // starts a fresh undo entry rather than merging with a pre-roll edit.
224        self.last_edit_key = None;
225    }
226
227    pub fn feature_type(&self, index: usize) -> Option<String> {
228        self.features()
229            .get(index)?
230            .get("type")
231            .and_then(Value::as_str)
232            .map(String::from)
233    }
234
235    pub fn feature_id(&self, index: usize) -> Option<String> {
236        self.features()
237            .get(index)?
238            .get("inputParams")
239            .and_then(|p| p.get("id"))
240            .and_then(Value::as_str)
241            .map(String::from)
242    }
243
244    pub fn index_of(&self, id: &str) -> Option<usize> {
245        self.features().iter().position(|f| {
246            f.get("inputParams")
247                .and_then(|p| p.get("id"))
248                .and_then(Value::as_str)
249                == Some(id)
250        })
251    }
252
253    /// The `inputParams` document of the feature at `index` (for the dialog).
254    pub fn feature_params(&self, index: usize) -> Option<Value> {
255        self.features().get(index)?.get("inputParams").cloned()
256    }
257
258    /// SOLVER write-back fold (the assembly pose-authority contract): set ONE
259    /// `inputParams` key of the feature whose `inputParams.id == id`, WITHOUT an
260    /// undo checkpoint and WITHOUT breaking edit coalescing — a solve write-back
261    /// is the kernel adopting its own result, not a user edit, so it must never
262    /// mint an undo entry (undoing a user action then re-running re-solves and
263    /// re-folds anyway). Returns whether a feature matched.
264    pub fn fold_param_no_undo(&mut self, id: &str, key: &str, value: Value) -> bool {
265        let Some(index) = self.index_of(id) else {
266            return false;
267        };
268        if let Some(feature) = self.features_mut().get_mut(index) {
269            if let Some(params) = feature
270                .get_mut("inputParams")
271                .and_then(Value::as_object_mut)
272            {
273                params.insert(key.to_string(), value);
274                return true;
275            }
276        }
277        false
278    }
279
280    pub fn set_feature_params(&mut self, index: usize, params: Value) {
281        if index >= self.len() {
282            return;
283        }
284        // Coalesce a slider drag (many consecutive edits of the SAME feature) into
285        // one undo entry, keyed by the feature index.
286        self.checkpoint(Some(&format!("param:{index}")));
287        if let Some(feat) = self.features_mut().get_mut(index) {
288            if let Some(obj) = feat.as_object_mut() {
289                obj.insert("inputParams".into(), params);
290            }
291        }
292    }
293
294    /// Replace the `inputParams` of MANY features as ONE mutation —
295    /// [`Self::set_feature_params`]'s batch sibling, and the reason it exists:
296    /// a packed BOM row rolls up N occurrences, so editing one cell writes N
297    /// features. A `set_feature_params` loop would mint N undo entries (each
298    /// keyed `param:{index}`, so none of them coalesce with each other), and
299    /// the user would have to press undo N times to take back ONE edit.
300    ///
301    /// Exactly ONE checkpoint covers the whole batch. The coalesce key names
302    /// the SET of indices, so a run of edits to the SAME set (typing into one
303    /// packed cell) still merges into a single entry, while switching to a
304    /// different set starts a new one — the `param:{index}` rule, lifted to a
305    /// group. Out-of-range indices are skipped; an empty batch is a no-op (no
306    /// checkpoint, so a fan-out that matched nothing leaves no empty entry).
307    pub fn set_many_feature_params(&mut self, edits: &[(usize, Value)]) {
308        let len = self.len();
309        let mut in_range: Vec<&(usize, Value)> =
310            edits.iter().filter(|(index, _)| *index < len).collect();
311        if in_range.is_empty() {
312            return;
313        }
314        // The key must not depend on the caller's ordering, or the same packed
315        // row could produce two different keys and stop coalescing.
316        in_range.sort_by_key(|(index, _)| *index);
317        let key: Vec<String> = in_range
318            .iter()
319            .map(|(index, _)| index.to_string())
320            .collect();
321        self.checkpoint(Some(&format!("params:{}", key.join(","))));
322        for (index, params) in in_range {
323            if let Some(feature) = self.features_mut().get_mut(*index) {
324                if let Some(object) = feature.as_object_mut() {
325                    object.insert("inputParams".into(), params.clone());
326                }
327            }
328        }
329    }
330
331    pub fn push_feature(&mut self, feature: Value) {
332        self.checkpoint(None);
333        self.features_mut().push(feature);
334    }
335
336    /// Append MANY features as ONE mutation — the batch an import lane needs
337    /// (`EngineState::add_features`). Distinct from a `push_feature` loop in the
338    /// two ways that matter: exactly ONE undo checkpoint (an N-instance assembly
339    /// import undoes in one step, not N), and the caller re-runs once instead of
340    /// once per feature. No-op for an empty batch (no checkpoint, so an import
341    /// that produced nothing leaves no empty undo entry).
342    pub fn push_features(&mut self, features: Vec<Value>) {
343        if features.is_empty() {
344            return;
345        }
346        self.checkpoint(None);
347        self.features_mut().extend(features);
348    }
349
350    pub fn remove_feature(&mut self, index: usize) {
351        if index >= self.len() {
352            return;
353        }
354        self.checkpoint(None);
355        self.features_mut().remove(index);
356    }
357
358    pub fn swap(&mut self, a: usize, b: usize) {
359        let len = self.len();
360        if a < len && b < len && a != b {
361            self.checkpoint(None);
362            self.features_mut().swap(a, b);
363        }
364    }
365
366    /// Mint the id for a NEW feature: `{base}{N}` where `base` is the feature's
367    /// shortName and `N` is this history's persistent GLOBAL counter, bumped by one
368    /// on every mint (`P.CU` → `P.CU7`, `S` → `S8`). GLOBAL across all feature
369    /// types, MONOTONIC, and NEVER reused — a delete does not free a number and
370    /// undo does not rewind the counter — and it persists across save/load, so two
371    /// features can never receive the same id over the document's whole lifetime.
372    pub fn next_feature_id(&mut self, base: &str) -> String {
373        self.feature_counter += 1;
374        format!("{base}{}", self.feature_counter)
375    }
376
377    /// The `stopAtId`-truncated request that stops AFTER the rolled-to feature —
378    /// the roll-to-step request the pipeline runs. Empty history → empty request.
379    pub fn prefix_request(&self) -> Value {
380        let mut request = self.request.clone();
381        if let Some(id) = self.feature_id(self.rollback()) {
382            if let Some(obj) = request.as_object_mut() {
383                obj.insert("stopAtId".into(), Value::String(id));
384            }
385        }
386        request
387    }
388
389    /// The tree listing for the UI: `{ step, features: [{index, type, id}] }`.
390    pub fn listing_json(&self) -> String {
391        let features: Vec<Value> = self
392            .features()
393            .iter()
394            .enumerate()
395            .map(|(index, _)| {
396                serde_json::json!({
397                    "index": index,
398                    "type": self.feature_type(index).unwrap_or_else(|| "?".into()),
399                    "id": self.feature_id(index).unwrap_or_else(|| "(no id)".into()),
400                })
401            })
402            .collect();
403        serde_json::json!({ "step": self.rollback(), "features": features }).to_string()
404    }
405
406    /// The whole request document (for persistence / debugging), with the
407    /// persistent global feature counter folded back in under `"featureCounter"`
408    /// so it round-trips through save/load (the twin of [`Self::from_request_json`],
409    /// which lifts it back out). Written only when non-zero, so a document that has
410    /// never minted a feature persists byte-for-byte as before (mirrors the
411    /// metadata field's "un-annotated model persists unchanged" convention).
412    pub fn request_json(&self) -> String {
413        let has_library = self
414            .parts_library
415            .as_object()
416            .map(|map| !map.is_empty())
417            .unwrap_or(false);
418        if self.feature_counter == 0 && !has_library {
419            return self.request.to_string();
420        }
421        let mut document = self.request.clone();
422        if let Some(obj) = document.as_object_mut() {
423            if self.feature_counter != 0 {
424                obj.insert("featureCounter".into(), Value::from(self.feature_counter));
425            }
426            if has_library {
427                obj.insert("partsLibrary".into(), (*self.parts_library).clone());
428            }
429        }
430        document.to_string()
431    }
432
433    /// The document WITHOUT the parts-library block — for the kernel round
434    /// trips that never read it. The assembly pose fold
435    /// (`assembly_apply_document_json`) only rewrites the `assembly` block and
436    /// per-feature `inputParams`, so handing it the library would serialize,
437    /// parse and re-serialize megabytes of part payload on every edit for
438    /// nothing. [`Self::request_json`] is the SAVE door and still carries it.
439    pub fn request_json_without_parts_library(&self) -> String {
440        if self.feature_counter == 0 {
441            return self.request.to_string();
442        }
443        let mut document = self.request.clone();
444        if let Some(obj) = document.as_object_mut() {
445            obj.insert("featureCounter".into(), Value::from(self.feature_counter));
446        }
447        document.to_string()
448    }
449
450    /// ADOPT a kernel-folded document (the assembly pose-authority write-back:
451    /// `assembly_apply_document_json` returned this document with the solved
452    /// `assembly` block + poses/isFixed folded onto the owning features).
453    /// Replaces the request WHOLESALE while keeping the rollback index (clamped),
454    /// the undo/redo stacks, and the live feature counter — a solver write-back
455    /// is not a user edit, so no undo checkpoint is pushed (the constraint edit
456    /// that triggered the solve lives in the kernel session's state, outside the
457    /// engine undo lane — flagged for the integrator).
458    pub fn adopt_folded_request(&mut self, json: &str) -> Result<(), String> {
459        let mut value: Value = serde_json::from_str(json)
460            .map_err(|error| format!("folded document parse: {error}"))?;
461        let Some(object) = value.as_object_mut() else {
462            return Err("folded document must be an object".to_string());
463        };
464        // `request_json` serialized the live counter into the document we handed
465        // the fold; the in-memory counter stays authoritative, so strip the echo
466        // (it is re-folded on the next serialize).
467        object.remove("featureCounter");
468        // As in `adopt_document`: present replaces, absent keeps.
469        let adopted_library = object.remove("partsLibrary");
470        if !object.contains_key("features") {
471            object.insert("features".into(), Value::Array(Vec::new()));
472        }
473        self.request = value;
474        if let Some(library) = adopted_library {
475            self.set_parts_library(library);
476            self.parts_library_mirrors_store = false;
477        }
478        let last = self.len().saturating_sub(1);
479        self.rollback = self.rollback.min(last);
480        Ok(())
481    }
482
483    // --- Undo / redo over the model document ------------------------------
484
485    fn snapshot(&self) -> Snapshot {
486        Snapshot {
487            request: self.request.clone(),
488            rollback: self.rollback,
489            parts_library: self.parts_library.clone(),
490        }
491    }
492
493    fn restore(&mut self, snap: Snapshot) {
494        self.request = snap.request;
495        self.parts_library = snap.parts_library;
496        self.parts_library_mirrors_store = false;
497        let last = self.len().saturating_sub(1);
498        self.rollback = snap.rollback.min(last);
499    }
500
501    /// Record a pre-mutation snapshot for undo. `coalesce_key` groups a run of
502    /// rapid same-target edits (one slider drag) into a SINGLE undo entry: while
503    /// the same non-empty key repeats, no new snapshot is pushed. A `None` key
504    /// never coalesces, so every structural add/delete/reorder is its own entry.
505    /// Any new snapshot clears the redo stack (a fresh edit forks the timeline)
506    /// and the oldest entry falls off once the stack passes [`MAX_UNDO`].
507    fn checkpoint(&mut self, coalesce_key: Option<&str>) {
508        if coalesce_key.is_some() && coalesce_key == self.last_edit_key.as_deref() {
509            return;
510        }
511        self.undo_stack.push(self.snapshot());
512        if self.undo_stack.len() > MAX_UNDO {
513            self.undo_stack.remove(0);
514        }
515        self.redo_stack.clear();
516        self.last_edit_key = coalesce_key.map(str::to_string);
517    }
518
519    /// Whether an undo step is available (for enabling the toolbar button).
520    pub fn can_undo(&self) -> bool {
521        !self.undo_stack.is_empty()
522    }
523
524    /// Whether a redo step is available.
525    pub fn can_redo(&self) -> bool {
526        !self.redo_stack.is_empty()
527    }
528
529    /// Undo the last model mutation: push the current state onto the redo stack
530    /// and restore the previous document + rolled-to step. Returns whether it
531    /// changed anything (false when the undo stack is empty).
532    pub fn undo(&mut self) -> bool {
533        let Some(prev) = self.undo_stack.pop() else {
534            return false;
535        };
536        self.redo_stack.push(self.snapshot());
537        self.restore(prev);
538        // A distinct undo breaks any coalescing run so the next edit is fresh.
539        self.last_edit_key = None;
540        true
541    }
542
543    /// Redo the last undone model mutation (symmetric with [`Self::undo`]).
544    pub fn redo(&mut self) -> bool {
545        let Some(next) = self.redo_stack.pop() else {
546            return false;
547        };
548        self.undo_stack.push(self.snapshot());
549        self.restore(next);
550        self.last_edit_key = None;
551        true
552    }
553}
554
555// ============================================================================
556// Assembly document accessors (appended — the assemblies Wave-3 slice). A
557// SEPARATE `impl` block so concurrent edits to the primary block don't
558// conflict; purely additive over the existing history API.
559// ============================================================================
560impl History {
561    /// ADOPT a whole replacement document — the assembly FOLD's write-back lane
562    /// (`assembly_apply_document_json` returns the document with the solved
563    /// `assembly` block + poses/isFixed folded into the features; the engine
564    /// adopts it before persisting or re-running — the pose-authority contract).
565    ///
566    /// `checkpoint` chooses the undo semantics: a USER constraint mutation
567    /// records an undo snapshot (so constraint edits stay undoable like any
568    /// model edit); the silent post-run pose fold passes `false` (solver
569    /// write-back is not a user edit — undoing the user's LAST edit must not
570    /// strand an extra fold step in between).
571    ///
572    /// A stray `featureCounter` in the adopted document is stripped (the
573    /// in-memory counter stays authoritative — `request_json` re-folds it on
574    /// serialize, exactly like `from_request_json` lifts it on load). The
575    /// rollback index is preserved (the fold never changes the feature count).
576    pub fn adopt_document(&mut self, document_json: &str) -> Result<(), String> {
577        let mut document: Value = serde_json::from_str(document_json)
578            .map_err(|error| format!("adopt document parse: {error}"))?;
579        if !document.is_object() {
580            return Err("adopt document: not a JSON object".into());
581        }
582        let mut adopted_library = None;
583        if let Some(obj) = document.as_object_mut() {
584            obj.remove("featureCounter");
585            // A block PRESENT in the adopted document replaces the field; one
586            // ABSENT leaves it alone (the fold lane is handed a library-free
587            // document by `request_json_without_parts_library` and must not
588            // silently drop the library on the way back).
589            adopted_library = obj.remove("partsLibrary");
590            if !obj.get("features").map(Value::is_array).unwrap_or(false) {
591                obj.insert("features".into(), Value::Array(Vec::new()));
592            }
593        }
594        self.request = document;
595        if let Some(library) = adopted_library {
596            self.set_parts_library(library);
597            self.parts_library_mirrors_store = false;
598        }
599        Ok(())
600    }
601
602    /// Same adoption with an undo snapshot recorded FIRST (never coalesced) —
603    /// the user-mutation twin of [`Self::adopt_document`].
604    pub fn adopt_document_checkpointed(&mut self, document_json: &str) -> Result<(), String> {
605        // Validate BEFORE snapshotting so a parse failure never pushes a
606        // phantom undo entry.
607        let probe: Value = serde_json::from_str(document_json)
608            .map_err(|error| format!("adopt document parse: {error}"))?;
609        if !probe.is_object() {
610            return Err("adopt document: not a JSON object".into());
611        }
612        self.checkpoint(None);
613        self.adopt_document(document_json)
614    }
615
616    /// Replace the document's `partsLibrary` block (the assemblies parts
617    /// library, spec §2.1) — the caller feeds `brep_kernel::parts_library_json()`
618    /// here (never an echo of a loaded block), so SAVE serializes the kernel
619    /// store with its heals and GC. An empty map clears the field, so a
620    /// non-assembly document serializes byte-identically to before.
621    ///
622    /// The block is held in the `parts_library` FIELD rather than on
623    /// `self.request`; [`Self::request_json`] folds it back into the saved
624    /// document. The on-disk shape is unchanged.
625    pub fn set_parts_library(&mut self, library: Value) {
626        let empty = library.as_object().map(|m| m.is_empty()).unwrap_or(true);
627        self.parts_library = std::rc::Rc::new(if empty { Value::Null } else { library });
628        self.parts_library_mirrors_store = true;
629    }
630
631    /// Replace the `partsLibrary` block as a USER EDIT: one undo checkpoint,
632    /// and the mirror flag CLEARED so the next run ships the block to the
633    /// runner instead of assuming the kernel store already agrees.
634    ///
635    /// [`Self::set_parts_library`] is the other door and means the opposite —
636    /// "this block came OUT of the kernel store" — so it must not be reused
637    /// here: a part-attribute edit is authored on this side and the runner has
638    /// never seen it. `coalesce_key` follows the `param:{index}` rule so a run
639    /// of keystrokes into one attribute is one undo entry.
640    pub fn set_parts_library_edited(&mut self, library: Value, coalesce_key: Option<&str>) {
641        self.checkpoint(coalesce_key);
642        let empty = library.as_object().map(|m| m.is_empty()).unwrap_or(true);
643        self.parts_library = std::rc::Rc::new(if empty { Value::Null } else { library });
644        self.parts_library_mirrors_store = false;
645    }
646
647    /// Whether the `partsLibrary` block currently mirrors the kernel store
648    /// (see `parts_library_mirrors_store`).
649    pub fn parts_library_mirrors_store(&self) -> bool {
650        self.parts_library_mirrors_store
651    }
652
653    /// The `partsLibrary` block (`Value::Null` when the document has none).
654    pub fn parts_library(&self) -> &Value {
655        &self.parts_library
656    }
657
658    /// The document's `assembly` block (`{constraints, idCounter}`), if any.
659    pub fn assembly_block(&self) -> Option<&Value> {
660        self.request.get("assembly")
661    }
662
663    /// The document's `wireHarness` block (`{connections, idCounter,
664    /// buildBundles}`), if any.
665    pub fn wire_harness_block(&self) -> Option<&Value> {
666        self.request.get("wireHarness")
667    }
668
669    /// Replace (or, with `None`, remove) the `wireHarness` block. A USER edit:
670    /// snapshotted for undo, never coalesced — each add / edit / remove of a
671    /// connection is its own undo step, like a feature add or delete.
672    pub fn set_wire_harness_block(&mut self, block: Option<Value>) {
673        self.checkpoint(None);
674        if let Some(object) = self.request.as_object_mut() {
675            match block {
676                Some(block) => {
677                    object.insert("wireHarness".into(), block);
678                }
679                None => {
680                    object.remove("wireHarness");
681                }
682            }
683        }
684    }
685}
686
687// ============================================================================
688// PMI block accessors (the PMI workbench slice). A SEPARATE `impl` block —
689// purely additive over the history API.
690// ============================================================================
691impl History {
692    /// The document's `pmi` block (`{views, idCounter}`), if any.
693    pub fn pmi_block(&self) -> Option<&Value> {
694        self.request.get("pmi")
695    }
696
697    /// Replace (or, with `None`, remove) the `pmi` block as a USER edit:
698    /// snapshotted for undo. `coalesce_key` groups a run of rapid same-target
699    /// edits (a label drag: `pmi:label:{id}`) into ONE undo entry; `None`
700    /// makes the edit its own step (a view capture, an annotation add / edit
701    /// / delete).
702    pub fn set_pmi_block(&mut self, block: Option<Value>, coalesce_key: Option<&str>) {
703        self.checkpoint(coalesce_key);
704        self.put_pmi_block(block);
705    }
706
707    /// Replace the `pmi` block WITHOUT an undo checkpoint — for a write that
708    /// belongs to the checkpoint just taken (an import lifting a file's PMI
709    /// beside the feature it added, so one undo removes both).
710    pub fn set_pmi_block_no_undo(&mut self, block: Option<Value>) {
711        self.put_pmi_block(block);
712    }
713
714    fn put_pmi_block(&mut self, block: Option<Value>) {
715        if let Some(object) = self.request.as_object_mut() {
716            match block {
717                Some(block) => {
718                    object.insert("pmi".into(), block);
719                }
720                None => {
721                    object.remove("pmi");
722                }
723            }
724        }
725    }
726
727    /// End a coalescing run (a label drag released): the next edit with the
728    /// same key starts a fresh undo entry.
729    pub fn break_coalescing(&mut self) {
730        self.last_edit_key = None;
731    }
732}
733
734// ============================================================================
735// PART ATTRIBUTES block accessors — the BOM attributes of the document ITSELF.
736//
737// `partAttributes` is a top-level document key (see
738// `engine_state::bom`'s attributes block), so a document carries the BOM data
739// of the PART it is. On a part in an assembly's library that record is written
740// through the library entry's embedded document; on the document you have OPEN
741// it is written here, by the toolbar's Properties dialog. Same key, same
742// shape, two doors — which is what makes a part's Part Number the same value
743// whether it is read from the assembly's BOM or from the part's own tab.
744// ============================================================================
745impl History {
746    /// The document's own `partAttributes` record, if any.
747    pub fn part_attributes_block(&self) -> Option<&Value> {
748        self.request.get(crate::engine_state::PART_ATTRIBUTES)
749    }
750
751    /// Replace (or, with `None`, remove) the document's own `partAttributes`
752    /// record as a USER edit: snapshotted for undo. `coalesce_key` groups a run
753    /// of edits to ONE field (a typing run in the Properties dialog) into a
754    /// single undo entry, exactly as the PMI block's does.
755    pub fn set_part_attributes_block(&mut self, block: Option<Value>, coalesce_key: Option<&str>) {
756        self.checkpoint(coalesce_key);
757        if let Some(object) = self.request.as_object_mut() {
758            match block {
759                Some(block) => {
760                    object.insert(crate::engine_state::PART_ATTRIBUTES.into(), block);
761                }
762                None => {
763                    object.remove(crate::engine_state::PART_ATTRIBUTES);
764                }
765            }
766        }
767    }
768}
769
770// ============================================================================
771// Expressions / configurator accessors (appended — the expressions/parameters
772// panel slice). A SEPARATE `impl` block so concurrent edits to the primary block
773// don't conflict; purely additive over the existing history API.
774//
775// The history document carries an `expressions` source string — the variable
776// sheet feature params evaluate against (a numeric param may be the string
777// `"boxW"`, evaluated by the pipeline's shared expression env). The panel edits
778// this and re-runs; the `configurator` object (typed named inputs) is exposed
779// read-only for display.
780// ============================================================================
781impl History {
782    /// The history document's `expressions` source string (empty when absent or
783    /// stored as `null`). The panel's editor binds to this.
784    pub fn expressions(&self) -> String {
785        self.request
786            .get("expressions")
787            .and_then(Value::as_str)
788            .unwrap_or("")
789            .to_string()
790    }
791
792    /// Replace the `expressions` source string. Snapshotted for undo, coalescing a
793    /// run of keystroke edits into ONE undo entry (like a slider drag) via the
794    /// shared `"expressions"` coalesce key, so a distinct add/edit/roll starts a
795    /// fresh entry. A no-op re-set (same text) still records under the same key.
796    pub fn set_expressions(&mut self, expressions: &str) {
797        self.checkpoint(Some("expressions"));
798        if let Some(obj) = self.request.as_object_mut() {
799            obj.insert(
800                "expressions".into(),
801                Value::String(expressions.to_string()),
802            );
803        }
804    }
805
806    /// The `configurator` object (typed named inputs), or `{}` when absent —
807    /// read-only for the panel's display (deeper configurator editing deferred).
808    pub fn configurator(&self) -> Value {
809        self.request
810            .get("configurator")
811            .cloned()
812            .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
813    }
814}
815
816// ============================================================================
817// Feature `persistentData` accessors (appended — the engine-native sketch mode
818// slice). A SEPARATE `impl` block (like the expressions accessors above) so
819// concurrent edits don't conflict; purely additive over the primary history API.
820//
821// A feature's `persistentData` is the kernel-persisted, non-input state (a
822// SKETCH feature stores its solved `{points, geometries, constraints}` under the
823// `sketch` key and the plane `basis` there). Sketch mode reads that state on
824// enter and writes the edited doc back on commit — mirroring how the ref-select
825// slice reads/writes `inputParams` through `feature_params` / `set_feature_params`.
826// ============================================================================
827impl History {
828    /// The `persistentData` document of the feature at `index` (`None` if the
829    /// feature or the field is absent) — the read twin of [`Self::feature_params`].
830    pub fn feature_persistent_data(&self, index: usize) -> Option<Value> {
831        self.features().get(index)?.get("persistentData").cloned()
832    }
833
834    /// Set a single `key` inside the feature-at-`index`'s `persistentData` object,
835    /// creating (or replacing a non-object) `persistentData` as needed. Snapshotted
836    /// for undo (a structural edit — never coalesced), like an add/delete.
837    pub fn set_feature_persistent_field(&mut self, index: usize, key: &str, value: Value) {
838        self.set_feature_persistent_field_coalesced(index, key, value, None);
839    }
840
841    /// [`Self::set_feature_persistent_field`] with an optional COALESCE key: a
842    /// run of same-key writes (a gizmo drag moving a spline anchor, frame after
843    /// frame) records ONE undo entry, exactly as a slider drag on a param does.
844    pub fn set_feature_persistent_field_coalesced(
845        &mut self,
846        index: usize,
847        key: &str,
848        value: Value,
849        coalesce_key: Option<&str>,
850    ) {
851        if index >= self.len() {
852            return;
853        }
854        self.checkpoint(coalesce_key);
855        if let Some(feat) = self.features_mut().get_mut(index).and_then(Value::as_object_mut) {
856            let entry = feat
857                .entry("persistentData")
858                .or_insert_with(|| Value::Object(serde_json::Map::new()));
859            if !entry.is_object() {
860                *entry = Value::Object(serde_json::Map::new());
861            }
862            if let Some(obj) = entry.as_object_mut() {
863                obj.insert(key.to_string(), value);
864            }
865        }
866    }
867}
868
869// BREP private tests: 52cc072226360e97