BREP_app 0.2.0

The BREP CAD application: an eframe (egui + wgpu) host that draws the brep-render 3D engine into an egui frame — native + wasm from one codebase.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
//! EDIT-IN-PLACE — the PRIMARY component edit flow (assemblies build-spec
//! §8.5): activating a component swaps the ACTIVE document to that part's
//! parts-library embedded document, the user edits it with the ordinary
//! history tools, and Finish refreshes the library entry so EVERY instance of
//! the part heals + the assembly re-solves; Cancel restores the assembly
//! untouched. A persistent banner (drawn through the [`ModeBar`] card, the
//! established special-mode home) shows "Editing <part> (<ACOMPn>)" with
//! Finish / Cancel.
//!
//! # The refresh lane — document transport, not a UI-side kernel call
//!
//! The kernel's `refresh_library_entry` mutates the RUNNER-side thread-local
//! library (the thread/worker that runs `execute_history`); calling it from
//! the UI thread under the Thread/Worker runner would mutate a cold store
//! nobody executes against (it only *looks* right under the test-time Inline
//! runner). Finish therefore rides the DOCUMENT: it rewrites the stashed
//! assembly's `partsLibrary[part]` entry — new `document`, a fresh
//! content-hash `sourceSignature`, and a BLANKED `snapshot` — and reloads.
//! `set_history_json` resets the runner (clearing its resident library), the
//! next run ingests the block, and the empty snapshot forces the kernel's
//! designed SELF-HEAL lane: re-execute the embedded document, re-snapshot,
//! rebuild every instance, re-solve. Same end state as `refresh_library_entry`,
//! correct under every runner.
//!
//! # WRITE-THROUGH — Finish also saves the part's SOURCE file
//!
//! The library refresh above is an IN-MEMORY update; on its own it persists
//! only if the user then saves the ASSEMBLY, leaving the part's `.BREP.json`
//! silently behind (and — because `panels::update_components` compares the
//! store's content against the entry's `sourceSignature` — badging the
//! component "outdated" against a file it is actually NEWER than). Finish
//! therefore writes the edited part document back to the `sourceKey` it came
//! from, so the entry's signature and the file agree and the badge stays dark.
//! Five lanes, in [`write_through`]:
//!
//! * **embedded-only** (`sourceKey: ""`): no file exists to write back to.
//!   Skipped SILENTLY, exactly as before this lane landed. This used to be
//!   EVERY STEP-imported part (build-spec §3.5); a STEP import now writes its
//!   unique parts to the store and stamps a real key, so the empty key is what
//!   a part gets when there was no store to write to — a headless import, or a
//!   write that failed.
//! * **source gone** (the file was deleted or renamed since insertion): NOT
//!   recreated — resurrecting a file the user removed is a worse surprise than
//!   a note. The assembly keeps the edit; a notice says the file was not
//!   updated.
//! * **source moved on** (the file was edited + saved elsewhere since the
//!   assembly last synced with it — the very case the outdated badge catches):
//!   NOT overwritten. Clobbering it would destroy work that lives nowhere
//!   else; the assembly keeps the edit, the notice names the file, and the
//!   badge (now genuinely divergent) stays lit.
//! * **write failed**: the edit still lands in the assembly — losing it is the
//!   worst outcome, a stale file is recoverable — with a notice carrying the
//!   backend's error. (A WRITE-BEHIND backend can also fail LATER; that lands
//!   in `ModelStore::take_persistence_errors`, which the shell already drains
//!   into the toast overlay every frame.)
//! * **written**: a notice naming the part AND the file, so the side effect on
//!   a document shared by other assemblies is visible rather than hidden.
//!
//! [`ModeBar`]: super::mode_bar::ModeBar

use crate::store::ModelStore;
use brep_render::engine_state::EngineState;
use eframe::egui;
#[cfg(target_arch = "wasm32")]
use std::collections::HashMap;

/// The stashed pre-edit context: everything Finish/Cancel needs to restore the
/// assembly and (on Finish) refresh the part entry.
struct Stash {
    /// The FULL assembly request document at entry (features + partsLibrary +
    /// assembly blocks + metadata + featureCounter — `history_request_json`).
    assembly_doc: String,
    /// The parts-library entry name being edited.
    part_name: String,
    /// The activating instance (for the banner label only — the refresh
    /// updates EVERY instance of the part).
    component_id: String,
    /// The part document as loaded (post-normalization), the dirty baseline.
    part_baseline: String,
}

/// The edit-in-place state machine (shell-owned; the engine stays the model
/// brain underneath — the ACTIVE document is simply swapped).
#[derive(Default)]
pub struct AssemblyEdit {
    stash: Option<Stash>,
    /// The dirty-guard confirm modal is up (a Cancel was requested on a dirty
    /// part edit; nothing is restored until the user decides).
    confirm_cancel: bool,
    /// Per-frame widget hit-rects for the headed verifier (wasm only).
    #[cfg(target_arch = "wasm32")]
    hits: HashMap<String, egui::Rect>,
}

