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
//! UPDATE COMPONENTS — the assemblies build-spec §8.6 flow: compare each
//! parts-library entry's `sourceSignature` against the ModelStore's CURRENT
//! content for its `sourceKey` (with [`document_signature`], the ONE signature
//! fn), badge the outdated count on the Assembly Constraints header and the
//! per-node rows of the structure tree, and refresh every outdated entry
//! through the ESTABLISHED document-transport lane
//! ([`refresh_library_entry`]): rewrite the embedded `document` + a fresh
//! signature + a BLANKED snapshot per entry, then ONE reload — the kernel
//! self-heals every instance from the refreshed documents, re-solves, and the
//! main-side sync captures the healed snapshots back into the document.
//!
//! # Staleness (recompute on a cheap trigger, never per frame)
//!
//! The comparison reads the store once per entry — too hot to recompute on
//! every panel draw. [`UpdateComponents::ensure_current`] caches the result
//! keyed on `(EngineState::applied_generation, FileDialog::save_generation)`:
//!
//! * an applied history run bumps the first half (covers opening an assembly,
//!   inserting components, edit-in-place Finish, constraint solves);
//! * a successful store save bumps the second (covers Open Part → edit →
//!   save → return to the assembly: the badge lights without a restart).
//!
//! Entries with an EMPTY `sourceKey` (embedded-only parts) are skipped;
//! entries whose key has no store document are NOTED (surfaced in the header
//! hover + a run-time notice) but never counted — they cannot be refreshed.
//!
//! The comparison only means "outdated" because every writer keeps the entry's
//! `sourceSignature` equal to what the FILE holds: the insert lane hashes the
//! file it read, [`UpdateComponents::run`] hashes the file it just pulled in,
//! and edit-in-place Finish SAVES the edited part back to its `sourceKey`
//! before stamping the signature (`panels::assembly_edit`'s write-through
//! lane). Without that last one an in-context edit read as outdated against a
//! file it was newer than — the badge meaning the opposite of what happened.

use crate::panels::assembly_edit::{document_signature, refresh_library_entry};
use crate::store::ModelStore;
use brep_render::engine_state::EngineState;
use serde_json::Value;

