BREP_app 0.3.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
//! Assembly COMPONENT STATE — the engine's component projection as owned rows.
//!
//! Not a panel. This is the shared, panel-independent snapshot of "what
//! components does this document have, and what is true of each": label, fixed,
//! the outdated-vs-source flag, the constraint-status rollup, visibility,
//! selection, member solids, and the nested sub-assembly chain grouping.
//!
//! It was the Assembly Structure panel's private snapshot until that panel was
//! folded into the BOM (which now draws every one of these adornments as a
//! column or an Item-cell glyph). The panel is gone; the projection outlived it
//! because it is engine truth, not a view: the BOM reads it, and the headed
//! verifiers read `__brepAssemblyTree` published from it.
//!
//! Strictly a VIEW projected from [`EngineState::assembly_components`] — never
//! an owning structure. The single source of truth stays history + parts
//! library, so undo/redo and provenance keep working unchanged.

use crate::panels::update_components::UpdateComponents;
use brep_render::assembly_status;
use brep_render::engine_state::EngineState;
use eframe::egui;
use serde_json::Value;
use std::collections::HashMap;

/// The grounded-component glyph (matches the kernel's Fixed-constraint glyph).
pub(crate) const FIXED_GLYPH: &str = "\u{23DA}"; // ⏚ earth ground

/// The outdated-vs-source badge glyph (`↻` — "refresh me").
pub(crate) const OUTDATED_GLYPH: &str = "\u{21BB}";

/// The outdated badge's amber (the status map's warning amber `#ff9f0a`).
pub(crate) const OUTDATED_AMBER: egui::Color32 = egui::Color32::from_rgb(0xff, 0x9f, 0x0a);

/// One component row's per-frame snapshot (decoupled from `state` so the draw
/// loop can issue deferred `&mut state` mutations afterwards — the panel
/// pattern shared with the Scene tree).
pub(crate) struct ComponentRow {
    /// Owning ACOMP feature id (= namespace prefix).
    pub id: String,
    /// `part_name (ACOMP3)`.
    pub label: String,
    /// The library part this instance is of — the BOM groups by it.
    pub part_name: String,
    pub fixed: bool,
    /// The instance's library entry no longer matches its store source (the
    /// update-components checker's per-part flag — every instance lights).
    pub outdated: bool,
    /// Worst constraint status referencing this component (None = the
    /// component participates in no constraint — no rollup dot).
    pub rollup_status: Option<String>,
    /// Every member solid currently visible?
    pub visible: bool,
    /// Any member solid in the current selection (viewport → tree sync)?
    pub selected: bool,
    /// Member scene names (namespaced).
    pub solids: Vec<String>,
    /// Read-only nested sub-assembly grouping parsed from the member names.
    pub children: Vec<ChainNode>,
}

/// One node of a rigid sub-assembly's read-only child grouping, parsed from the
/// members' chained namespace prefixes (`ACOMP3:ACOMP1:Part` → child `ACOMP1`
/// containing leaf `Part`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ChainNode {
    pub label: String,
    pub children: Vec<ChainNode>,
}

/// Whether a name segment reads as a component id (`ACOMP<digits>`) — the
/// syntactic nested-namespace discriminator (inner ids are not scene
/// components, so membership can only be judged by shape).
pub(crate) fn is_acomp_segment(segment: &str) -> bool {
    segment
        .strip_prefix("ACOMP")
        .map(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()))
        .unwrap_or(false)
}

/// The `collapsed`-set key for a nested component node: its chained path under
/// the owning component (`ACOMP1:ACOMP5`). Top-level rows key on the bare
/// `row.id` (never contains `:`); nested nodes append `:label`. The ONE place
/// this key is formed, so the render path and the collapse-all enumerator
/// ([`collapsible_keys`]) can never drift.
fn chain_key(parent_key: &str, label: &str) -> String {
    format!("{parent_key}:{label}")
}