impl AssemblyEdit {
    pub fn new() -> Self {
        Self::default()
    }

    /// Whether an edit-in-place session is active.
    pub fn active(&self) -> bool {
        self.stash.is_some()
    }

    /// The banner label: `Editing <part> (<ACOMPn>)`.
    pub fn label(&self) -> Option<String> {
        self.stash
            .as_ref()
            .map(|stash| format!("Editing {} ({})", stash.part_name, stash.component_id))
    }

    /// Whether the ACTIVE part document differs from its load baseline.
    pub fn dirty(&self, state: &EngineState) -> bool {
        self.stash
            .as_ref()
            .map(|stash| stash.part_baseline != state.history_request_json())
            .unwrap_or(false)
    }

    /// ENTER edit-in-place on the component owned by ACOMP `component_id`:
    /// stash the assembly document, load the part's embedded library document
    /// as the active history. Nested activation refuses with a toast; a
    /// missing/broken library entry toasts and stays in the assembly.
    pub fn enter(&mut self, state: &mut EngineState, component_id: &str) {
        if let Some(stash) = &self.stash {
            state.push_notice(format!(
                "Already editing {} ({}) — Finish or Cancel first",
                stash.part_name, stash.component_id
            ));
            return;
        }
        // A live sketch edit / reference pick holds a session over the CURRENT
        // document — swapping it out from under them would corrupt both.
        // (The context bar is hidden in these modes; this guards the tree-hook
        // seam and any future entry point.)
        if state.sketch_mode() || state.ref_select_active() {
            state.push_notice(
                "Finish the active sketch / reference selection before editing a part".to_string(),
            );
            return;
        }
        let Some(info) = state.component_info(component_id) else {
            state.push_notice(format!("'{component_id}' is not an assembly component"));
            return;
        };
        let assembly_doc = state.history_request_json();
        let document: serde_json::Value = match serde_json::from_str(&assembly_doc) {
            Ok(value) => value,
            Err(error) => {
                state.push_notice(format!("Edit in place: assembly document unreadable: {error}"));
                return;
            }
        };
        let entry = &document["partsLibrary"][&info.part_name];
        let part_doc = &entry["document"];
        if !part_doc.is_object() {
            state.push_notice(format!(
                "Edit in place: part '{}' has no parts-library document",
                info.part_name
            ));
            return;
        }
        // The assembly's selection / gizmos reference NAMESPACED entities that
        // do not exist in the part document — drop them before the swap.
        state.clear_selection();
        state.disarm_transform();
        match state.set_history_json(&part_doc.to_string()) {
            Ok(_) => {
                self.stash = Some(Stash {
                    assembly_doc,
                    part_name: info.part_name.clone(),
                    component_id: component_id.to_string(),
                    // Baseline AFTER the load so the dirty compare sees the
                    // same normalization Finish will serialize.
                    part_baseline: state.history_request_json(),
                });
                self.confirm_cancel = false;
                state.zoom_to_fit();
            }
            Err(error) => {
                state.push_notice(format!(
                    "Edit in place: part '{}' failed to load: {error}",
                    info.part_name
                ));
            }
        }
    }

    /// FINISH: refresh the library entry from the edited part document (new
    /// signature, blanked snapshot → kernel self-heal rebuilds every instance),
    /// WRITE THE EDIT BACK to the part's source document in the store (the
    /// module header's write-through lanes), restore the assembly, re-run +
    /// re-solve. An UNCHANGED part restores the assembly verbatim (no pointless
    /// rebuild-the-world, and nothing to save).
    pub fn finish(&mut self, state: &mut EngineState, store: &dyn ModelStore) {
        let Some(stash) = self.stash.take() else {
            return;
        };
        self.confirm_cancel = false;
        let edited = state.history_request_json();
        if edited == stash.part_baseline {
            self.restore(state, &stash.assembly_doc);
            return;
        }
        let mut assembly: serde_json::Value = match serde_json::from_str(&stash.assembly_doc) {
            Ok(value) => value,
            Err(_) => {
                // Should be impossible (we serialized it) — fail safe into the
                // stashed text rather than losing the assembly.
                state.push_notice("Edit in place: stashed assembly unreadable — restoring as-is".to_string());
                self.restore(state, &stash.assembly_doc);
                return;
            }
        };
        // The write-through target, read BEFORE the refresh rewrites the
        // entry's signature (the compare below needs the PRE-edit one).
        let target = source_target(&assembly, &stash.part_name);
        if refresh_library_entry(&mut assembly, &stash.part_name, &edited).is_err() {
            // `edited` came from `history_request_json` (always parseable), so
            // the only failure here is the entry itself going missing.
            state.push_notice(format!(
                "Edit in place: part '{}' vanished from the library — assembly restored, edits dropped",
                stash.part_name
            ));
            self.restore(state, &stash.assembly_doc);
            return;
        }
        // Save the part BEFORE the reload: every lane leaves the in-memory
        // refresh standing, so a failed write costs a file, never the edit.
        // (Notices survive `set_history_json` — the update-components batch
        // rides the same ordering.)
        write_through(state, store, &stash.part_name, target.as_ref(), &edited);
        self.restore(state, &assembly.to_string());
    }