/// The shell-owned outdated-parts checker + batch refresher. One instance on
/// the app; the constraints panel reads the count (and runs the refresh), the
/// structure tree reads per-part flags.
#[derive(Default)]
pub struct UpdateComponents {
    /// Parts-library entry names whose store content no longer matches their
    /// `sourceSignature` — the badge count.
    outdated: Vec<String>,
    /// Entry names with a `sourceKey` but NO store document under it (noted,
    /// never counted — nothing to refresh from).
    missing: Vec<String>,
    /// The `(applied_generation, save_generation)` key the cache was computed
    /// against; `None` forces a recompute on the next ensure.
    checked: Option<(u64, u64)>,
}

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

    /// Recompute the outdated/missing sets when the staleness key moved (or
    /// after [`Self::invalidate`]); otherwise a cheap compare. The shell calls
    /// this once per frame BEFORE the assembly panels draw.
    pub fn ensure_current(
        &mut self,
        state: &mut EngineState,
        model_store: &dyn ModelStore,
        save_generation: u64,
    ) {
        let key = (state.applied_generation(), save_generation);
        if self.checked == Some(key) {
            return;
        }
        let (outdated, missing) = compute(state, model_store);
        self.outdated = outdated;
        self.missing = missing;
        self.checked = Some(key);
    }

    /// Drop the cache so the next [`Self::ensure_current`] recomputes even on
    /// an unmoved key (after a run, or an external store change).
    pub fn invalidate(&mut self) {
        self.checked = None;
    }

    /// The outdated-entry count — the constraints-header badge number.
    pub fn outdated_count(&self) -> usize {
        self.outdated.len()
    }

    /// Whether `part_name`'s library entry is outdated — the structure tree's
    /// per-node badge (every instance of the part lights).
    pub fn is_outdated(&self, part_name: &str) -> bool {
        self.outdated.iter().any(|part| part == part_name)
    }

    /// Entry names noted as source-less (header hover text).
    pub fn missing(&self) -> &[String] {
        &self.missing
    }

    /// RUN the update (the header button): recompute against the LIVE document
    /// (never a stale cache), rewrite every outdated entry from its store
    /// content through [`refresh_library_entry`], then reload ONCE — the
    /// kernel self-heals every instance + re-solves, and the main-side sync
    /// captures healed snapshots back. Per-entry failures (missing store key,
    /// unreadable content) toast and SKIP — the batch never aborts. Returns
    /// the number of entries refreshed.
    pub fn run(
        &mut self,
        state: &mut EngineState,
        model_store: &dyn ModelStore,
    ) -> Result<usize, String> {
        let (outdated, missing) = compute(state, model_store);
        if !missing.is_empty() {
            state.push_notice(format!(
                "Update components: no source document for {} — skipped",
                missing.join(", ")
            ));
        }
        if outdated.is_empty() {
            self.invalidate();
            return Ok(0);
        }
        let mut document: Value = serde_json::from_str(&state.history_request_json())
            .map_err(|error| format!("assembly document unreadable: {error}"))?;
        let mut refreshed = 0usize;
        for part_name in &outdated {
            let source_key = document["partsLibrary"][part_name]["sourceKey"]
                .as_str()
                .unwrap_or_default()
                .to_string();
            let Some(contents) = model_store.read(&source_key) else {
                state.push_notice(format!(
                    "Update components: '{part_name}' source '{source_key}' missing — skipped"
                ));
                continue;
            };
            match refresh_library_entry(&mut document, part_name, &contents) {
                Ok(()) => refreshed += 1,
                Err(error) => state.push_notice(format!(
                    "Update components: '{part_name}': {error} — skipped"
                )),
            }
        }
        if refreshed > 0 {
            // The ONE reload for the whole batch (no zoom — the camera stays).
            state
                .set_history_json(&document.to_string())
                .map_err(|error| format!("assembly reload failed: {error}"))?;
            state.push_notice(format!(
                "Updated {refreshed} part(s) from source — every instance rebuilt"
            ));
        }
        self.invalidate();
        Ok(refreshed)
    }
}

/// The signature comparison over the LIVE document's `partsLibrary` block →
/// `(outdated, missing)` entry-name sets. Componentless documents short-circuit
/// to empty (zero cost for modeling files).
fn compute(state: &mut EngineState, model_store: &dyn ModelStore) -> (Vec<String>, Vec<String>) {
    let mut outdated = Vec::new();
    let mut missing = Vec::new();
    if !state.history_has_assembly() {
        return (outdated, missing);
    }
    // The document's partsLibrary block mirrors the kernel store post-sync
    // (heals + GC included) — make sure that fold ran for this generation.
    state.ensure_assembly_synced();
    let Ok(document) = serde_json::from_str::<Value>(&state.history_request_json()) else {
        return (outdated, missing);
    };
    let Some(library) = document.get("partsLibrary").and_then(Value::as_object) else {
        return (outdated, missing);
    };
    for (part_name, entry) in library {
        let Some(source_key) = entry
            .get("sourceKey")
            .and_then(Value::as_str)
            .filter(|key| !key.is_empty())
        else {
            continue; // embedded-only part: no source to compare against
        };
        match model_store.read(source_key) {
            None => missing.push(part_name.clone()),
            Some(contents) => {
                let signature = entry
                    .get("sourceSignature")
                    .and_then(Value::as_str)
                    .unwrap_or_default();
                if document_signature(&contents) != signature {
                    outdated.push(part_name.clone());
                }
            }
        }
    }
    (outdated, missing)
}

#[cfg(all(test, not(target_arch = "wasm32")))]
pub(crate) mod tests {
    use super::*;
    use crate::store::MemModelStore;
    use brep_render::engine_state::ComponentInsert;