/// Parse the read-only child grouping of one component's members: each member
/// name arrives with the OWNING prefix already stripped; a leading
/// `ACOMP<digits>:` chain groups into nested nodes, the remainder is a leaf.
/// Deterministic: groups in first-appearance order, leaves in member order.
pub(crate) fn chain_groups(member_locals: &[&str]) -> Vec<ChainNode> {
    let mut order: Vec<String> = Vec::new();
    let mut grouped: HashMap<String, Vec<&str>> = HashMap::new();
    let mut leaves: Vec<ChainNode> = Vec::new();
    for local in member_locals {
        match local.split_once(':') {
            Some((head, rest)) if is_acomp_segment(head) => {
                if !grouped.contains_key(head) {
                    order.push(head.to_string());
                }
                grouped.entry(head.to_string()).or_default().push(rest);
            }
            _ => leaves.push(ChainNode {
                label: (*local).to_string(),
                children: Vec::new(),
            }),
        }
    }
    let mut out: Vec<ChainNode> = order
        .into_iter()
        .map(|head| {
            let members = grouped.remove(&head).unwrap_or_default();
            ChainNode {
                children: chain_groups(&members),
                label: head,
            }
        })
        .collect();
    out.append(&mut leaves);
    out
}

/// The component id an assembly-constraint element ref belongs to: the vertex
/// `@`-suffix is stripped first, then the OUTERMOST namespace prefix (or the
/// bare id itself) is the owner. `None` for non-component refs.
fn owning_component_of_ref(element: &str) -> Option<&str> {
    let name = element.split('@').next().unwrap_or(element);
    let head = name.split(':').next().unwrap_or(name);
    is_acomp_segment(head).then_some(head)
}


/// Snapshot the engine's component projection into owned rows: label, fixed,
/// the outdated flag (from the update-components checker, per part name),
/// visibility (every member visible), selection (any member selected), the
/// constraint-status rollup, and the nested chain grouping.
pub(crate) fn snapshot(state: &mut EngineState, updates: &UpdateComponents) -> Vec<ComponentRow> {
    // Worst-status rollup per component, from the constraint state's element
    // refs (each ref's outermost prefix names its component).
    let mut rollup: HashMap<String, String> = HashMap::new();
    let constraint_state = state.assembly_state_value();
    if let Some(constraints) = constraint_state
        .get("constraints")
        .and_then(Value::as_array)
    {
        for entry in constraints {
            let status = entry
                .get("persistentData")
                .and_then(|data| data.get("status"))
                .and_then(Value::as_str)
                .unwrap_or("")
                .to_string();
            let elements = entry
                .get("inputParams")
                .and_then(|params| params.get("elements"))
                .and_then(Value::as_array)
                .cloned()
                .unwrap_or_default();
            for element in elements.iter().filter_map(Value::as_str) {
                let Some(component) = owning_component_of_ref(element) else {
                    continue;
                };
                let worse = rollup
                    .get(component)
                    .map(|current| {
                        assembly_status::status_severity(&status)
                            > assembly_status::status_severity(current)
                    })
                    .unwrap_or(true);
                if worse {
                    rollup.insert(component.to_string(), status.clone());
                }
            }
        }
    }

    let selected_solids = state.emphasis.selected_solids.clone();
    let solid_visible: HashMap<String, bool> = state
        .scene
        .solids()
        .iter()
        .map(|solid| (solid.name.clone(), solid.visible))
        .collect();

    state
        .assembly_components()
        .iter()
        .map(|record| {
            let prefix = format!("{}:", record.id);
            let locals: Vec<&str> = record
                .solids
                .iter()
                .map(|name| name.strip_prefix(&prefix).unwrap_or(name))
                .collect();
            ComponentRow {
                label: format!("{} ({})", record.part_name, record.id),
                part_name: record.part_name.clone(),
                fixed: record.fixed,
                outdated: updates.is_outdated(&record.part_name),
                rollup_status: rollup.get(&record.id).cloned(),
                visible: record
                    .solids
                    .iter()
                    .all(|name| solid_visible.get(name).copied().unwrap_or(true)),
                selected: record
                    .solids
                    .iter()
                    .any(|name| selected_solids.contains(name)),
                solids: record.solids.clone(),
                children: chain_groups(&locals),
                id: record.id.clone(),
            }
        })
        .collect()
}

/// Mirror an engine JSON string to `window.<name>` (wasm/verification only).
#[cfg(target_arch = "wasm32")]
fn publish(name: &str, json: &str) {
    if let Some(win) = web_sys::window() {
        let _ = js_sys::Reflect::set(
            &win,
            &wasm_bindgen::JsValue::from_str(name),
            &wasm_bindgen::JsValue::from_str(json),
        );
    }
}

