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