BREP_app 0.1.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
//! Context action toolbar — the **selection-driven** action bar (the engine-
//! native successor to the old app's floating selection action bar,
//! `SelectionFilter._syncSelectionActions` + `_getHistoryContextActionSpecs`).
//!
//! It is shown ONLY while something is selected (hidden otherwise) and its
//! buttons depend on the CURRENT selection (kinds + count read from
//! `selection_json`):
//!
//! * **Generic actions** (mirror the old selection action bar):
//!   - **Clear** — `clear_selection`.
//!   - **Hide** — `hide_selected` (toggles the visibility of EXACTLY what is
//!     selected: a selected face/edge/vertex hides just that sub-entity, a
//!     selected solid the whole solid; a second click shows it again).
//!   - **Edit owning feature** — for a SINGLE selected entity with a known
//!     producer, `creating_feature(name)` resolves the feature that built it;
//!     clicking rolls the model to that step (`roll_to`) and asks the shell to
//!     EXPAND that feature's inline dialog in the history tree.
//! * **Feature-from-selection** — the feature types whose PRIMARY reference
//!   accepts the selected kind. The mapping is DERIVED FROM THE KERNEL SCHEMAS,
//!   not hard-coded: for each feature we take its first top-level
//!   `reference_selection` field (a [`FieldKind::Reference`] in the `References`
//!   group — this excludes primitives, whose only reference is the boolean-op
//!   `targets`), and offer it when that field's `selectionFilter` intersects a
//!   currently-selected kind. So a FACE selected → Extrude / Offset Face / Push
//!   Face / Offset Shell / Thicken / Delete Face / Fillet / Chamfer / Revolve /
//!   Sweep / Path Sweep / Loft; an EDGE → Fillet / Chamfer / Tube; a SOLID →
//!   Boolean / Mirror / Transform / Pattern / Split / Rib. Clicking a feature
//!   action creates it (`add_feature`) with its primary reference PRE-FILLED with
//!   the selected entity name(s) matching the field filter, then asks the shell
//!   to expand the new node for tweaking.
//!
//! Like the other panels this owns NO model state — the selection + history live
//! in [`EngineState`], borrowed in; it only holds the per-frame `hits` map (widget
//! screen rects) + the last-drawn action ids the headed verifier reads.

use super::action_rail::{action_rail, ActionItem};
use crate::form;
use brep_render::engine_state::EngineState;
use brep_render::features;
use brep_render::style::FieldKind;
use eframe::egui;
use serde_json::Value;
use std::collections::HashMap;

/// A request bubbled back to the shell after a context action ran: EXPAND (open
/// the inline dialog of) the feature with this id in the history tree. The
/// context bar mutates the engine directly but cannot reach the history panel's
/// private "expanded" state, so it returns the id for the shell to focus.
pub type FocusRequest = Option<String>;

/// What a context-bar frame hands back to the shell. The bar mutates the engine
/// directly, but two effects it cannot reach itself:
/// * `focus` — the history feature to EXPAND after a create / edit-owning action
///   (the history panel's expand state is private to it); and
/// * `info_targets` — the entity names to open PINNED Info windows for after the
///   Info action (the Info-window manager is shell-owned). One name per selected
///   entity, so a multi-select opens one window each.
#[derive(Default)]
pub struct ContextOutcome {
    pub focus: FocusRequest,
    pub info_targets: Vec<String>,
}

