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
664// ============================================================================
665// Expressions / configurator accessors (appended — the expressions/parameters
666// panel slice). A SEPARATE `impl` block so concurrent edits to the primary block
667// don't conflict; purely additive over the existing history API.
668//
669// The history document carries an `expressions` source string — the variable
670// sheet feature params evaluate against (a numeric param may be the string
671// `"boxW"`, evaluated by the pipeline's shared expression env). The panel edits
672// this and re-runs; the `configurator` object (typed named inputs) is exposed
673// read-only for display.
674// ============================================================================
675impl History {
676    /// The history document's `expressions` source string (empty when absent or
677    /// stored as `null`). The panel's editor binds to this.
678    pub fn expressions(&self) -> String {
679        self.request
680            .get("expressions")
681            .and_then(Value::as_str)
682            .unwrap_or("")
683            .to_string()
684    }
685
686    /// Replace the `expressions` source string. Snapshotted for undo, coalescing a
687    /// run of keystroke edits into ONE undo entry (like a slider drag) via the
688    /// shared `"expressions"` coalesce key, so a distinct add/edit/roll starts a
689    /// fresh entry. A no-op re-set (same text) still records under the same key.
690    pub fn set_expressions(&mut self, expressions: &str) {
691        self.checkpoint(Some("expressions"));
692        if let Some(obj) = self.request.as_object_mut() {
693            obj.insert(
694                "expressions".into(),
695                Value::String(expressions.to_string()),
696            );
697        }
698    }
699
700    /// The `configurator` object (typed named inputs), or `{}` when absent —
701    /// read-only for the panel's display (deeper configurator editing deferred).
702    pub fn configurator(&self) -> Value {
703        self.request
704            .get("configurator")
705            .cloned()
706            .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
707    }
708}
709
710// ============================================================================
711// Feature `persistentData` accessors (appended — the engine-native sketch mode
712// slice). A SEPARATE `impl` block (like the expressions accessors above) so
713// concurrent edits don't conflict; purely additive over the primary history API.
714//
715// A feature's `persistentData` is the kernel-persisted, non-input state (a
716// SKETCH feature stores its solved `{points, geometries, constraints}` under the
717// `sketch` key and the plane `basis` there). Sketch mode reads that state on
718// enter and writes the edited doc back on commit — mirroring how the ref-select
719// slice reads/writes `inputParams` through `feature_params` / `set_feature_params`.
720// ============================================================================
721impl History {
722    /// The `persistentData` document of the feature at `index` (`None` if the
723    /// feature or the field is absent) — the read twin of [`Self::feature_params`].
724    pub fn feature_persistent_data(&self, index: usize) -> Option<Value> {
725        self.features().get(index)?.get("persistentData").cloned()
726    }
727
728    /// Set a single `key` inside the feature-at-`index`'s `persistentData` object,
729    /// creating (or replacing a non-object) `persistentData` as needed. Snapshotted
730    /// for undo (a structural edit — never coalesced), like an add/delete.
731    pub fn set_feature_persistent_field(&mut self, index: usize, key: &str, value: Value) {
732        if index >= self.len() {
733            return;
734        }
735        self.checkpoint(None);
736        if let Some(feat) = self.features_mut().get_mut(index).and_then(Value::as_object_mut) {
737            let entry = feat
738                .entry("persistentData")
739                .or_insert_with(|| Value::Object(serde_json::Map::new()));
740            if !entry.is_object() {
741                *entry = Value::Object(serde_json::Map::new());
742            }
743            if let Some(obj) = entry.as_object_mut() {
744                obj.insert(key.to_string(), value);
745            }
746        }
747    }
748}
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753
754    fn seed() -> History {
755        History::from_request_json(
756            r#"{"features":[
757                {"type":"P.CU","inputParams":{"id":"Box","sizeX":20}},
758                {"type":"P.CY","inputParams":{"id":"Pin","radius":6}},
759                {"type":"B","inputParams":{"id":"Cut","boolean":{"operation":"SUBTRACT","targets":["Pin"]}}}
760            ]}"#,
761        )
762        .unwrap()
763    }
764
765    #[test]
766    fn loads_and_rolls_to_last() {
767        let h = seed();
768        assert_eq!(h.len(), 3);
769        assert_eq!(h.rollback(), 2);
770        assert_eq!(h.feature_type(0).as_deref(), Some("P.CU"));
771        assert_eq!(h.feature_id(2).as_deref(), Some("Cut"));
772    }
773
774    #[test]
775    fn prefix_request_stops_at_rolled_step() {
776        let mut h = seed();
777        h.set_rollback(0);
778        let req = h.prefix_request();
779        assert_eq!(req["stopAtId"].as_str(), Some("Box"));
780        // The full feature list is retained (cache retention), only stopAtId moves.
781        assert_eq!(req["features"].as_array().unwrap().len(), 3);
782    }
783
784    // --- the parts-library block lives OFF `request` ----------------------
785
786    fn library(size: f64) -> Value {
787        serde_json::json!({ "widget": { "sourceKey": "w", "document": { "size": size } } })
788    }
789
790    /// The block is held in a FIELD but the SAVED document is unchanged: it
791    /// round-trips through `request_json` → `from_request_json` byte-for-byte,
792    /// and a document that never had one still serializes without the key.
793    #[test]
794    fn parts_library_round_trips_through_the_saved_document() {
795        let mut h = seed();
796        assert!(
797            !h.request_json().contains("partsLibrary"),
798            "a document with no library must serialize exactly as before"
799        );
800        h.set_parts_library(library(4.0));
801        let saved: Value = serde_json::from_str(&h.request_json()).unwrap();
802        assert_eq!(saved["partsLibrary"], library(4.0), "SAVE carries the block");
803
804        let reloaded = History::from_request_json(&saved.to_string()).unwrap();
805        assert_eq!(reloaded.parts_library(), &library(4.0), "load lifts it back");
806        assert!(
807            !reloaded.request_json_without_parts_library().contains("partsLibrary"),
808            "the fold document omits it"
809        );
810    }
811
812    /// The point of the move: the request the KERNEL runs never carries the
813    /// block, so no per-edit pass copies the embedded part payload.
814    #[test]
815    fn prefix_request_omits_the_parts_library() {
816        let mut h = seed();
817        h.set_parts_library(library(4.0));
818        assert!(
819            h.prefix_request().get("partsLibrary").is_none(),
820            "the per-run request must not carry the library"
821        );
822        assert_eq!(h.parts_library(), &library(4.0), "the field still holds it");
823    }
824
825    /// Undo/redo still rewind the block exactly as they did when it lived
826    /// inside `request` — a redo of a component insert has to bring back the
827    /// entry its ACOMP references.
828    #[test]
829    fn undo_and_redo_rewind_the_parts_library() {
830        let mut h = seed();
831        h.set_parts_library(library(4.0));
832        h.set_feature_params(0, serde_json::json!({"id":"Box","sizeX":30}));
833        h.set_parts_library(library(9.0));
834        assert_eq!(h.parts_library(), &library(9.0));
835
836        assert!(h.undo());
837        assert_eq!(h.parts_library(), &library(4.0), "undo restores the old block");
838        assert!(h.redo());
839        assert_eq!(h.parts_library(), &library(9.0), "redo restores the new one");
840    }
841
842    /// A restored or adopted block is NOT a mirror of the kernel store (it can
843    /// predate a heal or a GC), so the engine must re-read the store.
844    #[test]
845    fn a_restored_block_is_not_marked_as_a_store_mirror() {
846        let mut h = seed();
847        h.set_parts_library(library(4.0));
848        assert!(h.parts_library_mirrors_store(), "set from the store");
849        h.set_feature_params(0, serde_json::json!({"id":"Box","sizeX":30}));
850        assert!(h.undo());
851        assert!(
852            !h.parts_library_mirrors_store(),
853            "an undone block may predate the store's heals/GC"
854        );
855    }
856
857    /// The fold lane hands the kernel a library-free document and adopts what
858    /// comes back; the library must survive that round trip.
859    #[test]
860    fn adopting_a_library_free_document_keeps_the_block() {
861        let mut h = seed();
862        h.set_parts_library(library(4.0));
863        let folded = h.request_json_without_parts_library();
864        h.adopt_document(&folded).unwrap();
865        assert_eq!(
866            h.parts_library(),
867            &library(4.0),
868            "an adopted document with no block leaves the field alone"
869        );
870    }
871
872    #[test]
873    fn edit_add_delete_reorder() {
874        let mut h = seed();
875        // edit params of "Pin"
876        let idx = h.index_of("Pin").unwrap();
877        h.set_feature_params(idx, serde_json::json!({"id":"Pin","radius":9}));
878        assert_eq!(h.feature_params(idx).unwrap()["radius"], 9);
879        // add
880        h.push_feature(serde_json::json!({"type":"P.CU","inputParams":{"id":"Box"}}));
881        // the persistent counter mints the next id (seed ids carry no numeric
882        // suffix → the counter safe-inits to 0, so the first mint is 1).
883        assert_eq!(h.next_feature_id("Box"), "Box1");
884        // reorder swap 0<->1
885        h.swap(0, 1);
886        assert_eq!(h.feature_id(0).as_deref(), Some("Pin"));
887        // delete "Cut"
888        let cut = h.index_of("Cut").unwrap();
889        h.remove_feature(cut);
890        assert!(h.index_of("Cut").is_none());
891    }
892
893    #[test]
894    fn undo_redo_over_edit_and_add() {
895        let mut h = seed();
896        // A freshly loaded history has nothing to undo/redo.
897        assert!(!h.can_undo());
898        assert!(!h.can_redo());
899        // A roll is view state: it records NO undo entry.
900        h.set_rollback(0);
901        assert!(!h.can_undo());
902
903        // Edit a param → undo reverts it, redo re-applies it.
904        let box_idx = h.index_of("Box").unwrap();
905        assert_eq!(h.feature_params(box_idx).unwrap()["sizeX"], 20);
906        h.set_feature_params(box_idx, serde_json::json!({ "id": "Box", "sizeX": 50 }));
907        assert_eq!(h.feature_params(box_idx).unwrap()["sizeX"], 50);
908        assert!(h.can_undo());
909        assert!(h.undo());
910        assert_eq!(h.feature_params(box_idx).unwrap()["sizeX"], 20);
911        assert!(h.can_redo());
912        assert!(h.redo());
913        assert_eq!(h.feature_params(box_idx).unwrap()["sizeX"], 50);
914
915        // Add a feature → undo removes it, redo brings it back.
916        let n = h.len();
917        h.push_feature(serde_json::json!({ "type": "P.CU", "inputParams": { "id": "Extra" } }));
918        assert_eq!(h.len(), n + 1);
919        assert!(h.undo());
920        assert_eq!(h.len(), n);
921        assert!(h.index_of("Extra").is_none());
922        assert!(h.redo());
923        assert_eq!(h.len(), n + 1);
924        assert!(h.index_of("Extra").is_some());
925
926        // A NEW mutation forks the timeline (clears redo).
927        h.undo();
928        assert!(h.can_redo());
929        h.push_feature(serde_json::json!({ "type": "P.CU", "inputParams": { "id": "Fork" } }));
930        assert!(!h.can_redo());
931    }
932
933    #[test]
934    fn drag_edits_coalesce_into_one_undo() {
935        let mut h = seed();
936        h.set_rollback(0);
937        let box_idx = h.index_of("Box").unwrap();
938        // Simulate a slider DRAG: many consecutive edits of the same feature.
939        for v in [21, 22, 23, 24, 25] {
940            h.set_feature_params(box_idx, serde_json::json!({ "id": "Box", "sizeX": v }));
941        }
942        assert_eq!(h.feature_params(box_idx).unwrap()["sizeX"], 25);
943        // ONE undo reverts the WHOLE drag to the pre-drag value — and that is the
944        // only undo entry the drag produced.
945        assert!(h.undo());
946        assert_eq!(h.feature_params(box_idx).unwrap()["sizeX"], 20);
947        assert!(!h.can_undo());
948
949        // A roll between two edits of the same feature breaks coalescing, so the
950        // two edits become two distinct undo entries.
951        h.set_feature_params(box_idx, serde_json::json!({ "id": "Box", "sizeX": 30 }));
952        h.set_rollback(0);
953        h.set_feature_params(box_idx, serde_json::json!({ "id": "Box", "sizeX": 40 }));
954        assert!(h.undo());
955        assert_eq!(h.feature_params(box_idx).unwrap()["sizeX"], 30);
956        assert!(h.undo());
957        assert_eq!(h.feature_params(box_idx).unwrap()["sizeX"], 20);
958    }
959
960    #[test]
961    fn expressions_get_set_and_undo_coalesces() {
962        let mut h = seed();
963        assert_eq!(h.expressions(), ""); // seed has no expressions field
964        h.set_expressions("boxW = 30;");
965        assert_eq!(h.expressions(), "boxW = 30;");
966        // Consecutive expression edits coalesce into a single undo entry.
967        h.set_expressions("boxW = 40;");
968        h.set_expressions("boxW = 50;");
969        assert_eq!(h.expressions(), "boxW = 50;");
970        assert!(h.undo());
971        // ONE undo reverts the whole coalesced run to the pre-edit (empty) value.
972        assert_eq!(h.expressions(), "");
973        // The configurator accessor returns an object (empty here).
974        assert!(h.configurator().is_object());
975    }
976
977    #[test]
978    fn next_feature_id_is_shortname_plus_global_counter() {
979        let mut h = History::default();
980        // A NEW feature's id is `{shortName}{N}` with N the global counter, bumped
981        // once per mint — the exact ids for a couple of types (incl. dotted ones).
982        assert_eq!(h.next_feature_id("S"), "S1");
983        assert_eq!(h.next_feature_id("P.CU"), "P.CU2");
984        assert_eq!(h.next_feature_id("E"), "E3");
985    }
986
987    #[test]
988    fn counter_is_global_across_types_and_never_reused_on_delete() {
989        let mut h = History::default();
990        // Sketch, then cube, then extrude → S1, P.CU2, E3 (ONE global counter,
991        // not per-type).
992        let s = h.next_feature_id("S");
993        h.push_feature(serde_json::json!({ "type": "S", "inputParams": { "id": s } }));
994        let cu = h.next_feature_id("P.CU");
995        h.push_feature(serde_json::json!({ "type": "P.CU", "inputParams": { "id": cu } }));
996        let e = h.next_feature_id("E");
997        h.push_feature(serde_json::json!({ "type": "E", "inputParams": { "id": e } }));
998        assert_eq!(h.feature_id(0).as_deref(), Some("S1"));
999        assert_eq!(h.feature_id(1).as_deref(), Some("P.CU2"));
1000        assert_eq!(h.feature_id(2).as_deref(), Some("E3"));
1001
1002        // Delete the FIRST feature (S1): its number 1 must NOT be reused — the next
1003        // mint is strictly greater than every number handed out so far.
1004        let idx = h.index_of("S1").unwrap();
1005        h.remove_feature(idx);
1006        assert_eq!(h.next_feature_id("P.CY"), "P.CY4");
1007    }
1008
1009    #[test]
1010    fn counter_persists_across_serialize_reload() {
1011        let mut h = History::default();
1012        let first = h.next_feature_id("S"); // S1
1013        h.push_feature(serde_json::json!({ "type": "S", "inputParams": { "id": first } }));
1014
1015        // Serialize → reload through the REAL persistence path (`request_json` →
1016        // `from_request_json`), not a hand-built document.
1017        let json = h.request_json();
1018        assert!(json.contains("\"featureCounter\":1"), "counter is folded in: {json}");
1019        let mut reloaded = History::from_request_json(&json).unwrap();
1020
1021        // The counter CONTINUES from where it left off — no restart, and the new id
1022        // cannot collide with the persisted S1.
1023        assert_eq!(reloaded.next_feature_id("P.CU"), "P.CU2");
1024    }
1025
1026    #[test]
1027    fn unmutated_document_omits_the_counter_key() {
1028        // The seed's ids carry no numeric suffix → counter safe-inits to 0 → the
1029        // document persists byte-for-byte with NO `featureCounter` key.
1030        let h = seed();
1031        assert!(!h.request_json().contains("featureCounter"));
1032    }
1033
1034    #[test]
1035    fn undo_does_not_rewind_the_counter() {
1036        // Snapshots capture only `request` + `rollback`; the counter lives OFF the
1037        // request, so undoing an add (a delete) must NOT free the number — otherwise
1038        // a redo/new-add could reuse it.
1039        let mut h = History::default();
1040        let a = h.next_feature_id("P.CU"); // P.CU1
1041        h.push_feature(serde_json::json!({ "type": "P.CU", "inputParams": { "id": a } }));
1042        assert!(h.undo());
1043        assert!(h.index_of("P.CU1").is_none());
1044        assert_eq!(h.next_feature_id("P.CU"), "P.CU2");
1045    }
1046
1047    #[test]
1048    fn counter_safe_inits_above_existing_id_suffixes() {
1049        // A legacy document with NO stored counter: safe-init lifts the counter above
1050        // the largest existing numeric suffix so a new id can't collide.
1051        let mut h = History::from_request_json(
1052            r#"{"features":[
1053                {"type":"P.CU","inputParams":{"id":"P.CU5"}},
1054                {"type":"S","inputParams":{"id":"S2"}}
1055            ]}"#,
1056        )
1057        .unwrap();
1058        // Max suffix is 5 → the next mint is 6.
1059        assert_eq!(h.next_feature_id("E"), "E6");
1060    }
1061
1062    #[test]
1063    fn trailing_number_reads_the_suffix() {
1064        assert_eq!(trailing_number("P.CU12"), 12);
1065        assert_eq!(trailing_number("S1"), 1);
1066        assert_eq!(trailing_number("Box"), 0);
1067        assert_eq!(trailing_number("IMPORT3D"), 0); // ends in a letter
1068    }
1069}