    /// A one-cube part document with a parameterized width (the edit under
    /// test), matching the shared assembly fixtures.
    pub(crate) fn part_document(size_x: f64) -> String {
        serde_json::json!({
            "expressions": "",
            "configurator": {},
            "features": [{
                "type": "P.CU",
                "inputParams": {
                    "id": "Part",
                    "sizeX": size_x, "sizeY": 3.0, "sizeZ": 4.0,
                    "transform": {
                        "position": [0.0, 0.0, 0.0],
                        "rotationEuler": [0.0, 0.0, 0.0],
                        "scale": [1.0, 1.0, 1.0]
                    },
                    "boolean": { "targets": [], "operation": "NONE" }
                },
                "persistentData": {}
            }]
        })
        .to_string()
    }

    /// A two-instance assembly inserted THROUGH the real flow, with its part
    /// content resident in an in-memory store under the entry's `sourceKey`
    /// and the signature written by the ONE signature fn (as the insert lane
    /// does) — the fresh-insert-compares-up-to-date invariant's ground truth.
    pub(crate) fn assembly_with_store() -> (EngineState, MemModelStore) {
        brep_render::brep_kernel::clear_history_cache();
        let store = MemModelStore::new();
        let content = part_document(10.0);
        store.put("widget", &content);
        let mut state = EngineState::new();
        state
            .insert_component(ComponentInsert::New {
                name: "widget",
                source_key: "widget",
                source_signature: &document_signature(&content),
                document_json: &content,
            })
            .expect("insert 1");
        state
            .insert_component(ComponentInsert::Existing { part_name: "widget" })
            .expect("insert 2");
        (state, store)
    }

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

    /// DETECTION: fresh insert → 0; changed store content → counted; a
    /// missing store key → not counted but noted. The save-generation half of
    /// the staleness key drives each recheck (the after-save trigger).
    #[test]
    fn outdated_detection_counts_changed_and_notes_missing() {
        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, "fresh insert compares up-to-date");
        assert!(updates.missing().is_empty());

        // The part's source changes (edited + saved elsewhere) → counted, and
        // BOTH instances of the one entry light through is_outdated.
        store.put("widget", &part_document(14.0));
        updates.ensure_current(&mut state, &store, 1);
        assert_eq!(updates.outdated_count(), 1);
        assert!(updates.is_outdated("widget"));
        assert!(!updates.is_outdated("other"));

        // A key-order-only variant of the ORIGINAL content is NOT outdated
        // (the signature walks parsed JSON with sorted keys, not raw text).
        let reordered = {
            let value: Value = serde_json::from_str(&part_document(10.0)).unwrap();
            serde_json::to_string_pretty(&value).unwrap()
        };
        store.put("widget", &reordered);
        updates.ensure_current(&mut state, &store, 2);
        assert_eq!(updates.outdated_count(), 0, "formatting-only resave is not outdated");