/// The context bar's transient UI state (the model lives in the engine).
#[derive(Default)]
pub struct ContextBarPanel {
    /// Per-frame widget screen rects, published for the headed verifier. Rebuilt
    /// each frame (there is no DOM — egui draws on the canvas).
    hits: HashMap<String, egui::Rect>,
    /// The generic action ids drawn THIS frame (`clear` / `hide` / `edit-owning`)
    /// — published so the verifier can assert WHICH actions the selection offered.
    shown_actions: Vec<String>,
    /// The feature TYPE CODES offered THIS frame (`E`, `F`, `CH`, …).
    shown_features: Vec<String>,
}

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

    /// Draw the context bar as a FLOATING panel over the viewport (nothing when
    /// nothing is selected — like the old app's floating selection action bar).
    /// Drawn at ctx level (not inside the scrollable side panel) so its buttons
    /// are always reachable regardless of side-panel scroll. Returns a
    /// [`ContextOutcome`] — the feature id the shell should expand in the history
    /// tree (after a create-from-selection or edit-owning action) plus any entity
    /// names the shell should open pinned Info windows for (after the Info action).
    pub fn card(&mut self, ui: &mut egui::Ui, state: &mut EngineState) -> ContextOutcome {
        self.hits.clear();
        self.shown_actions.clear();
        self.shown_features.clear();

        // Modeling context actions ONLY. Hidden with no selection, and never
        // during reference-selection (the picker owns the selection) or in sketch
        // mode (the sketch context rail replaces this one). Rendered through the
        // SHARED single-column rail — see [`super::action_rail`] — so it and the
        // sketch context bar stay identical.
        if !state.has_selection() || state.ref_select_active() || state.sketch_mode() {
            return ContextOutcome::default();
        }

        let sel = Selection::read(state);
        let offers = feature_offers(&sel);

        // Build the action items: the generic actions, then feature-from-selection.
        // Info (🕵 U+1F575, the previous app's "Inspector, Metadata & Mass Properties"
        // glyph, from the bundled Noto Sans Symbols 2 font) opens one PINNED Info
        // window per selected entity — unlike the other actions it drives no engine
        // mutation; the shell opens the windows from the returned targets.
        let mut items = vec![
            ActionItem::new("action:clear", "\u{2716} Clear", "Clear the selection"),
            ActionItem::new("action:hide", "\u{1f441} Hide", "Hide/Show selection"),
            ActionItem::new(
                "action:info",
                "\u{1f575} Info",
                "Open a pinned Info window per selected entity",
            ),
        ];
        self.shown_actions.push("clear".into());
        self.shown_actions.push("hide".into());
        self.shown_actions.push("info".into());
        if sel.owning_feature.is_some() {
            items.push(ActionItem::new(
                "action:edit-owning",
                "Edit owning feature",
                "Roll to and edit the feature that created this",
            ));
            self.shown_actions.push("edit-owning".into());
        }
        for offer in &offers {
            items.push(ActionItem::new(
                format!("feature:{}", offer.type_code),
                offer.label.clone(),
                format!("Create {} from the selection", offer.label),
            ));
            self.shown_features.push(offer.type_code.clone());
        }

        let summary = sel.summary();
        let clicked = egui::Frame::popup(ui.style())
            .show(ui, |ui| {
                action_rail(
                    ui,
                    Some("Selection actions"),
                    Some(&summary),
                    &items,
                    &mut self.hits,
                )
            })
            .inner;

        // --- apply the intent (one engine mutation per frame) -----------------
        let mut outcome = ContextOutcome::default();
        match clicked.as_deref() {
            Some("action:clear") => {
                state.clear_selection();
            }
            Some("action:hide") => {
                state.hide_selected();
            }
            Some("action:info") => {
                // No engine mutation — hand the shell one target per selected entity
                // so it opens (or, on dedup, keeps) a pinned Info window for each.
                outcome.info_targets = sel.all_names();
            }
            Some("action:edit-owning") => {
                if let Some(fid) = sel.owning_feature.clone() {
                    if let Some(index) = feature_index(state, &fid) {
                        state.roll_to(index);
                    }
                    outcome.focus = Some(fid);
                }
            }
            Some(key) if key.starts_with("feature:") => {
                let code = &key["feature:".len()..];
                if let Some(offer) = offers.iter().find(|o| o.type_code == code) {
                    outcome.focus = create_feature_from_selection(state, offer, &sel);
                }
            }
            _ => {}
        }
        outcome
    }

    /// The published widget hit-rects (egui points) for the headed verifier —
    /// `action:clear|action:hide|action:edit-owning` + `feature:<TYPE>`.
    #[cfg(target_arch = "wasm32")]
    pub fn hits_json(&self) -> String {
        let map: serde_json::Map<String, Value> = self
            .hits
            .iter()
            .map(|(k, r)| {
                (
                    k.clone(),
                    serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
                )
            })
            .collect();
        Value::Object(map).to_string()
    }

    /// The bar's LOGICAL state for the verifier: whether it is shown + which
    /// generic actions and feature type-codes it offered this frame.
    #[cfg(target_arch = "wasm32")]
    pub fn state_json(&self) -> String {
        serde_json::json!({
            "shown": !self.hits.is_empty(),
            "actions": self.shown_actions,
            "features": self.shown_features,
        })
        .to_string()
    }
}