    /// CANCEL request: a dirty part edit prompts first (the confirm modal);
    /// a clean one restores immediately.
    pub fn cancel(&mut self, state: &mut EngineState) {
        if self.dirty(state) {
            self.confirm_cancel = true;
        } else {
            self.discard(state);
        }
    }

    /// Discard the part edits and restore the assembly unchanged (the confirm
    /// modal's "Discard" — also the clean-cancel path).
    fn discard(&mut self, state: &mut EngineState) {
        self.confirm_cancel = false;
        if let Some(stash) = self.stash.take() {
            self.restore(state, &stash.assembly_doc);
        }
    }

    /// Load `doc` back as the active document + frame it.
    fn restore(&mut self, state: &mut EngineState, doc: &str) {
        if let Err(error) = state.set_history_json(doc) {
            state.push_notice(format!("Edit in place: assembly restore failed: {error}"));
        }
        state.zoom_to_fit();
    }

    /// The persistent BANNER card ("Editing <part> (<ACOMPn>) — Finish /
    /// Cancel"), drawn inside the shared top-right overlay via the ModeBar.
    /// `store` is Finish's write-through destination. No-op while inactive.
    pub fn card(&mut self, ui: &mut egui::Ui, state: &mut EngineState, store: &dyn ModelStore) {
        if !self.active() {
            return;
        }
        #[cfg(target_arch = "wasm32")]
        self.hits.clear();
        let label = self.label().unwrap_or_default();
        let dirty = self.dirty(state);
        let mut do_finish = false;
        let mut do_cancel = false;
        egui::Frame::popup(ui.style()).show(ui, |ui| {
            ui.label(
                egui::RichText::new(if dirty {
                    format!("{label} \u{25CF}")
                } else {
                    label
                })
                .strong(),
            );
            ui.label(
                egui::RichText::new("Finish saves the part and updates every instance")
                    .weak()
                    .small(),
            );
            ui.horizontal(|ui| {
                let finish = ui.button("Finish").on_hover_text(
                    "Save the part and refresh it — every instance updates, the assembly re-solves",
                );
                self.hit("assembly-edit:finish", &finish);
                if finish.clicked() {
                    do_finish = true;
                }
                let cancel = ui
                    .button("Cancel")
                    .on_hover_text("Discard the part edits and restore the assembly");
                self.hit("assembly-edit:cancel", &cancel);
                if cancel.clicked() {
                    do_cancel = true;
                }
            });
        });
        if do_finish {
            self.finish(state, store);
        } else if do_cancel {
            self.cancel(state);
        }
    }

    /// The dirty-guard confirm modal (ctx level, like the file dialog's
    /// ConfirmNew). Draws only while a dirty Cancel is pending.
    pub fn show_confirm(&mut self, ctx: &egui::Context, state: &mut EngineState) {
        if !self.confirm_cancel || !self.active() {
            return;
        }
        let label = self.label().unwrap_or_default();
        let mut discard = false;
        let mut keep = false;
        let modal = egui::Modal::new(egui::Id::new("brep-assembly-edit-cancel")).show(ctx, |ui| {
            ui.set_width(340.0);
            ui.heading("Discard part edits?");
            ui.add_space(4.0);
            ui.label(format!("{label} has unsaved part edits."));
            ui.add_space(6.0);
            ui.horizontal(|ui| {
                let d = ui.button("Discard");
                self.hit("assembly-edit:confirm-discard", &d);
                if d.clicked() {
                    discard = true;
                }
                let k = ui.button("Keep editing");
                self.hit("assembly-edit:keep", &k);
                if k.clicked() {
                    keep = true;
                }
            });
        });
        if discard {
            self.discard(state);
        } else if keep || modal.should_close() {
            self.confirm_cancel = false;
        }
    }

    // --- verifier hooks (wasm only) ---------------------------------------

    /// Logical state for the headed verifier.
    #[cfg(target_arch = "wasm32")]
    pub fn state_json(&self, state: &EngineState) -> String {
        serde_json::json!({
            "active": self.active(),
            "label": self.label(),
            "dirty": self.dirty(state),
            "confirmCancel": self.confirm_cancel,
        })
        .to_string()
    }

