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    /// Names this undo step, so a caller can later ask whether the step it
34    /// took is still the newest one ([`History::undo_top`]). Drawn from one
35    /// process-wide counter; a redo entry keeps no serial of its own.
36    serial: u64,
37}
38
39/// Hands out every [`Snapshot::serial`] in this process.
40static NEXT_STEP: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
41
42fn next_step() -> u64 {
43    NEXT_STEP.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
44}
45
46/// The engine-owned mutable history.
47#[derive(Debug, Clone)]
48pub struct History {
49    /// The whole `HistoryRequest` document
50    /// (`{expressions, configurator, features: [...]}`).
51    ///
52    /// PRIVATE and reached only through [`Self::document`] /
53    /// [`Self::document_mut`] — the `_mut` door bumps [`Self::revision`], which
54    /// is what lets a per-frame cache key off the document without having to
55    /// enumerate the mutation sites. Naming the field `document` (not
56    /// `request`) is what makes that total: the compiler, not a reviewer, finds
57    /// a site that bypasses the doors.
58    document: Value,
59    /// A ticket drawn from one process-wide counter on construction and again on
60    /// every `&mut` reach into [`Self::document`]. Deliberately coarse — it
61    /// counts mutation OPPORTUNITIES, so it moves when nothing actually changed
62    /// (a cache then rebuilds once, harmlessly) but it cannot fail to move when
63    /// something did.
64    ///
65    /// Drawn from a shared counter rather than incremented per instance, because
66    /// a `History` is not only mutated, it is REPLACED: `set_history_json`
67    /// installs a fresh one on the engine a document ALREADY owns — the
68    /// `?loadModel=` boot drain, a recovery restore, an STL import accept, New.
69    /// A per-instance counter restarts there, so a replacement would hand a
70    /// cache a revision it had already seen for the same document and the cache
71    /// would serve the PREVIOUS model's values. One counter makes the guarantee
72    /// the same for both: no two states of any `History` in this process ever
73    /// share a revision.
74    revision: u64,
75    /// Index into `features` the model is rolled to (clamped to the last).
76    rollback: usize,
77    /// Undo/redo over the MODEL document. A snapshot is pushed BEFORE each model
78    /// mutation (edit / add / delete / reorder); roll-to-step is view state and
79    /// is NOT snapshotted. Rapid same-target edits (a slider drag) coalesce into a
80    /// single undo entry via `last_edit_key`. The stacks live here in the engine
81    /// core — the model is engine-owned, so its undo history is too; the UI only
82    /// triggers `undo()` / `redo()`.
83    undo_stack: Vec<Snapshot>,
84    redo_stack: Vec<Snapshot>,
85    /// The coalescing token of the most recently recorded edit (see `checkpoint`).
86    last_edit_key: Option<String>,
87    /// The persistent GLOBAL feature counter: bumped by one on every new-feature
88    /// mint ([`Self::next_feature_id`]), so a new id is `{shortName}{counter}`.
89    /// MONOTONIC and NEVER reused — deleting a feature does not free its number,
90    /// and (deliberately) undo does NOT rewind it, so re-doing an add can't collide
91    /// with a number already handed out. It is kept OFF `self.document` in memory
92    /// (so the undo snapshots that clone `request` never rewind it) and folded into
93    /// the serialized document under `"featureCounter"` so it round-trips save/load
94    /// (see [`Self::request_json`] / [`Self::from_request_json`]).
95    feature_counter: u64,
96    /// The assemblies PARTS LIBRARY block (`partsLibrary`), kept OFF
97    /// `self.document` for the same reason as `feature_counter`: every per-edit
98    /// pass over the document — the undo checkpoint clone, the kernel request
99    /// built by [`Self::prefix_request`], the assembly pose fold's serialize →
100    /// parse round trip — would otherwise copy the whole library, which for an
101    /// imported STEP assembly is megabytes of embedded part payload PER EDIT
102    /// and froze the browser UI. Folded back in by [`Self::request_json`] so
103    /// the SAVED document is byte-identical to before, and lifted back out by
104    /// [`Self::from_request_json`] / the adopt doors.
105    ///
106    /// `Rc` because undo/redo MUST rewind it (a redo of a component insert has
107    /// to restore the entry its ACOMP references) while a checkpoint must stay
108    /// a pointer copy — the whole point of moving it off `request`.
109    parts_library: std::rc::Rc<Value>,
110    /// Why the latest edit of a part's pins or ports was not followed to the
111    /// other side, naming the label that blocks it (see the pins-and-ports
112    /// block below). `None` once an edit binds, and after undo, redo or a
113    /// replaced document, which restore both sides together.
114    pin_port_hold: Option<String>,
115    /// A symbol edit changed the part's declared connection points, which only
116    /// a history run publishes. The engine's `pump` re-runs and clears it, so
117    /// no host has to remember to.
118    ports_followed: bool,
119    /// A ticket (from the same counter as [`Self::revision`]) redrawn on every
120    /// USER edit: every undo checkpoint, coalesced or not, and every undo and
121    /// redo. Machine writes (a solver fold, a parts-library heal, a
122    /// `_no_undo` block) and rolling do not move it. See [`Self::edit_serial`].
123    edit_serial: u64,
124    /// Why this document may not change — a PLM revision that is released, or
125    /// that this user has not checked out (`BREP_app`'s `Document::access`).
126    /// `None`: editable, as every file-based document always is. See
127    /// [`Self::lock`].
128    lock: Option<String>,
129    /// Where a mutation lands while [`Self::lock`] holds: a copy of the
130    /// document taken by [`Self::document_mut`] and thrown away by the next.
131    scratch: Value,
132    /// How many mutations were refused since construction — the count a host
133    /// watches to tell the user "check out to edit" ([`Self::refused_edits`]).
134    refused: u64,
135    /// Of those, the ones that were a USER's edit ([`Self::refused_user_edits`]).
136    refused_user: u64,
137    /// Set while an agent's staged feature evaluates
138    /// (`EngineState::stage_feature_begin`): USER edits are refused — at
139    /// their checkpoint or [`Self::refuse_user`] — while the run's own
140    /// write-back still lands. Unlike [`Self::lock`] it never touches a
141    /// write-back. See [`Self::set_busy`].
142    busy: Option<String>,
143    /// A user edit was refused for `busy` this frame: its `document_mut`
144    /// reach lands on the scratch copy. Cleared at the top of every pump
145    /// ([`Self::begin_frame`]), before any run lands.
146    refuse_next: bool,
147    /// User edits refused for `busy`, counted since construction.
148    busy_refused: u64,
149    /// The open undo group ([`Self::group_begin`]): `(token, step)`, where
150    /// `step` is the undo step the group's first edit took. Every later edit
151    /// while it is still the newest step folds into it, so a script's run is
152    /// one undo step however many edits it made.
153    group: Option<(u64, Option<u64>)>,
154}
155
156/// Hands out every revision ticket in this process — see [`History::revision`].
157/// Relaxed is enough: the value is only ever compared for equality against one
158/// a single thread read earlier, never used to order anything.
159static NEXT_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
160
161fn next_revision() -> u64 {
162    NEXT_REVISION.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
163}
164
165impl Default for History {
166    fn default() -> Self {
167        Self {
168            document: empty_request(),
169            revision: next_revision(),
170            edit_serial: next_revision(),
171            rollback: 0,
172            undo_stack: Vec::new(),
173            redo_stack: Vec::new(),
174            last_edit_key: None,
175            group: None,
176            feature_counter: 0,
177            parts_library: std::rc::Rc::new(Value::Null),
178            pin_port_hold: None,
179            ports_followed: false,
180            lock: None,
181            scratch: Value::Null,
182            refused: 0,
183            refused_user: 0,
184            busy: None,
185            refuse_next: false,
186            busy_refused: 0,
187        }
188    }
189}
190
191fn empty_request() -> Value {
192    serde_json::json!({ "expressions": "", "configurator": {}, "features": [] })
193}
194
195/// The trailing run of ASCII digits of `id` read as a number (`"P.CU12"` → 12,
196/// `"Box"` → 0). Trailing digits are ASCII (one byte each), so the slice boundary
197/// is always a valid char boundary; a missing/overflowing run yields 0.
198fn trailing_number(id: &str) -> u64 {
199    let digit_bytes = id
200        .bytes()
201        .rev()
202        .take_while(u8::is_ascii_digit)
203        .count();
204    id[id.len() - digit_bytes..].parse().unwrap_or(0)
205}
206
207impl History {
208    pub(crate) fn fold_plugin_persistent_data(&mut self, id: &str, data: Value) {
209        let Some(index) = self.index_of(id) else { return; };
210        if self.features()[index].get("persistentData") == Some(&data) { return; }
211        if let Some(features) = self.document_mut().get_mut("features").and_then(Value::as_array_mut) {
212            features[index]["persistentData"] = data;
213        }
214    }
215
216    /// Publish a successfully recomputed staged transaction as exactly one undo step.
217    /// The caller must check its captured revision before reaching this door.
218    pub(crate) fn commit_staged(&mut self, staged: &Self) -> Result<(), String> {
219        if self.refuse_user() {
220            return Err(format!("the document is read-only: {}", self.locked().unwrap_or("locked")));
221        }
222        self.checkpoint(None);
223        *self.document_mut() = staged.document.clone();
224        self.parts_library = staged.parts_library.clone();
225        self.feature_counter = staged.feature_counter;
226        self.rollback = staged.rollback;
227        self.pin_port_hold = None;
228        Ok(())
229    }
230
231    /// Load a whole history document (a saved part file parses as one). Rolls to
232    /// the last feature. Ensures a `features` array exists.
233    pub fn from_request_json(json: &str) -> Result<Self, String> {
234        let mut request: Value =
235            serde_json::from_str(json).map_err(|e| format!("history parse: {e}"))?;
236        if !request.get("features").map(Value::is_array).unwrap_or(false) {
237            if let Some(obj) = request.as_object_mut() {
238                obj.insert("features".into(), Value::Array(Vec::new()));
239            } else {
240                request = empty_request();
241            }
242        }
243        // Lift the persistent feature counter OUT of the document so it lives ONLY
244        // in the struct field: kept off `self.document`, the undo snapshots (which
245        // clone `request`) can never rewind it, and it can't be double-folded on
246        // re-serialize. A document with no stored counter (fresh or saved before
247        // this field existed) safe-inits below.
248        let stored = request
249            .as_object_mut()
250            .and_then(|obj| obj.remove("featureCounter"))
251            .and_then(|value| value.as_u64());
252        // Lift the parts-library block out of the document for the same reason
253        // (see the `parts_library` field): every per-edit copy of `request`
254        // would otherwise carry megabytes of embedded part payload.
255        let parts_library = request
256            .as_object_mut()
257            .and_then(|obj| obj.remove("partsLibrary"))
258            .unwrap_or(Value::Null);
259        let mut history = Self {
260            document: request,
261            rollback: 0,
262            parts_library: std::rc::Rc::new(parts_library),
263            ..Self::default()
264        };
265        history.rollback = history.len().saturating_sub(1);
266        // Safe init when unstored: start ABOVE the largest numeric suffix already
267        // present among feature ids so the next mint (`{shortName}{counter+1}`)
268        // cannot collide with an existing id. This holds because no shortName ends
269        // in a digit (verified — even `IMPORT3D` ends in `D`), so an id's trailing
270        // digits ARE its numeric suffix and `counter+1` strictly exceeds them all.
271        // A STORED counter is floored the same way: a document whose counter is
272        // behind its own ids (hand-written, or saved by an older build) would
273        // otherwise mint an id it already has, which the append now refuses.
274        history.feature_counter = stored.unwrap_or(0).max(history.max_id_suffix());
275        Ok(history)
276    }
277
278    /// The largest trailing-integer suffix among all existing feature ids (0 when
279    /// none carry one) — the floor for a safe counter init on a document with no
280    /// stored `featureCounter` (see [`Self::from_request_json`]).
281    fn max_id_suffix(&self) -> u64 {
282        self.features()
283            .iter()
284            .filter_map(|f| {
285                f.get("inputParams")
286                    .and_then(|p| p.get("id"))
287                    .and_then(Value::as_str)
288            })
289            .map(trailing_number)
290            .max()
291            .unwrap_or(0)
292    }
293
294    /// The whole document, read-only.
295    fn document(&self) -> &Value {
296        &self.document
297    }
298
299    /// The whole document for mutation. Bumps [`Self::revision`] — this is the
300    /// only way to reach `self.document` mutably.
301    ///
302    /// It is NOT the only door a cache keyed on the revision has to cover. The
303    /// `partsLibrary` block is held in [`Self::parts_library`], beside the
304    /// document rather than inside it, and [`Self::request_json`] folds it back
305    /// in — so it is part of the saved document and has its own two setters,
306    /// which bump the revision themselves. Anything else lifted out of
307    /// `self.document` into a field of its own owes the same.
308    ///
309    /// While the history is [locked](Self::lock), the reach lands on a SCRATCH
310    /// copy that the next reach replaces, and the refusal is counted: every
311    /// mutation that goes through this door — a user edit, and equally a
312    /// solver fold or a pin settle — leaves [`Self::request_json`] exactly as
313    /// it was. The revision does not move either, since nothing changed.
314    fn document_mut(&mut self) -> &mut Value {
315        if self.lock.is_some() || self.refuse_next {
316            self.refused += 1;
317            self.scratch = self.document.clone();
318            return &mut self.scratch;
319        }
320        self.revision = next_revision();
321        &mut self.document
322    }
323
324    /// Refuse every change to the saved document until [`Self::unlock`]:
325    /// [`Self::request_json`] stays byte for byte what it is now, whatever is
326    /// called. `reason` is the sentence a host shows ("released", "checked out
327    /// by grace", "not checked out"). Rolling ([`Self::set_rollback`]) is view
328    /// state and stays free, and so does reading.
329    ///
330    /// The three writes that do not pass `Self::document_mut` — the parts
331    /// library, the feature counter and the undo/redo stacks — check the lock
332    /// themselves; `a_locked_history_keeps_its_saved_bytes` calls every
333    /// mutator and pins the invariant.
334    pub fn lock(&mut self, reason: impl Into<String>) {
335        self.lock = Some(reason.into());
336        // A typing run from before the lock must not coalesce with the first
337        // edit after the next checkout into one undo entry.
338        self.break_coalescing();
339    }
340
341    /// Allow changes again (a checkout took the lock).
342    pub fn unlock(&mut self) {
343        self.lock = None;
344        self.scratch = Value::Null;
345    }
346
347    /// Why the document may not change, or `None` when it may.
348    pub fn locked(&self) -> Option<&str> {
349        self.lock.as_deref()
350    }
351
352    /// Mutations refused while locked, counted since this history was built.
353    /// A host compares it frame to frame to tell the user an edit was refused.
354    pub fn refused_edits(&self) -> u64 {
355        self.refused
356    }
357
358    /// Refused mutations that were a user's edit: every one that takes an undo
359    /// checkpoint, an undo, redo or retract, a feature id minted, a parts-library
360    /// edit. A kernel write-back (a solver fold, a pin settle, a parts-library
361    /// heal) is refused just the same but not counted here: it is nobody's edit,
362    /// so a host tells the user about THIS count, not [`Self::refused_edits`].
363    pub fn refused_user_edits(&self) -> u64 {
364        self.refused_user
365    }
366
367    /// Count a refused write that does not pass [`Self::document_mut`], and
368    /// say whether to refuse it.
369    fn refuse(&mut self) -> bool {
370        if self.lock.is_some() {
371            self.refused += 1;
372            return true;
373        }
374        false
375    }
376
377    /// [`Self::refuse`] for a write only a user makes — refused, too, while
378    /// the history is [busy](Self::set_busy).
379    fn refuse_user(&mut self) -> bool {
380        let refused = self.refuse();
381        if refused {
382            self.refused_user += 1;
383            return true;
384        }
385        if self.busy.is_some() {
386            self.busy_refused += 1;
387            self.refuse_next = true;
388            return true;
389        }
390        false
391    }
392
393    /// Refuse USER edits while an agent's staged feature evaluates (`Some`),
394    /// or accept them again (`None`). A run's write-back is never refused
395    /// for this; a user's edit is, and counted ([`Self::busy_refused`]).
396    pub fn set_busy(&mut self, reason: Option<String>) {
397        self.busy = reason;
398        self.refuse_next = false;
399        if self.busy.is_some() {
400            self.break_coalescing();
401        }
402    }
403
404    /// Why user edits are being refused just now, if they are.
405    pub fn busy(&self) -> Option<&str> {
406        self.busy.as_deref()
407    }
408
409    /// User edits refused while busy, counted since construction.
410    pub fn busy_refused(&self) -> u64 {
411        self.busy_refused
412    }
413
414    /// The top of a frame's pump: a refused user edit's scratch redirect
415    /// ends here, before any run's write-back lands.
416    pub fn begin_frame(&mut self) {
417        self.refuse_next = false;
418    }
419
420    /// A monotonic counter of mutation opportunities on the document — the
421    /// cache key for anything derived from it that would otherwise be rebuilt
422    /// every frame (see the `__brepParams` blob in `BREP_app`).
423    pub fn revision(&self) -> u64 {
424        self.revision
425    }
426
427    /// Moves on every user edit of the model and on every undo and redo, and
428    /// on nothing else: not on a run's write-back, not on rolling. What a
429    /// transient view of the model (the family-row preview) keys on to know
430    /// that the user changed the model under it.
431    pub fn edit_serial(&self) -> u64 {
432        self.edit_serial
433    }
434
435    /// The features slice (empty if none).
436    pub fn features(&self) -> &[Value] {
437        self.document()
438            .get("features")
439            .and_then(Value::as_array)
440            .map(Vec::as_slice)
441            .unwrap_or(&[])
442    }
443
444    fn features_mut(&mut self) -> &mut Vec<Value> {
445        let obj = self
446            .document_mut()
447            .as_object_mut()
448            .expect("history request is a JSON object");
449        obj.entry("features")
450            .or_insert_with(|| Value::Array(Vec::new()));
451        obj.get_mut("features")
452            .and_then(Value::as_array_mut)
453            .expect("features is a JSON array")
454    }
455
456    pub fn len(&self) -> usize {
457        self.features().len()
458    }
459
460    pub fn is_empty(&self) -> bool {
461        self.len() == 0
462    }
463
464    /// The rolled-to index, clamped to a valid feature (0 when empty).
465    pub fn rollback(&self) -> usize {
466        self.rollback.min(self.len().saturating_sub(1))
467    }
468
469    pub fn set_rollback(&mut self, index: usize) {
470        self.rollback = if self.is_empty() {
471            0
472        } else {
473            index.min(self.len() - 1)
474        };
475        // Rolling to a step is a view move, not a model edit: it records NO undo
476        // snapshot, but it DOES break the edit-coalescing run so the next edit
477        // starts a fresh undo entry rather than merging with a pre-roll edit.
478        self.last_edit_key = None;
479    }
480
481    pub fn feature_type(&self, index: usize) -> Option<String> {
482        self.features()
483            .get(index)?
484            .get("type")
485            .and_then(Value::as_str)
486            .map(String::from)
487    }
488
489    pub fn feature_id(&self, index: usize) -> Option<String> {
490        self.features()
491            .get(index)?
492            .get("inputParams")
493            .and_then(|p| p.get("id"))
494            .and_then(Value::as_str)
495            .map(String::from)
496    }
497
498    pub fn index_of(&self, id: &str) -> Option<usize> {
499        self.features().iter().position(|f| {
500            f.get("inputParams")
501                .and_then(|p| p.get("id"))
502                .and_then(Value::as_str)
503                == Some(id)
504        })
505    }
506
507    /// The `inputParams` document of the feature at `index` (for the dialog).
508    pub fn feature_params(&self, index: usize) -> Option<Value> {
509        self.features().get(index)?.get("inputParams").cloned()
510    }
511
512    /// SOLVER write-back fold (the assembly pose-authority contract): set ONE
513    /// `inputParams` key of the feature whose `inputParams.id == id`, WITHOUT an
514    /// undo checkpoint and WITHOUT breaking edit coalescing — a solve write-back
515    /// is the kernel adopting its own result, not a user edit, so it must never
516    /// mint an undo entry (undoing a user action then re-running re-solves and
517    /// re-folds anyway). Returns whether a feature matched.
518    pub fn fold_param_no_undo(&mut self, id: &str, key: &str, value: Value) -> bool {
519        let Some(index) = self.index_of(id) else {
520            return false;
521        };
522        if let Some(feature) = self.features_mut().get_mut(index) {
523            if let Some(params) = feature
524                .get_mut("inputParams")
525                .and_then(Value::as_object_mut)
526            {
527                params.insert(key.to_string(), value);
528                return true;
529            }
530        }
531        false
532    }
533
534    pub fn set_feature_params(&mut self, index: usize, params: Value) {
535        if index >= self.len() {
536            return;
537        }
538        // Coalesce a slider drag (many consecutive edits of the SAME feature) into
539        // one undo entry, keyed by the feature index.
540        self.checkpoint(Some(&format!("param:{index}")));
541        if let Some(feat) = self.features_mut().get_mut(index) {
542            if let Some(obj) = feat.as_object_mut() {
543                obj.insert("inputParams".into(), params);
544            }
545        }
546    }
547
548    /// Replace the `inputParams` of MANY features as ONE mutation —
549    /// [`Self::set_feature_params`]'s batch sibling, and the reason it exists:
550    /// a packed BOM row rolls up N occurrences, so editing one cell writes N
551    /// features. A `set_feature_params` loop would mint N undo entries (each
552    /// keyed `param:{index}`, so none of them coalesce with each other), and
553    /// the user would have to press undo N times to take back ONE edit.
554    ///
555    /// Exactly ONE checkpoint covers the whole batch. The coalesce key names
556    /// the SET of indices, so a run of edits to the SAME set (typing into one
557    /// packed cell) still merges into a single entry, while switching to a
558    /// different set starts a new one — the `param:{index}` rule, lifted to a
559    /// group. Out-of-range indices are skipped; an empty batch is a no-op (no
560    /// checkpoint, so a fan-out that matched nothing leaves no empty entry).
561    pub fn set_many_feature_params(&mut self, edits: &[(usize, Value)]) {
562        let len = self.len();
563        let mut in_range: Vec<&(usize, Value)> =
564            edits.iter().filter(|(index, _)| *index < len).collect();
565        if in_range.is_empty() {
566            return;
567        }
568        // The key must not depend on the caller's ordering, or the same packed
569        // row could produce two different keys and stop coalescing.
570        in_range.sort_by_key(|(index, _)| *index);
571        let key: Vec<String> = in_range
572            .iter()
573            .map(|(index, _)| index.to_string())
574            .collect();
575        self.checkpoint(Some(&format!("params:{}", key.join(","))));
576        for (index, params) in in_range {
577            if let Some(feature) = self.features_mut().get_mut(*index) {
578                if let Some(object) = feature.as_object_mut() {
579                    object.insert("inputParams".into(), params.clone());
580                }
581            }
582        }
583    }
584
585    pub fn push_feature(&mut self, feature: Value) {
586        self.checkpoint(None);
587        self.features_mut().push(feature);
588    }
589
590    /// Append MANY features as ONE mutation — the batch an import lane needs
591    /// (`EngineState::add_features`). Distinct from a `push_feature` loop in the
592    /// two ways that matter: exactly ONE undo checkpoint (an N-instance assembly
593    /// import undoes in one step, not N), and the caller re-runs once instead of
594    /// once per feature. No-op for an empty batch (no checkpoint, so an import
595    /// that produced nothing leaves no empty undo entry).
596    pub fn push_features(&mut self, features: Vec<Value>) {
597        if features.is_empty() {
598            return;
599        }
600        self.checkpoint(None);
601        self.features_mut().extend(features);
602    }
603
604    pub fn remove_feature(&mut self, index: usize) {
605        if index >= self.len() {
606            return;
607        }
608        self.checkpoint(None);
609        self.features_mut().remove(index);
610    }
611
612    pub fn swap(&mut self, a: usize, b: usize) {
613        let len = self.len();
614        if a < len && b < len && a != b {
615            self.checkpoint(None);
616            self.features_mut().swap(a, b);
617        }
618    }
619
620    /// Mint the id for a NEW feature: `{base}{N}` where `base` is the feature's
621    /// shortName and `N` is this history's persistent GLOBAL counter, bumped by one
622    /// on every mint (`P.CU` → `P.CU7`, `S` → `S8`). GLOBAL across all feature
623    /// types, MONOTONIC, and NEVER reused — a delete does not free a number and
624    /// undo does not rewind the counter — and it persists across save/load, so two
625    /// features can never receive the same id over the document's whole lifetime.
626    pub fn next_feature_id(&mut self, base: &str) -> String {
627        // Locked, the counter is saved state and does not move; the id is
628        // still minted, for a feature whose push will be refused anyway.
629        if self.refuse_user() {
630            return format!("{base}{}", self.feature_counter + 1);
631        }
632        self.feature_counter += 1;
633        format!("{base}{}", self.feature_counter)
634    }
635
636    /// Commit IDs reserved on a transaction's cloned history. Document adoption
637    /// deliberately ignores serialized counters, so the transaction must carry
638    /// its allocation high-water mark separately. Explicit committed IDs also
639    /// set a floor; undo keeps this monotonic, while rollback restores a clone.
640    pub(crate) fn retain_feature_allocations(&mut self, staged: &Self) {
641        if self.refuse_user() {
642            return;
643        }
644        self.feature_counter = self
645            .feature_counter
646            .max(staged.feature_counter)
647            .max(self.max_id_suffix());
648    }
649
650    /// The `stopAtId`-truncated request that stops AFTER the rolled-to feature —
651    /// the roll-to-step request the pipeline runs. Empty history → empty request.
652    pub fn prefix_request(&self) -> Value {
653        let mut request = self.document().clone();
654        if let Some(id) = self.feature_id(self.rollback()) {
655            if let Some(obj) = request.as_object_mut() {
656                obj.insert("stopAtId".into(), Value::String(id));
657            }
658        }
659        request
660    }
661
662    /// The request that stops BEFORE feature `index` — the scene that feature
663    /// resolves its references against, wherever the model is rolled to. `None`
664    /// when there is no feature at `index`.
665    pub fn request_before(&self, index: usize) -> Option<Value> {
666        let id = self.feature_id(index)?;
667        let mut request = self.document().clone();
668        request.as_object_mut()?.insert("stopBeforeId".into(), Value::String(id));
669        Some(request)
670    }
671
672    /// The tree listing for the UI: `{ step, features: [{index, type, id}] }`.
673    pub fn listing_json(&self) -> String {
674        let features: Vec<Value> = self
675            .features()
676            .iter()
677            .enumerate()
678            .map(|(index, _)| {
679                serde_json::json!({
680                    "index": index,
681                    "type": self.feature_type(index).unwrap_or_else(|| "?".into()),
682                    "id": self.feature_id(index).unwrap_or_else(|| "(no id)".into()),
683                })
684            })
685            .collect();
686        serde_json::json!({ "step": self.rollback(), "features": features }).to_string()
687    }
688
689    /// The whole request document (for persistence / debugging), with the
690    /// persistent global feature counter folded back in under `"featureCounter"`
691    /// so it round-trips through save/load (the twin of [`Self::from_request_json`],
692    /// which lifts it back out). Written only when non-zero, so a document that has
693    /// never minted a feature persists byte-for-byte as before (mirrors the
694    /// metadata field's "un-annotated model persists unchanged" convention).
695    pub fn request_json(&self) -> String {
696        let has_library = self
697            .parts_library
698            .as_object()
699            .map(|map| !map.is_empty())
700            .unwrap_or(false);
701        if self.feature_counter == 0 && !has_library {
702            return self.document().to_string();
703        }
704        let mut document = self.document().clone();
705        if let Some(obj) = document.as_object_mut() {
706            if self.feature_counter != 0 {
707                obj.insert("featureCounter".into(), Value::from(self.feature_counter));
708            }
709            if has_library {
710                obj.insert("partsLibrary".into(), (*self.parts_library).clone());
711            }
712        }
713        document.to_string()
714    }
715
716    /// [`Self::request_json`] as a value, for a caller that edits it: the
717    /// same document, never printed and parsed back (an assembly's library
718    /// is megabytes).
719    pub fn request_value(&self) -> Value {
720        let mut document = self.document().clone();
721        if let Some(obj) = document.as_object_mut() {
722            if self.feature_counter != 0 {
723                obj.insert("featureCounter".into(), Value::from(self.feature_counter));
724            }
725            if self.parts_library.as_object().is_some_and(|map| !map.is_empty()) {
726                obj.insert("partsLibrary".into(), (*self.parts_library).clone());
727            }
728        }
729        document
730    }
731
732    /// The document WITHOUT the parts-library block — for the kernel round
733    /// trips that never read it. The assembly pose fold
734    /// (`assembly_apply_document_json`) only rewrites the `assembly` block and
735    /// per-feature `inputParams`, so handing it the library would serialize,
736    /// parse and re-serialize megabytes of part payload on every edit for
737    /// nothing. [`Self::request_json`] is the SAVE door and still carries it.
738    pub fn request_json_without_parts_library(&self) -> String {
739        if self.feature_counter == 0 {
740            return self.document().to_string();
741        }
742        let mut document = self.document().clone();
743        if let Some(obj) = document.as_object_mut() {
744            obj.insert("featureCounter".into(), Value::from(self.feature_counter));
745        }
746        document.to_string()
747    }
748
749    // --- Undo / redo over the model document ------------------------------
750
751    fn snapshot(&self) -> Snapshot {
752        Snapshot {
753            request: self.document().clone(),
754            rollback: self.rollback,
755            parts_library: self.parts_library.clone(),
756            serial: next_step(),
757        }
758    }
759
760    fn restore(&mut self, snap: Snapshot) {
761        self.edit_serial = next_revision();
762        *self.document_mut() = snap.request;
763        self.parts_library = snap.parts_library;
764        // A snapshot holds both sides as they stood together, so whatever the
765        // last pin or port edit held is no longer on screen.
766        self.pin_port_hold = None;
767        let last = self.len().saturating_sub(1);
768        self.rollback = snap.rollback.min(last);
769    }
770
771    /// Record a pre-mutation snapshot for undo. `coalesce_key` groups a run of
772    /// rapid same-target edits (one slider drag) into a SINGLE undo entry: while
773    /// the same non-empty key repeats, no new snapshot is pushed. A `None` key
774    /// never coalesces, so every structural add/delete/reorder is its own entry.
775    /// Any new snapshot clears the redo stack (a fresh edit forks the timeline)
776    /// and the oldest entry falls off once the stack passes [`MAX_UNDO`].
777    ///
778    /// A run has no time limit: only another key, an undo or
779    /// [`Self::break_coalescing`] ends it. The PMI label drag and the sheet
780    /// view, dimension and ordinate moves call that on release; nothing else
781    /// does, and a typing run has no release. So a param slider or
782    /// field (`param:{index}`) or an eCAD field (`component:{id}:text`) edited,
783    /// left, and edited again later with nothing in between is ONE undo step
784    /// across the pause, even across a Save. An eCAD host should call it when
785    /// focus leaves the editor's fields. Param runs are left as they are, a
786    /// decision not yet taken.
787    fn checkpoint(&mut self, coalesce_key: Option<&str>) {
788        // Locked, the edit this checkpoint precedes is refused at
789        // `document_mut`: no undo entry for an edit that never happened.
790        if self.lock.is_some() {
791            self.refused_user += 1;
792            return;
793        }
794        // Busy (an agent's staged feature is evaluating): the user's edit is
795        // refused the same way, landing on the scratch copy.
796        if self.busy.is_some() {
797            self.busy_refused += 1;
798            self.refuse_next = true;
799            return;
800        }
801        // Before the coalesce return: the second keystroke of a typing run is
802        // an edit too.
803        self.edit_serial = next_revision();
804        if coalesce_key.is_some() && coalesce_key == self.last_edit_key.as_deref() {
805            return;
806        }
807        if let Some((_, Some(step))) = self.group {
808            if self.undo_top() == Some(step) {
809                return;
810            }
811        }
812        self.undo_stack.push(self.snapshot());
813        if self.undo_stack.len() > MAX_UNDO {
814            self.undo_stack.remove(0);
815        }
816        self.redo_stack.clear();
817        self.last_edit_key = coalesce_key.map(str::to_string);
818        if let Some((_, step)) = &mut self.group {
819            *step = self.undo_stack.last().map(|snapshot| snapshot.serial);
820        }
821    }
822
823    /// Open an undo group: every edit until [`Self::group_end`] is one undo
824    /// step. A group already open is replaced. The token names this group to
825    /// its end.
826    pub fn group_begin(&mut self) -> u64 {
827        let token = next_step();
828        self.group = Some((token, None));
829        self.last_edit_key = None;
830        token
831    }
832
833    /// Close group `token`. With `rollback`, take its step back as though its
834    /// edits had never been made ([`Self::retract`]), while that step is still
835    /// the newest; answers whether it did. A token that is not the open group
836    /// closes nothing.
837    pub fn group_end(&mut self, token: u64, rollback: bool) -> Result<bool, String> {
838        let Some((open, step)) = self.group else {
839            return Err("no undo group is open".into());
840        };
841        if open != token {
842            return Err("that undo group is not the open one".into());
843        }
844        self.group = None;
845        self.last_edit_key = None;
846        Ok(rollback && step.is_some_and(|step| self.retract(step)))
847    }
848
849    /// The open undo group's token, if any.
850    pub fn group_open(&self) -> Option<u64> {
851        self.group.map(|(token, _)| token)
852    }
853
854    /// Whether an undo step is available (for enabling the toolbar button).
855    pub fn can_undo(&self) -> bool {
856        !self.undo_stack.is_empty()
857    }
858
859    /// Whether a redo step is available.
860    pub fn can_redo(&self) -> bool {
861        !self.redo_stack.is_empty()
862    }
863
864    /// Undo the last model mutation: push the current state onto the redo stack
865    /// and restore the previous document + rolled-to step. Returns whether it
866    /// changed anything (false when the undo stack is empty).
867    pub fn undo(&mut self) -> bool {
868        if self.refuse_user() {
869            return false;
870        }
871        let Some(prev) = self.undo_stack.pop() else {
872            return false;
873        };
874        self.redo_stack.push(self.snapshot());
875        self.restore(prev);
876        // A distinct undo breaks any coalescing run so the next edit is fresh.
877        self.last_edit_key = None;
878        true
879    }
880
881    /// Redo the last undone model mutation (symmetric with [`Self::undo`]).
882    pub fn redo(&mut self) -> bool {
883        if self.refuse_user() {
884            return false;
885        }
886        let Some(next) = self.redo_stack.pop() else {
887            return false;
888        };
889        self.undo_stack.push(self.snapshot());
890        self.restore(next);
891        self.last_edit_key = None;
892        true
893    }
894}
895
896// ============================================================================
897// Assembly document accessors (appended — the assemblies Wave-3 slice). A
898// SEPARATE `impl` block so concurrent edits to the primary block don't
899// conflict; purely additive over the existing history API.
900// ============================================================================
901impl History {
902    /// ADOPT a whole replacement document — the assembly FOLD's write-back lane
903    /// (`assembly_apply_document_json` returns the document with the solved
904    /// `assembly` block + poses/isFixed folded into the features; the engine
905    /// adopts it before persisting or re-running — the pose-authority contract).
906    ///
907    /// `checkpoint` chooses the undo semantics: a USER constraint mutation
908    /// records an undo snapshot (so constraint edits stay undoable like any
909    /// model edit); the silent post-run pose fold passes `false` (solver
910    /// write-back is not a user edit — undoing the user's LAST edit must not
911    /// strand an extra fold step in between).
912    ///
913    /// A stray `featureCounter` in the adopted document is stripped (the
914    /// in-memory counter stays authoritative — `request_json` re-folds it on
915    /// serialize, exactly like `from_request_json` lifts it on load). The
916    /// rollback index is preserved (the fold never changes the feature count).
917    pub fn adopt_document(&mut self, document_json: &str) -> Result<(), String> {
918        // Locked, say so: a caller that checks the result (an eCAD block sync
919        // recording what it wrote) must not believe a refused write landed.
920        if let Some(reason) = &self.lock {
921            let error = format!("the document is read-only: {reason}");
922            self.refused += 1;
923            return Err(error);
924        }
925        let document: Value = serde_json::from_str(document_json)
926            .map_err(|error| format!("adopt document parse: {error}"))?;
927        self.adopt_document_value(document)
928    }
929
930    /// [`Self::adopt_document`] for a caller that already holds the parsed
931    /// document: an assembly's parts library is megabytes, and printing it only
932    /// to parse it back was most of an Update Components commit at 500 parts.
933    pub fn adopt_document_value(&mut self, mut document: Value) -> Result<(), String> {
934        if let Some(reason) = &self.lock {
935            let error = format!("the document is read-only: {reason}");
936            self.refused += 1;
937            return Err(error);
938        }
939        if !document.is_object() {
940            return Err("adopt document: not a JSON object".into());
941        }
942        let mut adopted_library = None;
943        if let Some(obj) = document.as_object_mut() {
944            obj.remove("featureCounter");
945            // A block PRESENT in the adopted document replaces the field; one
946            // ABSENT leaves it alone (the fold lane is handed a library-free
947            // document by `request_json_without_parts_library` and must not
948            // silently drop the library on the way back).
949            adopted_library = obj.remove("partsLibrary");
950            if !obj.get("features").map(Value::is_array).unwrap_or(false) {
951                obj.insert("features".into(), Value::Array(Vec::new()));
952            }
953        }
954        *self.document_mut() = document;
955        // A replaced document is not the one a pin or port edit was held on.
956        self.pin_port_hold = None;
957        if let Some(library) = adopted_library {
958            self.set_parts_library(library);
959            }
960        Ok(())
961    }
962
963    /// Same adoption with an undo snapshot recorded FIRST (never coalesced) —
964    /// the user-mutation twin of [`Self::adopt_document`].
965    pub fn adopt_document_checkpointed(&mut self, document_json: &str) -> Result<(), String> {
966        // Validate BEFORE snapshotting so a parse failure never pushes a
967        // phantom undo entry.
968        let probe: Value = serde_json::from_str(document_json)
969            .map_err(|error| format!("adopt document parse: {error}"))?;
970        if !probe.is_object() {
971            return Err("adopt document: not a JSON object".into());
972        }
973        self.checkpoint(None);
974        self.adopt_document_value(probe)
975    }
976
977    /// [`Self::adopt_document_checkpointed`] for an already parsed document.
978    pub fn adopt_document_value_checkpointed(&mut self, document: Value) -> Result<(), String> {
979        if !document.is_object() {
980            return Err("adopt document: not a JSON object".into());
981        }
982        self.checkpoint(None);
983        self.adopt_document_value(document)
984    }
985
986    /// Replace the document's `partsLibrary` block (the assemblies parts
987    /// library) — the caller feeds `brep_kernel::parts_library_json()` here
988    /// (never an echo of a loaded block), so SAVE serializes the kernel
989    /// store with its heals and GC. An empty map clears the field, so a
990    /// non-assembly document serializes byte-identically to before.
991    ///
992    /// The block is held in the `parts_library` FIELD rather than on
993    /// `self.document`; [`Self::request_json`] folds it back into the saved
994    /// document. The on-disk shape is unchanged.
995    pub fn set_parts_library(&mut self, library: Value) {
996        if self.refuse() {
997            return;
998        }
999        let empty = library.as_object().map(|m| m.is_empty()).unwrap_or(true);
1000        self.parts_library = std::rc::Rc::new(if empty { Value::Null } else { library });
1001        // The block is part of the saved document, so this is a document
1002        // mutation and owes the revision a tick — `document_mut` never sees it.
1003        self.revision = next_revision();
1004    }
1005
1006    /// Replace the `partsLibrary` block as a USER EDIT: one undo checkpoint,
1007    /// and the mirror flag CLEARED so the next run ships the block to the
1008    /// runner instead of assuming the kernel store already agrees.
1009    ///
1010    /// [`Self::set_parts_library`] is the other door and means the opposite —
1011    /// "this block came OUT of the kernel store" — so it must not be reused
1012    /// here: a part-attribute edit is authored on this side and the runner has
1013    /// never seen it. `coalesce_key` follows the `param:{index}` rule so a run
1014    /// of keystrokes into one attribute is one undo entry.
1015    pub fn set_parts_library_edited(&mut self, library: Value, coalesce_key: Option<&str>) {
1016        if self.refuse_user() {
1017            return;
1018        }
1019        self.checkpoint(coalesce_key);
1020        let empty = library.as_object().map(|m| m.is_empty()).unwrap_or(true);
1021        self.parts_library = std::rc::Rc::new(if empty { Value::Null } else { library });
1022        // As [`Self::set_parts_library`]: `checkpoint` records an undo entry
1023        // and moves nothing a cache can see.
1024        self.revision = next_revision();
1025    }
1026
1027    /// The `partsLibrary` block (`Value::Null` when the document has none).
1028    pub fn parts_library(&self) -> &Value {
1029        &self.parts_library
1030    }
1031
1032    /// Top-level block `key` of the document as it stood before the NEWEST
1033    /// undoable edit (`None` with nothing to undo, or when that document had
1034    /// no such block): what a caller reads to tell whether the newest step is
1035    /// the one it means to take back.
1036    pub fn undo_top_block(&self, key: &str) -> Option<&Value> {
1037        self.undo_stack.last().and_then(|snapshot| snapshot.request.get(key))
1038    }
1039
1040    /// The `partsLibrary` block as it stood before each undoable edit, newest
1041    /// first: where a caller finds a library entry as it was when it appeared.
1042    pub fn undo_parts_libraries(&self) -> impl Iterator<Item = &Value> + '_ {
1043        self.undo_stack.iter().rev().map(|snapshot| snapshot.parts_library.as_ref())
1044    }
1045
1046    /// The document's `assembly` block (`{constraints, idCounter}`), if any.
1047    pub fn assembly_block(&self) -> Option<&Value> {
1048        self.document().get("assembly")
1049    }
1050
1051    /// Replace the `assembly` block (`{constraints, idCounter}`) — the STATE
1052    /// half of the assembly write-back, which the engine folds from the run's
1053    /// reply after every run and from the live session after a constraint
1054    /// mutation. `checkpoint` = true makes it an undoable USER edit (a
1055    /// mutation); false writes it silently, because a solver adopting its own
1056    /// result is not a user edit and must never mint an undo entry — undoing a
1057    /// user action then re-running re-solves and re-folds anyway.
1058    pub fn set_assembly_block(&mut self, block: Value, checkpoint: bool) {
1059        if checkpoint {
1060            self.checkpoint(None);
1061        }
1062        if let Some(object) = self.document_mut().as_object_mut() {
1063            object.insert("assembly".into(), block);
1064        }
1065    }
1066
1067    /// The document's `wireHarness` block (`{connections, idCounter,
1068    /// buildBundles}`), if any.
1069    pub fn wire_harness_block(&self) -> Option<&Value> {
1070        self.document().get("wireHarness")
1071    }
1072
1073    /// Replace (or, with `None`, remove) the `wireHarness` block. A USER edit:
1074    /// snapshotted for undo, never coalesced — each add / edit / remove of a
1075    /// connection is its own undo step, like a feature add or delete.
1076    pub fn set_wire_harness_block(&mut self, block: Option<Value>) {
1077        self.checkpoint(None);
1078        if let Some(object) = self.document_mut().as_object_mut() {
1079            match block {
1080                Some(block) => {
1081                    object.insert("wireHarness".into(), block);
1082                }
1083                None => {
1084                    object.shift_remove("wireHarness");
1085                }
1086            }
1087        }
1088    }
1089}
1090
1091// ============================================================================
1092// PMI block accessors (the PMI workbench slice). A SEPARATE `impl` block —
1093// purely additive over the history API.
1094// ============================================================================
1095impl History {
1096    /// The document's `pmi` block (`{views, idCounter}`), if any.
1097    pub fn pmi_block(&self) -> Option<&Value> {
1098        self.document().get("pmi")
1099    }
1100
1101    /// Replace (or, with `None`, remove) the `pmi` block as a USER edit:
1102    /// snapshotted for undo. `coalesce_key` groups a run of rapid same-target
1103    /// edits (a label drag: `pmi:label:{id}`) into ONE undo entry; `None`
1104    /// makes the edit its own step (a view capture, an annotation add / edit
1105    /// / delete).
1106    pub fn set_pmi_block(&mut self, block: Option<Value>, coalesce_key: Option<&str>) {
1107        self.checkpoint(coalesce_key);
1108        self.put_pmi_block(block);
1109    }
1110
1111    /// Replace the `pmi` block WITHOUT an undo checkpoint — for a write that
1112    /// belongs to the checkpoint just taken (an import lifting a file's PMI
1113    /// beside the feature it added, so one undo removes both).
1114    pub fn set_pmi_block_no_undo(&mut self, block: Option<Value>) {
1115        self.put_pmi_block(block);
1116    }
1117
1118    fn put_pmi_block(&mut self, block: Option<Value>) {
1119        if let Some(object) = self.document_mut().as_object_mut() {
1120            match block {
1121                Some(block) => {
1122                    object.insert("pmi".into(), block);
1123                }
1124                None => {
1125                    object.shift_remove("pmi");
1126                }
1127            }
1128        }
1129    }
1130
1131    /// End a coalescing run (a label drag released): the next edit with the
1132    /// same key starts a fresh undo entry.
1133    pub fn break_coalescing(&mut self) {
1134        self.last_edit_key = None;
1135    }
1136}
1137
1138// ============================================================================
1139// CAM block accessors (the CAM workbench slice). A SEPARATE `impl` block,
1140// purely additive over the history API, on the PMI block's pattern.
1141//
1142// `cam` is a top-level document key like `pmi` and `sheets`: the user's
1143// setups, tools, operations and post settings. The kernel never reads it and
1144// `HistoryRequest` has no field for it — the document's unknown-key
1145// pass-through ([`Self::request_json`] serializes the raw document) is what
1146// round-trips it through save, load and undo. Derived data (toolpaths, mesh
1147// snapshots, stock states) is never written here: it is regenerated.
1148// ============================================================================
1149impl History {
1150    /// The document's `cam` block (`{setups, tools, operations, ncPrograms,
1151    /// idCounter}`), if any.
1152    pub fn cam_block(&self) -> Option<&Value> {
1153        self.document().get("cam")
1154    }
1155
1156    /// Replace (or, with `None`, remove) the `cam` block as a USER edit:
1157    /// snapshotted for undo. `coalesce_key` groups a run of rapid same-target
1158    /// edits (typing in one field of one operation: `cam:op:{id}:{field}`)
1159    /// into ONE undo entry; `None` makes the edit its own step (a setup, tool
1160    /// or operation add / delete, a pick committed).
1161    pub fn set_cam_block(&mut self, block: Option<Value>, coalesce_key: Option<&str>) {
1162        let coalesce_key = coalesce_key.map(|change| format!("cam:{change}"));
1163        self.checkpoint(coalesce_key.as_deref());
1164        self.put_cam_block(block);
1165    }
1166
1167    /// Replace the `cam` block WITHOUT an undo checkpoint — for a write that
1168    /// belongs to the checkpoint just taken (an import lifting a file's CAM
1169    /// data beside the feature it added, so one undo removes both).
1170    pub fn set_cam_block_no_undo(&mut self, block: Option<Value>) {
1171        self.put_cam_block(block);
1172    }
1173
1174    fn put_cam_block(&mut self, block: Option<Value>) {
1175        if let Some(object) = self.document_mut().as_object_mut() {
1176            match block {
1177                Some(block) => {
1178                    object.insert("cam".into(), block);
1179                }
1180                None => {
1181                    object.shift_remove("cam");
1182                }
1183            }
1184        }
1185    }
1186}
1187
1188// ============================================================================
1189// SHEETS block accessors (the drawing-sheet slice).
1190//
1191// `sheets` is a top-level document key like `pmi` and `partAttributes`: a
1192// sheet places saved PMI views and adds no geometry, so the kernel never needs
1193// it and `HistoryRequest` has no field for it — the document's unknown-key
1194// pass-through ([`Self::request_json`] serializes the raw document) is what
1195// round-trips it through save, load and undo. Absent on every document without
1196// a sheet, so those files re-serialize byte-identically.
1197// ============================================================================
1198impl History {
1199    /// The document's `sheets` block (`{sheets, idCounter}`), if any.
1200    pub fn sheets_block(&self) -> Option<&Value> {
1201        self.document().get(crate::sheets::SHEETS)
1202    }
1203
1204    /// Replace (or, with `None`, remove) the `sheets` block as a USER edit:
1205    /// snapshotted for undo. `coalesce_key` groups a run of rapid same-target
1206    /// edits (dragging a placed view: `sheet:view:{id}`) into ONE undo entry,
1207    /// exactly as the PMI label drag's does.
1208    pub fn set_sheets_block(&mut self, block: Option<Value>, coalesce_key: Option<&str>) {
1209        self.checkpoint(coalesce_key);
1210        if let Some(object) = self.document_mut().as_object_mut() {
1211            match block {
1212                Some(block) => {
1213                    object.insert(crate::sheets::SHEETS.into(), block);
1214                }
1215                None => {
1216                    object.shift_remove(crate::sheets::SHEETS);
1217                }
1218            }
1219        }
1220    }
1221}
1222
1223// ============================================================================
1224// PART ATTRIBUTES block accessors — the BOM attributes of the document ITSELF.
1225//
1226// `partAttributes` is a top-level document key (see
1227// `engine_state::bom`'s attributes block), so a document carries the BOM data
1228// of the PART it is. On a part in an assembly's library that record is written
1229// through the library entry's embedded document; on the document you have OPEN
1230// it is written here, by the toolbar's Properties dialog. Same key, same
1231// shape, two doors — which is what makes a part's Part Number the same value
1232// whether it is read from the assembly's BOM or from the part's own tab.
1233// ============================================================================
1234impl History {
1235    /// The document's own `partAttributes` record, if any.
1236    pub fn part_attributes_block(&self) -> Option<&Value> {
1237        self.document().get(crate::engine_state::PART_ATTRIBUTES)
1238    }
1239
1240    /// Replace (or, with `None`, remove) the document's own `partAttributes`
1241    /// record as a USER edit: snapshotted for undo. `coalesce_key` groups a run
1242    /// of edits to ONE field (a typing run in the Properties dialog) into a
1243    /// single undo entry, exactly as the PMI block's does.
1244    pub fn set_part_attributes_block(&mut self, block: Option<Value>, coalesce_key: Option<&str>) {
1245        self.checkpoint(coalesce_key);
1246        if let Some(object) = self.document_mut().as_object_mut() {
1247            match block {
1248                Some(block) => {
1249                    object.insert(crate::engine_state::PART_ATTRIBUTES.into(), block);
1250                }
1251                None => {
1252                    object.shift_remove(crate::engine_state::PART_ATTRIBUTES);
1253                }
1254            }
1255        }
1256    }
1257}
1258
1259// ============================================================================
1260// Expressions / configurator accessors (appended — the expressions/parameters
1261// panel slice). A SEPARATE `impl` block so concurrent edits to the primary block
1262// don't conflict; purely additive over the existing history API.
1263//
1264// The history document carries an `expressions` source string — the variable
1265// sheet feature params evaluate against (a numeric param may be the string
1266// `"boxW"`, evaluated by the pipeline's shared expression env). The panel edits
1267// this and re-runs; the `configurator` object (typed named inputs) is exposed
1268// read-only for display.
1269// ============================================================================
1270impl History {
1271    /// The history document's `expressions` source string (empty when absent or
1272    /// stored as `null`). The panel's editor binds to this.
1273    pub fn expressions(&self) -> String {
1274        self.document()
1275            .get("expressions")
1276            .and_then(Value::as_str)
1277            .unwrap_or("")
1278            .to_string()
1279    }
1280
1281    /// Replace the `expressions` source string. Snapshotted for undo, coalescing a
1282    /// run of keystroke edits into ONE undo entry (like a slider drag) via the
1283    /// shared `"expressions"` coalesce key, so a distinct add/edit/roll starts a
1284    /// fresh entry. A no-op re-set (same text) still records under the same key.
1285    pub fn set_expressions(&mut self, expressions: &str) {
1286        self.checkpoint(Some("expressions"));
1287        if let Some(obj) = self.document_mut().as_object_mut() {
1288            obj.insert(
1289                "expressions".into(),
1290                Value::String(expressions.to_string()),
1291            );
1292        }
1293    }
1294
1295    /// The `configurator` object (typed named inputs), or `{}` when absent —
1296    /// read-only for the panel's display (deeper configurator editing deferred).
1297    pub fn configurator(&self) -> Value {
1298        self.document()
1299            .get("configurator")
1300            .cloned()
1301            .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
1302    }
1303}
1304
1305// ============================================================================
1306// Feature `persistentData` accessors (appended — the engine-native sketch mode
1307// slice). A SEPARATE `impl` block (like the expressions accessors above) so
1308// concurrent edits don't conflict; purely additive over the primary history API.
1309//
1310// A feature's `persistentData` is the kernel-persisted, non-input state (a
1311// SKETCH feature stores its solved `{points, geometries, constraints}` under the
1312// `sketch` key and the plane `basis` there). Sketch mode reads that state on
1313// enter and writes the edited doc back on commit — mirroring how the ref-select
1314// slice reads/writes `inputParams` through `feature_params` / `set_feature_params`.
1315// ============================================================================
1316impl History {
1317    /// The `persistentData` document of the feature at `index` (`None` if the
1318    /// feature or the field is absent) — the read twin of [`Self::feature_params`].
1319    pub fn feature_persistent_data(&self, index: usize) -> Option<Value> {
1320        self.features().get(index)?.get("persistentData").cloned()
1321    }
1322
1323    /// Set a single `key` inside the feature-at-`index`'s `persistentData` object,
1324    /// creating (or replacing a non-object) `persistentData` as needed. Snapshotted
1325    /// for undo (a structural edit — never coalesced), like an add/delete.
1326    pub fn set_feature_persistent_field(&mut self, index: usize, key: &str, value: Value) {
1327        self.set_feature_persistent_field_coalesced(index, key, value, None);
1328    }
1329
1330    /// [`Self::set_feature_persistent_field`] with an optional COALESCE key: a
1331    /// run of same-key writes (a gizmo drag moving a spline anchor, frame after
1332    /// frame) records ONE undo entry, exactly as a slider drag on a param does.
1333    pub fn set_feature_persistent_field_coalesced(
1334        &mut self,
1335        index: usize,
1336        key: &str,
1337        value: Value,
1338        coalesce_key: Option<&str>,
1339    ) {
1340        if index >= self.len() {
1341            return;
1342        }
1343        self.checkpoint(coalesce_key);
1344        if let Some(feat) = self.features_mut().get_mut(index).and_then(Value::as_object_mut) {
1345            let entry = feat
1346                .entry("persistentData")
1347                .or_insert_with(|| Value::Object(serde_json::Map::new()));
1348            if !entry.is_object() {
1349                *entry = Value::Object(serde_json::Map::new());
1350            }
1351            if let Some(obj) = entry.as_object_mut() {
1352                obj.insert(key.to_string(), value);
1353            }
1354        }
1355    }
1356
1357    /// Write several `persistentData` fields of the feature at `index` as ONE
1358    /// user edit: one undo checkpoint covers them all, and there is none when
1359    /// every field already holds its value, so an action that changed nothing
1360    /// leaves no undo step that does nothing. Returns whether anything changed.
1361    ///
1362    /// Leaving a sketch writes its geometry, its dimension label offsets and
1363    /// its external references this way. Written one field at a time they were
1364    /// three undo steps, and only the third Ctrl+Z took the drawing back.
1365    pub fn set_feature_persistent_fields(&mut self, index: usize, fields: Vec<(&str, Value)>) -> bool {
1366        let Some(feature) = self.features().get(index) else {
1367            return false;
1368        };
1369        let current = feature.get("persistentData");
1370        if fields.iter().all(|(key, value)| current.and_then(|data| data.get(*key)) == Some(value)) {
1371            return false;
1372        }
1373        self.checkpoint(None);
1374        if let Some(feat) = self.features_mut().get_mut(index).and_then(Value::as_object_mut) {
1375            let entry = feat
1376                .entry("persistentData")
1377                .or_insert_with(|| Value::Object(serde_json::Map::new()));
1378            if !entry.is_object() {
1379                *entry = Value::Object(serde_json::Map::new());
1380            }
1381            if let Some(obj) = entry.as_object_mut() {
1382                for (key, value) in fields {
1383                    obj.insert(key.to_string(), value);
1384                }
1385            }
1386        }
1387        true
1388    }
1389
1390    /// The undo step on top of the stack right now, as a token for
1391    /// [`Self::retract`] and [`Self::set_feature_persistent_fields_in_step`];
1392    /// `None` when there is nothing to undo.
1393    pub fn undo_top(&self) -> Option<u64> {
1394        self.undo_stack.last().map(|snapshot| snapshot.serial)
1395    }
1396
1397    /// Take back undo step `step` as though its edit had never been made:
1398    /// restore the state it holds and DROP it, leaving no redo entry. Only while
1399    /// `step` is still the newest step; otherwise nothing happens and this
1400    /// returns false, and the caller undoes the ordinary way.
1401    ///
1402    /// Cancelling a sketch created in the same session uses it, so the cancel
1403    /// leaves no undo step behind: two presses, one of which brought the
1404    /// cancelled sketch back, before. It cannot restore a redo stack the step's
1405    /// own edit cleared when it was taken. Neither could the cancel it replaces.
1406    pub fn retract(&mut self, step: u64) -> bool {
1407        if self.refuse_user() {
1408            return false;
1409        }
1410        if self.undo_top() != Some(step) {
1411            return false;
1412        }
1413        let Some(snapshot) = self.undo_stack.pop() else {
1414            return false;
1415        };
1416        self.restore(snapshot);
1417        self.last_edit_key = None;
1418        true
1419    }
1420
1421    /// [`Self::set_feature_persistent_fields`] as part of undo step `step`,
1422    /// with no checkpoint of its own, while `step` is still the newest step;
1423    /// otherwise an ordinary write with its own step. Finishing a sketch
1424    /// created in the same session writes this way, so creating, drawing and
1425    /// finishing it is one undo step.
1426    pub fn set_feature_persistent_fields_in_step(
1427        &mut self,
1428        index: usize,
1429        fields: Vec<(&str, Value)>,
1430        step: u64,
1431    ) -> bool {
1432        if self.undo_top() != Some(step) {
1433            return self.set_feature_persistent_fields(index, fields);
1434        }
1435        let Some(feat) = self.features_mut().get_mut(index).and_then(Value::as_object_mut) else {
1436            return false;
1437        };
1438        let entry = feat
1439            .entry("persistentData")
1440            .or_insert_with(|| Value::Object(serde_json::Map::new()));
1441        if !entry.is_object() {
1442            *entry = Value::Object(serde_json::Map::new());
1443        }
1444        if let Some(obj) = entry.as_object_mut() {
1445            for (key, value) in fields {
1446                obj.insert(key.to_string(), value);
1447            }
1448        }
1449        true
1450    }
1451}
1452
1453// ============================================================================
1454// eCAD block accessors (the eCAD documents slice).
1455//
1456// The four eCAD workbenches keep their documents as top-level keys: `diagram`
1457// (the wiring diagram) and `pcb` (schematic and board) are blocks of an
1458// ASSEMBLY document, since a board is an assembly; `symbol` and `pads` are
1459// blocks of a PART document, since a part carries its own symbol and pads.
1460// Like `sheets`, the kernel never reads them and `HistoryRequest` has no field
1461// for them: the raw document is what carries them through save, load, undo and
1462// the assembly fold. Absent on every document the eCAD editors never wrote, so
1463// those files re-serialize byte-identically.
1464//
1465// None of these setters runs the history. The tab's dirty dot and the autosave
1466// see the edit through [`History::revision`], which every write here moves. The
1467// two symbol setters also carry a pin edit to the part's PORT features (the
1468// pins-and-ports block below), and the engine's `pump` runs the history when
1469// that moved a port.
1470// ============================================================================
1471
1472/// The assembly document's wiring diagram (an eCAD `Document`).
1473pub const DIAGRAM: &str = "diagram";
1474/// The assembly document's schematic and board (an eCAD `Document`).
1475pub const PCB: &str = "pcb";
1476/// The part document's schematic symbol (an eCAD `Symbol`). Defined AS the
1477/// kernel's key: the pin/port binding (`brep_kernel` `part_pins`) reads the same
1478/// block, so the two can never name different keys.
1479pub const SYMBOL: &str = brep_kernel::SYMBOL_BLOCK;
1480/// The part document's footprint pads (an eCAD `Footprint`), defined AS the
1481/// kernel's key for the same reason.
1482pub const PADS: &str = brep_kernel::PADS_BLOCK;
1483/// The part's declared connection points (`ports.rs`) — data, not history,
1484/// and a sibling of `symbol` and `pads`.
1485pub const PORTS: &str = brep_kernel::PORTS_BLOCK;
1486
1487impl History {
1488    /// The document's `diagram` block, if any.
1489    pub fn diagram_block(&self) -> Option<&Value> {
1490        self.document().get(DIAGRAM)
1491    }
1492
1493    /// The document's `pcb` block, if any.
1494    pub fn pcb_block(&self) -> Option<&Value> {
1495        self.document().get(PCB)
1496    }
1497
1498    /// The document's `symbol` block, if any.
1499    pub fn symbol_block(&self) -> Option<&Value> {
1500        self.document().get(SYMBOL)
1501    }
1502
1503    /// The document's `pads` block, if any.
1504    pub fn pads_block(&self) -> Option<&Value> {
1505        self.document().get(PADS)
1506    }
1507
1508    /// The document's `ports` block (its declared connection points), if any.
1509    pub fn ports_block(&self) -> Option<&Value> {
1510        self.document().get(PORTS)
1511    }
1512
1513    /// The part's declared connection points, in block order.
1514    pub fn declared_points(&self) -> Vec<brep_kernel::DeclaredPoint> {
1515        brep_kernel::declared_points(self.document())
1516    }
1517
1518    /// The symbol's pin labels in pin order, or `None` without a symbol.
1519    pub fn pin_labels(&self) -> Option<Vec<String>> {
1520        brep_kernel::pin_labels(self.document())
1521    }
1522
1523    /// The symbol's pins with the UNIT each belongs to, in pin order, or `None`
1524    /// without a symbol. [`Self::pin_labels`] with the unit kept, for a caller
1525    /// that must say WHICH group a pin binds into.
1526    pub fn pins(&self) -> Option<Vec<brep_kernel::DeclaredPin>> {
1527        brep_kernel::pins(self.document())
1528    }
1529
1530    /// The port group the pins of symbol unit `unit` bind into, when one is
1531    /// mapped. The unit map's rule is the kernel's ([`brep_kernel::unit_groups`]).
1532    pub fn port_group_for_unit(&self, unit: u32) -> Option<String> {
1533        brep_kernel::port_group_for_unit(self.document(), unit)
1534    }
1535
1536    /// Replace (or, with `None`, remove) the `diagram` block as a USER edit.
1537    /// See `Self::set_ecad_block` for `coalesce_key`.
1538    pub fn set_diagram_block(&mut self, block: Option<Value>, coalesce_key: Option<&str>) {
1539        self.set_ecad_block(DIAGRAM, block, coalesce_key);
1540    }
1541
1542    /// Replace (or, with `None`, remove) the `pcb` block as a USER edit.
1543    /// See `Self::set_ecad_block` for `coalesce_key`.
1544    pub fn set_pcb_block(&mut self, block: Option<Value>, coalesce_key: Option<&str>) {
1545        self.set_ecad_block(PCB, block, coalesce_key);
1546    }
1547
1548    /// Replace (or, with `None`, remove) the `symbol` block as a USER edit,
1549    /// and follow it to the part's ports in the same undo step (see
1550    /// `Self::follow_pins`). See `Self::set_ecad_block` for `coalesce_key`.
1551    pub fn set_symbol_block(&mut self, block: Option<Value>, coalesce_key: Option<&str>) {
1552        self.set_ecad_block(SYMBOL, block, coalesce_key);
1553        self.follow_pins(None);
1554    }
1555
1556    /// Replace (or, with `None`, remove) the `pads` block as a USER edit.
1557    /// See `Self::set_ecad_block` for `coalesce_key`.
1558    pub fn set_pads_block(&mut self, block: Option<Value>, coalesce_key: Option<&str>) {
1559        self.set_ecad_block(PADS, block, coalesce_key);
1560    }
1561
1562    /// Write the `symbol` block WITHOUT an undo checkpoint, for a write that
1563    /// belongs to the checkpoint just taken: a KiCad import adds its Import 3D
1564    /// feature (one checkpoint) and writes the symbol and pads it read beside
1565    /// it, so one undo removes the whole import. The PMI import's
1566    /// [`Self::set_pmi_block_no_undo`] is the precedent.
1567    ///
1568    /// Skipping the checkpoint is deliberate, and costs the tab nothing: the
1569    /// write still goes through `document_mut`, which moves the revision the
1570    /// unsaved dot and the autosave key on.
1571    pub fn set_symbol_block_no_undo(&mut self, block: Option<Value>) {
1572        // No checkpoint marks where this write starts, so its base is the
1573        // document just before it.
1574        let base = self.document().clone();
1575        self.put_ecad_block(SYMBOL, block);
1576        self.follow_pins(Some(base));
1577    }
1578
1579    /// The `pads` twin of [`Self::set_symbol_block_no_undo`].
1580    pub fn set_pads_block_no_undo(&mut self, block: Option<Value>) {
1581        self.put_ecad_block(PADS, block);
1582    }
1583
1584    /// Replace (or, with `None`, remove) the `ports` block as a USER edit, and
1585    /// follow it to the part's pins in the same undo step (see
1586    /// `Self::follow_ports`).
1587    pub fn set_ports_block(&mut self, block: Option<Value>, coalesce_key: Option<&str>) {
1588        self.set_ecad_block(PORTS, block, coalesce_key);
1589        self.follow_ports();
1590        self.ports_followed = true;
1591    }
1592
1593    /// The `ports` twin of [`Self::set_symbol_block_no_undo`] — for a write
1594    /// that belongs to the checkpoint just taken (the KiCad import).
1595    pub fn set_ports_block_no_undo(&mut self, block: Option<Value>) {
1596        self.put_ecad_block(PORTS, block);
1597        self.ports_followed = true;
1598    }
1599
1600    /// Write block `key`, snapshotted for undo. `coalesce_key` is eCAD's own
1601    /// `Change::coalesce`, passed as the editor reported it: typing in one
1602    /// field of one object reports `component:{uuid}:text` on every frame, and
1603    /// a run of writes with that key is ONE undo entry. It is namespaced by the
1604    /// block here (`diagram:component:{uuid}:text`), so a run in one block never
1605    /// swallows an edit to another. `None` (a drag, a placement, the editor's
1606    /// own undo) is its own step.
1607    fn set_ecad_block(&mut self, key: &str, block: Option<Value>, coalesce_key: Option<&str>) {
1608        let coalesce_key = coalesce_key.map(|change| format!("{key}:{change}"));
1609        self.checkpoint(coalesce_key.as_deref());
1610        self.put_ecad_block(key, block);
1611    }
1612
1613    fn put_ecad_block(&mut self, key: &str, block: Option<Value>) {
1614        if let Some(object) = self.document_mut().as_object_mut() {
1615            match block {
1616                Some(block) => {
1617                    object.insert(key.into(), block);
1618                }
1619                None => {
1620                    object.shift_remove(key);
1621                }
1622            }
1623        }
1624    }
1625}
1626
1627// ============================================================================
1628// Pins and points: a part's symbol pins ARE its declared connection points,
1629// bound by NAME (eCAD plan decision 8; `brep_kernel` `part_pins` holds the
1630// rules).
1631//
1632// The binding lives HERE, inside the writes, so no caller can edit one side
1633// without the other: the symbol setters follow a pin edit to the points, and
1634// every mutator that can add, remove or rename a point follows it to the pins,
1635// inside the same undo step. Undo, redo and a replaced document need nothing,
1636// since a snapshot restores both sides together.
1637//
1638// Each follow diffs against the RUN BASE: the document the current coalesced
1639// run started from. Typing `10` over pin `2` is four writes under one eCAD key
1640// (`2`, ``, `1`, `10`). The two in the middle cannot bind (no label, and a
1641// label pin 1 already has), so they are HELD: the user's edit stands, the
1642// other side keeps what the last bound write gave it, and `pin_port_hold`
1643// names why. Replaying from the base makes the last keystroke decide
1644// whichever came before it.
1645// ============================================================================
1646
1647impl History {
1648    /// Why the latest pin or port edit was not followed to the other side, in
1649    /// words that name the label (`two pins are labelled '1'`), or `None`.
1650    pub fn pin_port_hold(&self) -> Option<&str> {
1651        self.pin_port_hold.as_deref()
1652    }
1653
1654    /// Everything about this part's pins and connection points that does not
1655    /// pair, by name; `None` for a document without a symbol.
1656    pub fn pin_point_report(&self) -> Option<brep_kernel::PinPointReport> {
1657        brep_kernel::pin_point_report(self.document())
1658    }
1659
1660    /// Whether a symbol edit has changed the declared points since the history
1661    /// last ran, clearing it. The engine re-runs when it was set.
1662    pub(crate) fn take_ports_followed(&mut self) -> bool {
1663        std::mem::take(&mut self.ports_followed)
1664    }
1665
1666    /// Whether the next [`crate::engine_state::EngineState::pump`] will re-run
1667    /// for a moved point, without clearing it — so the app can hand a document
1668    /// that is not in front its own parts library before that run is submitted.
1669    pub fn ports_follow_pending(&self) -> bool {
1670        self.ports_followed
1671    }
1672
1673    /// The document the current coalesced run started from. Right after a
1674    /// checkpointing write it is the top of the undo stack: [`Self::checkpoint`]
1675    /// pushes the pre-write state unless the write joins a run, and a run's
1676    /// first write pushed the state the run started from. Eviction takes the
1677    /// OLDEST entry, never the top. The stack is never empty after a
1678    /// checkpoint, so the fallback (the current document, which binds nothing)
1679    /// is never taken by a follow. Borrowed: a follow runs on every write of a
1680    /// drag, so the base is compared in place and never copied.
1681    fn run_base(&self) -> &Value {
1682        self.undo_stack
1683            .last()
1684            .map(|snapshot| &snapshot.request)
1685            .unwrap_or_else(|| self.document())
1686    }
1687
1688    /// Carry the symbol edit between the base (`base`, else the run base) and
1689    /// now to the connection points and the pads: a renamed pin renames its
1690    /// point in place (so the point keeps its placement), a removed pin removes
1691    /// its point, and a new pin gets a new point in the part's first port
1692    /// group.
1693    fn follow_pins(&mut self, base: Option<Value>) {
1694        let mut document = self.document().clone();
1695        let outcome = {
1696            let base = base.as_ref().unwrap_or_else(|| self.run_base());
1697            brep_kernel::follow_pin_edit(base, &mut document)
1698        };
1699        self.settle(outcome, document);
1700    }
1701
1702    /// Carry a connection-point edit between the run base and now to the pins
1703    /// and pads. Only an edit that changed the declared points is followed, so
1704    /// an unrelated edit neither binds nor clears a standing hold.
1705    fn follow_ports(&mut self) {
1706        if self.symbol_block().is_none() {
1707            return;
1708        }
1709        if brep_kernel::declared_points(self.run_base()) == brep_kernel::declared_points(self.document()) {
1710            return;
1711        }
1712        let mut document = self.document().clone();
1713        let outcome = brep_kernel::follow_point_edit(self.run_base(), &mut document);
1714        self.settle(outcome, document);
1715    }
1716
1717    /// Adopt a follow's result, or record why it was held. The write goes
1718    /// through `document_mut`, not a checkpoint: it belongs to the step the
1719    /// user's own write just took. The rolled-to step keeps its meaning: a
1720    /// model rolled to its last feature is still rolled to its last feature
1721    /// once a port is appended or removed, and one rolled back stays on the
1722    /// same feature.
1723    fn settle(&mut self, outcome: Result<Vec<brep_kernel::PinPointChange>, String>, document: Value) {
1724        let changes = match outcome {
1725            Ok(changes) => changes,
1726            Err(reason) => {
1727                self.pin_port_hold = Some(reason);
1728                return;
1729            }
1730        };
1731        self.pin_port_hold = None;
1732        if changes.is_empty() {
1733            return;
1734        }
1735        let moves_a_port = changes.iter().any(|change| {
1736            use brep_kernel::PinPointChange::*;
1737            // Exhaustive on purpose: a new kind of change must say whether
1738            // it touches the geometry a run publishes.
1739            match change {
1740                PointRenamed { .. } | PointAdded { .. } | PointRemoved { .. } => true,
1741                PinRelabelled { .. } | PinAdded { .. } | PinRemoved { .. }
1742                | PadsRenumbered { .. } | PadsAmbiguous { .. } => false,
1743            }
1744        });
1745        let at_end = self.rollback + 1 >= self.len();
1746        let rolled_to = self.feature_id(self.rollback());
1747        *self.document_mut() = document;
1748        let len = self.len();
1749        self.rollback = if len == 0 {
1750            0
1751        } else if at_end {
1752            len - 1
1753        } else {
1754            rolled_to
1755                .and_then(|id| self.index_of(&id))
1756                .unwrap_or_else(|| self.rollback.saturating_sub(1))
1757                .min(len - 1)
1758        };
1759        self.ports_followed |= moves_a_port;
1760    }
1761}
1762
1763
1764// ============================================================================
1765// Named top-level blocks the APP owns (appended — the document-classes slice).
1766// The history holds the document as a JSON value, so a key the kernel never
1767// reads (`documentClass`, a family's `familyTable`, a template's
1768// `templateInputs`, a member's `familySource`) rides save, open and every undo
1769// snapshot untouched. These are the doors that change one: the user-edit one
1770// records an undo step, the other is for a write that is bookkeeping rather
1771// than an edit (the class a load reads off the file's extension).
1772// ============================================================================
1773impl History {
1774    /// The document's top-level block `key`, if present.
1775    pub fn document_block(&self, key: &str) -> Option<&Value> {
1776        self.document().get(key)
1777    }
1778
1779    /// Replace (or, with `None`, remove) top-level block `key` as a USER edit:
1780    /// snapshotted for undo. `coalesce_key` groups a typing run into one step,
1781    /// namespaced by the block so a run in one never swallows another's.
1782    pub fn set_document_block(&mut self, key: &str, block: Option<Value>, coalesce_key: Option<&str>) {
1783        let coalesce_key = coalesce_key.map(|change| format!("block:{key}:{change}"));
1784        self.checkpoint(coalesce_key.as_deref());
1785        self.set_document_block_no_undo(key, block);
1786    }
1787
1788    /// [`Self::set_document_block`] with no undo step.
1789    pub fn set_document_block_no_undo(&mut self, key: &str, block: Option<Value>) {
1790        if let Some(object) = self.document_mut().as_object_mut() {
1791            match block {
1792                Some(block) => {
1793                    object.insert(key.to_string(), block);
1794                }
1795                None => {
1796                    // `shift_remove`: with `preserve_order`, `remove` moves the
1797                    // last key into the gap, and the reordered document then
1798                    // reads as an unsaved change.
1799                    object.shift_remove(key);
1800                }
1801            }
1802        }
1803    }
1804}
1805