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