/// The current selection, resolved once per frame from `selection_json`, plus the
/// single-selection owning feature (for **Edit owning feature**).
struct Selection {
    solids: Vec<String>,
    faces: Vec<String>,
    edges: Vec<String>,
    vertices: usize,
    /// The producer feature id of a SINGLE-entity selection with a known producer.
    owning_feature: Option<String>,
}

impl Selection {
    fn read(state: &EngineState) -> Self {
        let v: Value = serde_json::from_str(&state.selection_json()).unwrap_or(Value::Null);
        let names = |key: &str| -> Vec<String> {
            v[key]
                .as_array()
                .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
                .unwrap_or_default()
        };
        let solids = names("solids");
        let faces = names("faces");
        let edges = names("edges");
        let vertices = v["vertices"].as_u64().unwrap_or(0) as usize;

        // A single selected entity → its owning feature (the old app's
        // Edit-owning-feature, generalized from FACE/PLANE to any single entity).
        let total = solids.len() + faces.len() + edges.len();
        let single = if total == 1 && vertices == 0 {
            faces
                .first()
                .or_else(|| edges.first())
                .or_else(|| solids.first())
                .cloned()
        } else {
            None
        };
        let owning_feature = single
            .as_deref()
            .and_then(|name| state.creating_feature(name))
            .map(|(id, _ty)| id);

        Self {
            solids,
            faces,
            edges,
            vertices,
            owning_feature,
        }
    }

    /// The selectable KINDS currently present (vertices carry no names, and no
    /// primary reference is vertex-only, so they never drive feature actions).
    fn kinds_present(&self) -> Vec<&'static str> {
        let mut kinds = Vec::new();
        if !self.solids.is_empty() {
            kinds.push("SOLID");
        }
        if !self.faces.is_empty() {
            kinds.push("FACE");
        }
        if !self.edges.is_empty() {
            kinds.push("EDGE");
        }
        kinds
    }

    /// The selected names whose kind the reference `filter` accepts (de-duplicated,
    /// in solid→face→edge order). `PLANE` maps to selected faces, `COMPONENT` to
    /// selected solids (the picker never yields a bare component here).
    fn names_for_filter(&self, filter: &[String]) -> Vec<String> {
        let mut out: Vec<String> = Vec::new();
        let push = |src: &[String], out: &mut Vec<String>| {
            for name in src {
                if !out.iter().any(|n| n == name) {
                    out.push(name.clone());
                }
            }
        };
        for f in filter {
            match f.as_str() {
                "SOLID" | "COMPONENT" => push(&self.solids, &mut out),
                "FACE" | "PLANE" => push(&self.faces, &mut out),
                "EDGE" => push(&self.edges, &mut out),
                _ => {}
            }
        }
        out
    }

    /// Every NAMED selected entity (solids → faces → edges), de-duplicated — one per
    /// pinned Info window the Info action opens. Vertices carry no name; datums are
    /// not part of this bar's model (a datum-only selection never shows the context
    /// bar — `has_selection` ignores datums).
    fn all_names(&self) -> Vec<String> {
        let mut out: Vec<String> = Vec::new();
        for src in [&self.solids, &self.faces, &self.edges] {
            for name in src {
                if !name.is_empty() && !out.iter().any(|n| n == name) {
                    out.push(name.clone());
                }
            }
        }
        out
    }

    fn summary(&self) -> String {
        format!(
            "Selected: {} solid, {} face, {} edge, {} vertex",
            self.solids.len(),
            self.faces.len(),
            self.edges.len(),
            self.vertices,
        )
    }
}

