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