    /// Published widget hit-rects for the headed verifier.
    #[cfg(target_arch = "wasm32")]
    pub fn hits_json(&self) -> String {
        let map: serde_json::Map<String, serde_json::Value> = self
            .hits
            .iter()
            .map(|(k, r)| {
                (
                    k.clone(),
                    serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
                )
            })
            .collect();
        serde_json::Value::Object(map).to_string()
    }

    #[cfg(target_arch = "wasm32")]
    fn hit(&mut self, key: &str, resp: &egui::Response) {
        self.hits.insert(key.to_string(), resp.rect);
    }
    #[cfg(not(target_arch = "wasm32"))]
    #[inline]
    fn hit(&mut self, _key: &str, _resp: &egui::Response) {}
}

/// Rewrite `partsLibrary[part_name]` in a parsed assembly document with a NEW
/// part document text: the embedded `document`, a fresh [`document_signature`],
/// and a BLANKED `snapshot`. The blank snapshot is the heal trigger: the
/// entry's resident `dirty` flag cannot ride the document (serde-skipped), but
/// an unreadable snapshot routes the ACOMP feature into the SAME self-heal lane
/// (re-execute `document`, re-snapshot, rebuild every instance, re-solve).
///
/// The ONE "refresh a library entry through the document" step (the
/// document-transport lane in this module's header): edit-in-place Finish and
/// the update-components batch (`panels::update_components`) both ride it. The
/// caller reloads the rewritten document (`set_history_json`) once — batch
/// callers rewrite every entry first, then reload.
pub fn refresh_library_entry(
    assembly: &mut serde_json::Value,
    part_name: &str,
    document_json: &str,
) -> Result<(), String> {
    let document: serde_json::Value = serde_json::from_str(document_json)
        .map_err(|error| format!("part document unreadable: {error}"))?;
    if !document.is_object() {
        return Err("part document unreadable: not a JSON object".to_string());
    }
    match assembly["partsLibrary"].get_mut(part_name) {
        Some(entry) if entry.is_object() => {
            entry["document"] = document;
            entry["sourceSignature"] =
                serde_json::Value::String(document_signature(document_json));
            entry["snapshot"] = serde_json::Value::String(String::new());
            Ok(())
        }
        _ => Err(format!("no parts-library entry '{part_name}'")),
    }
}

/// The write-through target for `part_name`: `(sourceKey, sourceSignature)` as
/// the entry carries them BEFORE [`refresh_library_entry`] rewrites the
/// signature — the key names the file, the signature is the content the
/// assembly last SYNCED with that file (its insertion, an update-components
/// run, or an earlier write-through).
///
/// `None` for an EMBEDDED-ONLY entry — an empty `sourceKey` — because there is
/// no file to write back to. (Build-spec §3.5 used to make that every
/// STEP-imported part; an import now writes its parts and stamps a real key, so
/// an empty key means there was no store to write to.) `panels::update_components` skips the same entries for the
/// same reason; inventing a store document for one would be a surprise, not a
/// save.
fn source_target(assembly: &serde_json::Value, part_name: &str) -> Option<(String, String)> {
    let entry = assembly.get("partsLibrary")?.get(part_name)?;
    let key = entry.get("sourceKey")?.as_str()?;
    if key.is_empty() {
        return None;
    }
    let signature = entry
        .get("sourceSignature")
        .and_then(serde_json::Value::as_str)
        .unwrap_or_default();
    Some((key.to_string(), signature.to_string()))
}

/// WRITE-THROUGH: save `edited` (the part document Finish just refreshed the
/// library entry from) back to the store document it came from, so the file and
/// the assembly's embedded copy stay one thing — and the update-components
/// badge, which compares [`document_signature`] of the file against the entry's
/// `sourceSignature`, reads a freshly edited part as up-to-date instead of
/// backwards.
///
/// The module header lists the lanes. EVERY refusal leaves the caller's
/// in-memory refresh standing and only pushes a notice: a stale file is
/// recoverable, a dropped edit is not.
///
/// `pub(crate)` because it is the ONE write-through lane, not this module's
/// alone: the BOM panel's part-attribute edits (`panels::bom`) change the same
/// part document and must reach the same file by the same five rules. A second
/// copy of "when may I overwrite a part file?" is exactly the drift this
/// function exists to prevent.
pub(crate) fn write_through(
    state: &mut EngineState,
    store: &dyn ModelStore,
    part_name: &str,
    target: Option<&(String, String)>,
    edited: &str,
) {
    // Embedded-only (STEP-imported) part: no source document, no write, no
    // notice — nothing changed for it, so nothing to report.
    let Some((source_key, inserted_signature)) = target else {
        return;
    };
    let Some(current) = store.read(source_key) else {
        state.push_notice(format!(
            "Edit in place: source '{source_key}' is no longer in storage — \
             '{part_name}' updated in the assembly only, the file was NOT written"
        ));
        return;
    };
    if document_signature(&current) != *inserted_signature {
        state.push_notice(format!(
            "Edit in place: source '{source_key}' changed since the assembly last synced '{part_name}' — \
             NOT overwritten; the assembly holds your edit (Update Components takes the file's version instead)"
        ));
        return;
    }
    match store.write(source_key, edited) {
        Ok(()) => state.push_notice(format!("Saved '{part_name}' to '{source_key}'")),
        Err(error) => state.push_notice(format!(
            "Edit in place: could not save '{source_key}': {error}\
             '{part_name}' updated in the assembly only"
        )),
    }
}