/// Publish `__brepAssemblyTree` — the headed verifiers' component oracle.
///
/// Published from the projection rather than from any panel, so it stays true
/// whichever view is on screen. Three verifiers read it, one of them
/// (`verify_bom_menu`) as the ENGINE-SIDE proof that a menu action landed —
/// checking a panel's own rendering would be checking the panel against itself.
#[allow(unused_variables)]
pub(crate) fn publish_tree(rows: &[ComponentRow]) {
    #[cfg(target_arch = "wasm32")]
    {
        let listing: Vec<Value> = rows
            .iter()
            .map(|row| {
                serde_json::json!({
                    "id": row.id,
                    "label": row.label,
                    "fixed": row.fixed,
                    "outdated": row.outdated,
                    "visible": row.visible,
                    "selected": row.selected,
                    "status": row.rollup_status,
                    "solids": row.solids,
                })
            })
            .collect();
        publish("__brepAssemblyTree", &Value::Array(listing).to_string());
    }
}
#[cfg(test)]
mod tests {
    use super::*;
    use brep_render::engine_state::ComponentInsert;

    /// A one-cube part document (the insert-flow payload).
    fn part_document() -> String {
        serde_json::json!({
            "expressions": "",
            "configurator": {},
            "features": [{
                "type": "P.CU",
                "inputParams": {
                    "id": "Part",
                    "sizeX": 2.0, "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 through the REAL insert flow.
    fn two_instance_state() -> EngineState {
        brep_render::brep_kernel::clear_history_cache();
        let mut state = EngineState::new();
        state
            .insert_component(ComponentInsert::New {
                name: "bracket",
                source_key: "bracket",
                source_signature: "sig-1",
                document_json: &part_document(),
            })
            .expect("insert 1");
        state
            .insert_component(ComponentInsert::Existing { part_name: "bracket" })
            .expect("insert 2");
        state
    }

    /// A checker with nothing outdated (the default for tests not exercising
    /// the badge lane).
    fn no_updates() -> UpdateComponents {
        UpdateComponents::new()
    }

    /// NESTED CHAIN parsing: members with chained prefixes group into read-only
    /// child nodes; mixed flat members stay leaves; recursion nests further.
    #[test]
    fn chain_groups_parse_nested_prefixes() {
        let nodes = chain_groups(&["ACOMP1:Part", "ACOMP1:Cap", "Plate"]);
        assert_eq!(nodes.len(), 2);
        assert_eq!(nodes[0].label, "ACOMP1");
        assert_eq!(
            nodes[0].children,
            vec![
                ChainNode { label: "Part".into(), children: vec![] },
                ChainNode { label: "Cap".into(), children: vec![] },
            ]
        );
        assert_eq!(nodes[1].label, "Plate");

        // Two levels deep: ACOMP5:ACOMP1:Part chains recursively.
        let nodes = chain_groups(&["ACOMP5:ACOMP1:Part", "ACOMP5:Base"]);
        assert_eq!(nodes.len(), 1);
        assert_eq!(nodes[0].label, "ACOMP5");
        assert_eq!(nodes[0].children[0].label, "ACOMP1");
        assert_eq!(nodes[0].children[0].children[0].label, "Part");
        assert_eq!(nodes[0].children[1].label, "Base");

        // A NON-component prefix (a sketch-style authored name) stays a leaf.
        let nodes = chain_groups(&["S1:PROFILE"]);
        assert_eq!(nodes, vec![ChainNode { label: "S1:PROFILE".into(), children: vec![] }]);
    }

    /// NESTED SUB-ASSEMBLY integration: an assembly DOCUMENT (partsLibrary +
    /// its own ACOMP instance) inserted as a part arrives rigid, its members
    /// carrying chained prefixes — the tree renders the read-only child
    /// grouping from them.
    #[test]
    fn nested_sub_assembly_projects_a_chain_grouping() {
        // Build the inner assembly document: one bracket instance.
        brep_render::brep_kernel::clear_history_cache();
        let mut inner = EngineState::new();
        inner
            .insert_component(ComponentInsert::New {
                name: "bracket",
                source_key: "bracket",
                source_signature: "sig-1",
                document_json: &part_document(),
            })
            .expect("inner insert");
        let assembly_doc = inner.history.request_json();
        drop(inner);

        // A fresh document inserts THAT assembly as a rigid component.
        brep_render::brep_kernel::clear_history_cache();
        let mut state = EngineState::new();
        state
            .insert_component(ComponentInsert::New {
                name: "subasm",
                source_key: "subasm",
                source_signature: "sig-2",
                document_json: &assembly_doc,
            })
            .expect("outer insert");

        let rows = snapshot(&mut state, &no_updates());
        assert_eq!(rows.len(), 1);
        assert_eq!(rows[0].label, "subasm (ACOMP1)");
        // The member chains the INNER instance's prefix under the outer one.
        assert_eq!(rows[0].solids, vec!["ACOMP1:ACOMP1:Part".to_string()]);
        assert_eq!(rows[0].children.len(), 1);
        assert_eq!(rows[0].children[0].label, "ACOMP1", "read-only child group");
        assert_eq!(rows[0].children[0].children[0].label, "Part");
    }

    /// The rollup owner parser: bare ids, namespaced topology, vertex `@` refs;
    /// non-component refs yield none.
    #[test]
    fn rollup_owner_parses_element_refs() {
        assert_eq!(owning_component_of_ref("ACOMP2"), Some("ACOMP2"));
        assert_eq!(owning_component_of_ref("ACOMP2:Part_PZ"), Some("ACOMP2"));
        assert_eq!(owning_component_of_ref("ACOMP3:ACOMP1:Part_PZ"), Some("ACOMP3"));
        assert_eq!(owning_component_of_ref("ACOMP1:Part@2,3,4"), Some("ACOMP1"));
        assert_eq!(owning_component_of_ref("Box_PZ"), None);
        assert_eq!(owning_component_of_ref("S1:PROFILE"), None);
    }

    /// Constraint statuses roll up onto the referenced components (worst wins,
    /// through the ONE status map's severity ordering).
    #[test]
    fn constraint_status_rolls_up_onto_components() {
        let mut state = two_instance_state();
        state
            .assembly_add_constraint(
                "fixed",
                &serde_json::json!({ "elements": ["ACOMP2"] }).to_string(),
            )
            .expect("add fixed constraint");
        let rows = snapshot(&mut state, &no_updates());
        assert!(rows[0].rollup_status.is_none(), "ACOMP1 has no constraints");
        let status = rows[1].rollup_status.as_deref().expect("ACOMP2 rolled up");
        assert!(!status.is_empty());
    }

    /// BADGE PLUMBING (build-spec §8.6): when the update-components checker
    /// marks a part outdated, EVERY instance of it lights. Ported from the
    /// Structure panel that used to draw the badge — the flag is the part that
    /// was ever worth testing; where it is DRAWN is now the BOM's business.
    #[test]
    fn the_outdated_flag_lights_every_instance_of_the_part() {
        use crate::panels::parts_library::document_signature;
        use crate::store::MemModelStore;

        brep_render::brep_kernel::clear_history_cache();
        let store = MemModelStore::new();
        let content = part_document();
        store.put("bracket", &content);
        let mut state = EngineState::new();
        state
            .insert_component(ComponentInsert::New {
                name: "bracket",
                source_key: "bracket",
                source_signature: &document_signature(&content),
                document_json: &content,
            })
            .unwrap();
        state
            .insert_component(ComponentInsert::Existing { part_name: "bracket" })
            .unwrap();
        let mut updates = UpdateComponents::new();

        updates.ensure_current(&mut state, &store, 0);
        let rows = snapshot(&mut state, &updates);
        assert!(
            !rows[0].outdated && !rows[1].outdated,
            "nothing is outdated against its own source"
        );

        // The part's SOURCE changes underneath: both instances light.
        let mut edited: Value = serde_json::from_str(&content).unwrap();
        edited["features"][0]["inputParams"]["sizeX"] = serde_json::json!(9.0);
        store.put("bracket", &edited.to_string());
        updates.ensure_current(&mut state, &store, 1);
        let rows = snapshot(&mut state, &updates);
        assert!(
            rows[0].outdated && rows[1].outdated,
            "every instance of the entry lights, not just the first"
        );
    }
}