        // The store document vanishes: noted, never counted.
        store.remove("widget").unwrap();
        updates.ensure_current(&mut state, &store, 3);
        assert_eq!(updates.outdated_count(), 0);
        assert_eq!(updates.missing(), ["widget".to_string()]);
    }

    /// STALENESS: a stable `(applied, save)` key never re-reads the store; a
    /// moved save-generation (or an applied run) recomputes.
    #[test]
    fn ensure_current_caches_per_generation_key() {
        let (mut state, store) = assembly_with_store();
        let mut updates = UpdateComponents::new();

        updates.ensure_current(&mut state, &store, 0);
        let reads = store.reads();
        assert!(reads > 0, "first ensure reads the store");
        updates.ensure_current(&mut state, &store, 0);
        assert_eq!(store.reads(), reads, "stable key: no re-read per frame");
        updates.ensure_current(&mut state, &store, 1);
        assert!(store.reads() > reads, "a save bump recomputes");
    }

    /// THE REFRESH ROUND-TRIP: run() rewrites the outdated entry from the
    /// store (document + signature), the reload rebuilds EVERY instance with
    /// the edit, the main-side sync captures a restorable snapshot back into
    /// the document (non-blank), and the badge recomputes to 0.
    #[test]
    fn run_refreshes_entries_instances_and_snapshot() {
        let (mut state, store) = assembly_with_store();
        let new_content = part_document(14.0);
        store.put("widget", &new_content);
        let mut updates = UpdateComponents::new();
        updates.ensure_current(&mut state, &store, 1);
        assert_eq!(updates.outdated_count(), 1);

        let refreshed = updates.run(&mut state, &store).expect("run ok");
        assert_eq!(refreshed, 1);

        // Entry document + signature updated; snapshot captured back healed.
        let document: Value = serde_json::from_str(&state.history_request_json()).unwrap();
        let entry = &document["partsLibrary"]["widget"];
        assert_eq!(entry["document"]["features"][0]["inputParams"]["sizeX"], 14.0);
        assert_eq!(entry["sourceSignature"], document_signature(&new_content));
        let snapshot = entry["snapshot"].as_str().unwrap_or_default();
        assert!(!snapshot.is_empty(), "the sync captured the healed snapshot");
        assert!(brep_render::brep_kernel::restore_solids(snapshot).is_ok());

        // Both instances rebuilt with the edit.
        assert!((size_x_of(&state, "ACOMP1:Part") - 14.0).abs() < 1e-6);
        assert!((size_x_of(&state, "ACOMP2:Part") - 14.0).abs() < 1e-6);

        // The run invalidated the cache; a recheck lands on 0.
        updates.ensure_current(&mut state, &store, 1);
        assert_eq!(updates.outdated_count(), 0, "everything up-to-date after the run");

        let notices = state.take_notices();
        assert!(
            notices.iter().any(|n| n.contains("Updated 1 part")),
            "{notices:?}"
        );
    }

    /// FAILURE TOLERANCE: an unreadable source toasts + skips while the good
    /// entry refreshes; a vanished source key is noted + skipped — the batch
    /// never aborts and never damages the skipped entry.
    #[test]
    fn per_entry_failures_toast_and_skip() {
        brep_render::brep_kernel::clear_history_cache();
        let store = MemModelStore::new();
        let widget = part_document(10.0);
        let gadget = part_document(9.0);
        store.put("widget", &widget);
        store.put("gadget", &gadget);
        let mut state = EngineState::new();
        state
            .insert_component(ComponentInsert::New {
                name: "widget",
                source_key: "widget",
                source_signature: &document_signature(&widget),
                document_json: &widget,
            })
            .unwrap();
        state
            .insert_component(ComponentInsert::New {
                name: "gadget",
                source_key: "gadget",
                source_signature: &document_signature(&gadget),
                document_json: &gadget,
            })
            .unwrap();
        let mut updates = UpdateComponents::new();

        // widget's source changes VALIDLY; gadget's becomes unreadable.
        store.put("widget", &part_document(14.0));
        store.put("gadget", "definitely not json");
        let refreshed = updates.run(&mut state, &store).expect("batch survives");
        assert_eq!(refreshed, 1, "the good entry refreshed, the bad one skipped");
        let notices = state.take_notices();
        assert!(
            notices
                .iter()
                .any(|n| n.contains("gadget") && n.contains("unreadable")),
            "{notices:?}"
        );
        let document: Value = serde_json::from_str(&state.history_request_json()).unwrap();
        assert_eq!(
            document["partsLibrary"]["widget"]["document"]["features"][0]["inputParams"]["sizeX"],
            14.0
        );
        assert_eq!(
            document["partsLibrary"]["gadget"]["document"]["features"][0]["inputParams"]["sizeX"],
            9.0,
            "the skipped entry keeps its old content"
        );

        // Second wave: gadget's source key vanishes entirely (missing lane).
        store.put("widget", &part_document(16.0));
        store.remove("gadget").unwrap();
        let refreshed = updates.run(&mut state, &store).expect("batch survives");
        assert_eq!(refreshed, 1);
        let notices = state.take_notices();
        assert!(
            notices
                .iter()
                .any(|n| n.contains("no source document") && n.contains("gadget")),
            "{notices:?}"
        );
    }
}