/// One offered feature action, resolved from a feature's schema.
struct Offer {
    /// The feature TYPE CODE (e.g. `E`, `F`, `CH`).
    type_code: String,
    /// The button label (the feature's long name).
    label: String,
    /// The JSON path of the feature's PRIMARY reference field to pre-fill.
    ref_path: Vec<String>,
    /// That field's `selectionFilter` (which selected kinds map into it).
    filter: Vec<String>,
    /// Whether that field takes a list (vs a single name).
    multiple: bool,
}

/// The feature actions to offer for `sel`: every registered feature whose PRIMARY
/// reference (its first `References`-group reference field — NOT the boolean-op
/// `targets`, which every primitive carries) accepts a currently-selected kind.
/// The whole mapping is derived from the kernel feature schemas at run time.
fn feature_offers(sel: &Selection) -> Vec<Offer> {
    let kinds = sel.kinds_present();
    if kinds.is_empty() {
        return Vec::new();
    }
    let catalogue = features::feature_catalogue();
    let mut out = Vec::new();
    if let Some(list) = catalogue.get("features").and_then(Value::as_array) {
        for feature in list {
            let Some(ty) = feature.get("type").and_then(Value::as_str) else {
                continue;
            };
            if ty.is_empty() {
                continue;
            }
            // Primary reference = the first Reference field in the `References`
            // group (a top-level `reference_selection` param). Primitives only
            // carry the boolean-op `targets` Reference (group `Boolean`), so they
            // are correctly skipped.
            let fields = features::feature_form_fields(ty);
            let Some(primary) = fields
                .iter()
                .find(|f| matches!(f.kind, FieldKind::Reference { .. }) && f.group == "References")
            else {
                continue;
            };
            let FieldKind::Reference { filter, multiple } = &primary.kind else {
                continue;
            };
            if !filter
                .iter()
                .any(|f| kinds.iter().any(|k| *k == f.as_str()))
            {
                continue;
            }
            out.push(Offer {
                type_code: ty.to_string(),
                label: features::feature_long_name(ty),
                ref_path: primary.path.clone(),
                filter: filter.clone(),
                multiple: *multiple,
            });
        }
    }
    out
}

/// Create a feature of `offer.type_code` referencing the selection: build a fresh
/// descriptor whose `inputParams` are the schema defaults with an engine-unique
/// `id` and the PRIMARY reference pre-filled with the selected names matching its
/// filter, then append it (`add_feature`, which rolls to it). Returns the new
/// feature id (for the shell to expand its node).
fn create_feature_from_selection(
    state: &mut EngineState,
    offer: &Offer,
    sel: &Selection,
) -> Option<String> {
    let id = state.next_feature_id(&features::feature_short_name(&offer.type_code));
    let mut params = features::feature_default_params(&offer.type_code);
    if let Value::Object(map) = &mut params {
        map.insert("id".into(), Value::String(id.clone()));
    }

    let names = sel.names_for_filter(&offer.filter);
    let value = if offer.multiple {
        Value::Array(names.into_iter().map(Value::String).collect())
    } else {
        Value::String(names.into_iter().next().unwrap_or_default())
    };
    form::set_at(&mut params, &offer.ref_path, value);

    let feature = serde_json::json!({
        "type": offer.type_code,
        "inputParams": params,
        "persistentData": {},
    });
    if state.add_feature(&feature.to_string()).is_ok() {
        Some(id)
    } else {
        None
    }
}