/// The ONE signature fn, re-exported from its engine-altitude home
/// ([`brep_render::engine_state::document_signature`]) so the app's writers —
/// the insert flow (`panels::file`), [`refresh_library_entry`], the
/// update-components comparison (`panels::update_components`) — and any
/// engine-side writer hash a part document identically. Kept on this path
/// because this module is the app's documented signature/refresh home.
pub use brep_render::engine_state::document_signature;

// Native-only (as `panels::update_components`'s suite is): the fixtures ride
// the test-only in-memory `ModelStore`, which the wasm build does not compile.
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
    use super::*;
    use crate::panels::component_actions::tests::{assembly_engine, two_instance_assembly_json};
    use crate::panels::update_components::tests::{assembly_with_store, part_document};
    use crate::panels::update_components::UpdateComponents;
    use crate::store::MemModelStore;

    /// A store that reads fine and NEVER writes — the quota/permission failure
    /// the write-through lane must survive without dropping the user's edit.
    struct FailingStore(MemModelStore);

    impl ModelStore for FailingStore {
        fn read(&self, name: &str) -> Option<String> {
            self.0.read(name)
        }
        fn write(&self, _name: &str, _contents: &str) -> Result<(), String> {
            Err("storage quota exceeded".into())
        }
    }

    /// The `sizeX` of the ONE feature in a stored part document.
    fn stored_size_x(store: &dyn ModelStore, key: &str) -> f64 {
        let contents = store.read(key).unwrap_or_else(|| panic!("no '{key}' stored"));
        let document: serde_json::Value = serde_json::from_str(&contents).expect("stored json");
        document["features"][0]["inputParams"]["sizeX"]
            .as_f64()
            .expect("sizeX")
    }

    /// Enter edit-in-place on `component_id` and widen the part's cube to
    /// `size_x` — the one edit every write-through test makes.
    fn edit_width(edit: &mut AssemblyEdit, state: &mut EngineState, component_id: &str, size_x: f64) {
        edit.enter(state, component_id);
        assert!(edit.active(), "edit-in-place entered");
        let mut params: serde_json::Value =
            serde_json::from_str(&state.feature_params_json(0)).unwrap();
        params["sizeX"] = serde_json::json!(size_x);
        state
            .update_feature_params("Part", &params.to_string())
            .unwrap();
        assert!(edit.dirty(state), "the part edit marks the session dirty");
    }

    fn size_x_of(state: &EngineState, solid: &str) -> f64 {
        let bbox = &state.scene.solid(solid).expect(solid).bbox;
        bbox.max[0] - bbox.min[0]
    }

    #[test]
    fn enter_swaps_to_the_part_document_and_finish_updates_every_instance() {
        let mut engine = assembly_engine();
        let pre_entry = engine.history_request_json();
        let mut edit = AssemblyEdit::new();
        assert!(!edit.active());

        edit.enter(&mut engine, "ACOMP2");
        assert!(edit.active(), "editing session active");
        assert_eq!(edit.label().as_deref(), Some("Editing widget (ACOMP2)"));
        // The ACTIVE history is now the PART document: one un-namespaced cube.
        assert_eq!(engine.history_len(), 1);
        assert_eq!(engine.feature_id_at(0).as_deref(), Some("Part"));
        assert!(engine.scene.solid("Part").is_some(), "part solid, no namespace");
        assert!(!edit.dirty(&engine), "freshly-entered edit is clean");

        // Edit the part: widen the cube 10 → 14.
        let mut params: serde_json::Value =
            serde_json::from_str(&engine.feature_params_json(0)).unwrap();
        params["sizeX"] = serde_json::json!(14.0);
        engine.update_feature_params("Part", &params.to_string()).unwrap();
        assert!(edit.dirty(&engine), "a part edit marks the session dirty");

        // Finish: the assembly restores and BOTH instances carry the edit
        // (library refresh → kernel self-heal rebuild of every instance). The
        // fixture's `sourceKey` has no store document, so the write-through
        // lane notes it and stands aside — the in-memory path is unchanged.
        edit.finish(&mut engine, &MemModelStore::new());
        assert!(!edit.active());
        assert_eq!(engine.history_len(), 2, "assembly features restored");
        assert!((size_x_of(&engine, "ACOMP1:Part") - 14.0).abs() < 1e-6);
        assert!((size_x_of(&engine, "ACOMP2:Part") - 14.0).abs() < 1e-6);

        // The refreshed library entry rode the document: new signature and the
        // edited embedded document. Finish ships a BLANKED snapshot (the heal
        // trigger); the run's main-side assembly sync then heals it and
        // captures the HEALED snapshot back into the document (the save-
        // freshness lane), so by the time we read it here it must be a valid
        // restorable payload again — never the stale pre-edit one.
        let doc: serde_json::Value =
            serde_json::from_str(&engine.history_request_json()).unwrap();
        let entry = &doc["partsLibrary"]["widget"];
        assert_ne!(entry["sourceSignature"], "sig-1", "signature refreshed");
        assert_eq!(
            entry["document"]["features"][0]["inputParams"]["sizeX"], 14.0,
            "embedded document carries the edit"
        );
        let snapshot = entry["snapshot"].as_str().unwrap_or_default();
        assert!(
            !snapshot.is_empty(),
            "the main-side sync captures the healed snapshot into the document"
        );
        assert!(
            brep_render::brep_kernel::restore_solids(snapshot).is_ok(),
            "captured snapshot restores cleanly"
        );
        let _ = pre_entry;
    }

    #[test]
    fn cancel_restores_the_assembly_unchanged_with_a_dirty_guard() {
        let mut engine = assembly_engine();
        let pre_entry = engine.history_request_json();
        let mut edit = AssemblyEdit::new();

        edit.enter(&mut engine, "ACOMP1");
        let mut params: serde_json::Value =
            serde_json::from_str(&engine.feature_params_json(0)).unwrap();
        params["sizeX"] = serde_json::json!(99.0);
        engine.update_feature_params("Part", &params.to_string()).unwrap();
        assert!(edit.dirty(&engine));

        // A DIRTY cancel prompts first — nothing restored yet.
        edit.cancel(&mut engine);
        assert!(edit.confirm_cancel, "dirty cancel raises the confirm modal");
        assert!(edit.active(), "still editing until the user decides");
        assert_eq!(engine.history_len(), 1, "still the part document");

        // Discard: assembly restored BYTE-IDENTICAL, edits dropped.
        edit.discard(&mut engine);
        assert!(!edit.active());
        assert_eq!(engine.history_request_json(), pre_entry, "assembly untouched");
        assert!((size_x_of(&engine, "ACOMP1:Part") - 10.0).abs() < 1e-6);
    }

    #[test]
    fn clean_cancel_and_clean_finish_skip_the_refresh() {
        let mut engine = assembly_engine();
        let pre_entry = engine.history_request_json();
        let mut edit = AssemblyEdit::new();

        // Clean cancel: no prompt, straight restore.
        edit.enter(&mut engine, "ACOMP2");
        edit.cancel(&mut engine);
        assert!(!edit.confirm_cancel && !edit.active());
        assert_eq!(engine.history_request_json(), pre_entry);

        // Clean finish: restore verbatim — the library signature stays and
        // NOTHING is written back (there is no edit to save).
        let store = MemModelStore::new();
        store.put("widget", &two_instance_assembly_json());
        let before = store.read("widget");
        edit.enter(&mut engine, "ACOMP2");
        edit.finish(&mut engine, &store);
        assert_eq!(store.read("widget"), before, "an unchanged part is not saved");
        assert!(!edit.active());
        let doc: serde_json::Value =
            serde_json::from_str(&engine.history_request_json()).unwrap();
        assert_eq!(doc["partsLibrary"]["widget"]["sourceSignature"], "sig-1");
    }

    #[test]
    fn nested_activation_refuses_with_a_toast() {
        let mut engine = assembly_engine();
        let mut edit = AssemblyEdit::new();
        edit.enter(&mut engine, "ACOMP2");
        let _ = engine.take_notices();

        edit.enter(&mut engine, "ACOMP1");
        assert_eq!(
            edit.label().as_deref(),
            Some("Editing widget (ACOMP2)"),
            "the first session survives"
        );
        let notices = engine.take_notices();
        assert!(
            notices.iter().any(|n| n.contains("Already editing")),
            "{notices:?}"
        );
    }

    #[test]
    fn missing_library_entry_toasts_and_stays_in_the_assembly() {
        // Strip the library block: the ACOMP features fail to build (that is
        // fine) and edit-in-place must refuse cleanly.
        let mut doc: serde_json::Value =
            serde_json::from_str(&two_instance_assembly_json()).unwrap();
        doc.as_object_mut().unwrap().remove("partsLibrary");
        let mut engine = EngineState::new();
        let _ = engine.set_history_json(&doc.to_string());
        let mut edit = AssemblyEdit::new();

        edit.enter(&mut engine, "ACOMP2");
        assert!(!edit.active());
        assert_eq!(engine.history_len(), 2, "assembly document untouched");
        let notices = engine.take_notices();
        assert!(
            notices.iter().any(|n| n.contains("no parts-library document")),
            "{notices:?}"
        );
    }

    /// The shared refresh helper: a good document rewrites the entry (document
    /// + fresh signature + blanked snapshot); unreadable content and a missing
    /// entry error WITHOUT touching the assembly value's other entries.
    #[test]
    fn refresh_library_entry_rewrites_or_errors() {
        let mut assembly: serde_json::Value =
            serde_json::from_str(&two_instance_assembly_json()).unwrap();
        let new_doc = r#"{"features":[{"type":"P.CU","inputParams":{"id":"Part","sizeX":14}}]}"#;

        refresh_library_entry(&mut assembly, "widget", new_doc).expect("refresh ok");
        let entry = &assembly["partsLibrary"]["widget"];
        assert_eq!(entry["document"]["features"][0]["inputParams"]["sizeX"], 14);
        assert_eq!(entry["sourceSignature"], document_signature(new_doc));
        assert_eq!(entry["snapshot"], "", "blanked snapshot = the heal trigger");

        let before = assembly.clone();
        let err = refresh_library_entry(&mut assembly, "widget", "not json").unwrap_err();
        assert!(err.contains("unreadable"), "{err}");
        let err = refresh_library_entry(&mut assembly, "gone", new_doc).unwrap_err();
        assert!(err.contains("no parts-library entry"), "{err}");
        assert_eq!(assembly, before, "failed refreshes leave the document alone");
    }

    // --- WRITE-THROUGH ------------------------------------------------------

    /// THE POINT OF THE SLICE: Finish saves the edited part back to the file it
    /// was inserted from, and the update-components badge — which compares the
    /// FILE's signature against the entry's — then reads the component as
    /// up-to-date instead of "outdated against a file it is newer than".
    #[test]
    fn finish_writes_the_edit_back_to_the_source_and_clears_the_outdated_badge() {
        let (mut state, store) = assembly_with_store();
        let mut updates = UpdateComponents::new();
        updates.ensure_current(&mut state, &store, 0);
        assert_eq!(updates.outdated_count(), 0, "a fresh insert starts clean");

        let mut edit = AssemblyEdit::new();
        edit_width(&mut edit, &mut state, "ACOMP2", 14.0);
        // Negative control: nothing is written until Finish, so a regression
        // that drops the write flips the badge below to 1.
        assert!((stored_size_x(&store, "widget") - 10.0).abs() < 1e-9);

        edit.finish(&mut state, &store);
        assert!(!edit.active());

        // The SOURCE file carries the edit …
        assert!((stored_size_x(&store, "widget") - 14.0).abs() < 1e-9);
        let stored = store.read("widget").expect("source document");
        // … and the entry's signature is the ONE signature fn over exactly that
        // content — the invariant the badge compare rests on.
        let document: serde_json::Value =
            serde_json::from_str(&state.history_request_json()).unwrap();
        assert_eq!(
            document["partsLibrary"]["widget"]["sourceSignature"],
            document_signature(&stored)
        );

        // The badge (recomputed from scratch, not a cache hit) is DARK.
        let mut after = UpdateComponents::new();
        after.ensure_current(&mut state, &store, 0);
        assert_eq!(after.outdated_count(), 0, "a written-through edit is not outdated");
        assert!(!after.is_outdated("widget"));
        assert!(after.missing().is_empty());

        // The in-memory path is untouched: every instance rebuilt.
        assert!((size_x_of(&state, "ACOMP1:Part") - 14.0).abs() < 1e-6);
        assert!((size_x_of(&state, "ACOMP2:Part") - 14.0).abs() < 1e-6);

        let notices = state.take_notices();
        assert!(
            notices.iter().any(|n| n.contains("Saved") && n.contains("widget")),
            "the success notice names the part and the file: {notices:?}"
        );
    }

    /// A FAILED WRITE (quota, permissions, a read-only backend) never costs the
    /// edit: the assembly holds it, the notice carries the backend's error.
    #[test]
    fn a_failed_write_keeps_the_edit_and_reports_the_file_was_not_saved() {
        let (mut state, store) = assembly_with_store();
        let store = FailingStore(store);
        let mut edit = AssemblyEdit::new();
        edit_width(&mut edit, &mut state, "ACOMP1", 14.0);

        edit.finish(&mut state, &store);
        assert!(!edit.active());

        // Edit kept in memory, file untouched.
        assert!((size_x_of(&state, "ACOMP1:Part") - 14.0).abs() < 1e-6);
        assert!((size_x_of(&state, "ACOMP2:Part") - 14.0).abs() < 1e-6);
        assert!((stored_size_x(&store, "widget") - 10.0).abs() < 1e-9);
        let notices = state.take_notices();
        assert!(
            notices
                .iter()
                .any(|n| n.contains("could not save 'widget'") && n.contains("quota")),
            "{notices:?}"
        );
    }

    /// A source document DELETED (or renamed) since insertion is not recreated
    /// — the edit stands in the assembly, with a notice; the entry lands in the
    /// update-components MISSING lane (noted, never counted).
    #[test]
    fn a_vanished_source_is_noted_and_never_recreated() {
        let (mut state, store) = assembly_with_store();
        store.remove("widget").unwrap();
        let mut edit = AssemblyEdit::new();
        edit_width(&mut edit, &mut state, "ACOMP2", 14.0);

        edit.finish(&mut state, &store);
        assert_eq!(store.read("widget"), None, "a removed source is not resurrected");
        assert!((size_x_of(&state, "ACOMP2:Part") - 14.0).abs() < 1e-6);
        let notices = state.take_notices();
        assert!(
            notices
                .iter()
                .any(|n| n.contains("no longer in storage") && n.contains("widget")),
            "{notices:?}"
        );

        let mut updates = UpdateComponents::new();
        updates.ensure_current(&mut state, &store, 0);
        assert_eq!(updates.outdated_count(), 0);
        assert_eq!(updates.missing(), ["widget".to_string()]);
    }

    /// A source that MOVED ON since insertion (edited + saved elsewhere — the
    /// case the outdated badge exists to catch) is NOT clobbered: write-through
    /// would destroy work that lives nowhere else. The edit stays in the
    /// assembly and the entry stays badged, which is now the truth.
    #[test]
    fn a_source_changed_since_insert_is_not_overwritten() {
        let (mut state, store) = assembly_with_store();
        // Somebody else opened the part, edited it and saved.
        let elsewhere = part_document(7.0);
        store.put("widget", &elsewhere);
        let mut edit = AssemblyEdit::new();
        edit_width(&mut edit, &mut state, "ACOMP1", 14.0);

        edit.finish(&mut state, &store);
        assert_eq!(store.read("widget").as_deref(), Some(elsewhere.as_str()));
        assert!((size_x_of(&state, "ACOMP1:Part") - 14.0).abs() < 1e-6);
        let notices = state.take_notices();
        assert!(
            notices
                .iter()
                .any(|n| n.contains("changed since") && n.contains("widget")),
            "{notices:?}"
        );

        let mut updates = UpdateComponents::new();
        updates.ensure_current(&mut state, &store, 0);
        assert!(
            updates.is_outdated("widget"),
            "the file really does differ — the badge stays lit"
        );
    }

    /// EMBEDDED-ONLY parts (`sourceKey: ""` — every STEP-imported part) behave
    /// exactly as before: the in-memory refresh runs, the store is never read,
    /// never written, and no source notice appears.
    #[test]
    fn embedded_only_parts_never_touch_the_store() {
        let mut document: serde_json::Value =
            serde_json::from_str(&two_instance_assembly_json()).unwrap();
        document["partsLibrary"]["widget"]["sourceKey"] = serde_json::json!("");
        let mut engine = EngineState::new();
        engine.set_history_json(&document.to_string()).expect("assembly loads");
        let store = MemModelStore::new();
        store.put("widget", &part_document(10.0));
        let before = store.read("widget");
        let reads = store.reads();

        let mut edit = AssemblyEdit::new();
        edit_width(&mut edit, &mut engine, "ACOMP2", 14.0);
        edit.finish(&mut engine, &store);

        assert_eq!(store.list(), vec!["widget".to_string()], "no document invented");
        assert_eq!(store.read("widget"), before, "the source lane never wrote");
        assert_eq!(store.reads(), reads + 1, "…and only this test's own assert read it");
        assert!((size_x_of(&engine, "ACOMP1:Part") - 14.0).abs() < 1e-6);
        assert!((size_x_of(&engine, "ACOMP2:Part") - 14.0).abs() < 1e-6);
        let notices = engine.take_notices();
        assert!(
            notices
                .iter()
                .all(|n| !n.contains("Saved") && !n.contains("storage")),
            "an embedded-only part says nothing about a source: {notices:?}"
        );
    }
}