/// The feature index carrying id `id` (the engine exposes index→id, so we scan).
fn feature_index(state: &EngineState, id: &str) -> Option<usize> {
    (0..state.history_len()).find(|&i| state.feature_id_at(i).as_deref() == Some(id))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sel_of(solids: &[&str], faces: &[&str], edges: &[&str]) -> Selection {
        Selection {
            solids: solids.iter().map(|s| s.to_string()).collect(),
            faces: faces.iter().map(|s| s.to_string()).collect(),
            edges: edges.iter().map(|s| s.to_string()).collect(),
            vertices: 0,
            owning_feature: None,
        }
    }

    #[test]
    fn face_selection_offers_face_features_not_solid_ones() {
        let offers = feature_offers(&sel_of(&[], &["Box_PZ"], &[]));
        let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
        // Face-primary features are offered…
        for want in ["E", "O.F", "PF", "O.S", "THK", "DF", "F", "CH"] {
            assert!(codes.contains(&want), "FACE should offer {want}: {codes:?}");
        }
        // …and solid-only-primary features are NOT.
        for nope in ["B", "M", "XFORM", "PATTERN", "SPL", "RIB"] {
            assert!(!codes.contains(&nope), "FACE must not offer {nope}: {codes:?}");
        }
        // Primitives (only a boolean `targets` Reference) never appear.
        assert!(!codes.contains(&"P.CU"));
    }

    #[test]
    fn edge_selection_offers_fillet_chamfer_tube() {
        let offers = feature_offers(&sel_of(&[], &[], &["Box_E0"]));
        let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
        for want in ["F", "CH", "TU"] {
            assert!(codes.contains(&want), "EDGE should offer {want}: {codes:?}");
        }
        assert!(!codes.contains(&"E"), "EDGE must not offer Extrude: {codes:?}");
    }

    #[test]
    fn solid_selection_offers_solid_features() {
        let offers = feature_offers(&sel_of(&["Box"], &[], &[]));
        let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
        for want in ["B", "M", "XFORM", "PATTERN", "SPL", "RIB"] {
            assert!(codes.contains(&want), "SOLID should offer {want}: {codes:?}");
        }
        assert!(!codes.contains(&"F"), "SOLID must not offer Fillet: {codes:?}");
    }

    #[test]
    fn empty_selection_offers_nothing() {
        assert!(feature_offers(&sel_of(&[], &[], &[])).is_empty());
    }

    #[test]
    fn extrude_primary_reference_is_single_profile() {
        let offers = feature_offers(&sel_of(&[], &["F1"], &[]));
        let extrude = offers.iter().find(|o| o.type_code == "E").expect("extrude offered");
        assert_eq!(extrude.ref_path, vec!["profile".to_string()]);
        assert!(!extrude.multiple, "extrude profile is a single reference");
        assert!(extrude.filter.iter().any(|f| f == "FACE"));
    }

    #[test]
    fn all_names_gathers_every_named_entity_for_info_windows() {
        // A multi-select of a solid + two faces + an edge → four Info-window targets
        // (solids → faces → edges order, de-duplicated).
        let sel = sel_of(&["Box"], &["Box_PZ", "Box_NZ"], &["Box_E0"]);
        assert_eq!(sel.all_names(), ["Box", "Box_PZ", "Box_NZ", "Box_E0"]);
        // Nothing selected → no windows.
        assert!(sel_of(&[], &[], &[]).all_names().is_empty());
    }

    #[test]
    fn names_for_filter_maps_kinds() {
        let sel = sel_of(&["Box"], &["Box_PZ", "Box_NZ"], &["Box_E0"]);
        assert_eq!(sel.names_for_filter(&["FACE".into()]), ["Box_PZ", "Box_NZ"]);
        assert_eq!(sel.names_for_filter(&["EDGE".into()]), ["Box_E0"]);
        assert_eq!(sel.names_for_filter(&["SOLID".into()]), ["Box"]);
        // A multi-kind filter (fillet's FACE+EDGE) gathers both.
        assert_eq!(
            sel.names_for_filter(&["FACE".into(), "EDGE".into()]),
            ["Box_PZ", "Box_NZ", "Box_E0"]
        );
    }
}