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
//! Toolbar — a top `Panel` strip of primary actions above the viewport:
//! Undo / Redo, a Wireframe toggle, and Zoom-to-fit, plus the File-actions
//! SEAM (owned by the concurrent file panel). Standard views live on the
//! ViewCube navigation gizmo, not here.
//!
//! Follows the panel pattern (a small state struct + a `show(&mut self, ui,
//! state, store)` the shell calls once), but unlike the left-column sections it
//! creates its OWN top panel — so the shell just calls `self.toolbar.show(…)`
//! FIRST in `App::ui` (before the left panel + viewport) to reserve the strip.
//!
//! The MODEL is engine-owned: the toolbar only TRIGGERS engine methods
//! (`state.undo()` / `state.redo()` / `state.zoom_to_fit()`) and drives the
//! wireframe through the existing
//! settings-apply path (`apply_settings_json` → bumps generation + dirty). It
//! owns only the per-frame `hits` map (widget screen rects) the headed verifier
//! reads to drive real clicks, exactly like the history panel.
//!
//! The File buttons don't touch storage here: a click returns a [`FileAction`]
//! from [`ToolbarPanel::show`] and the shell hands it to the reusable
//! [`crate::panels::file::FileDialog`].
//!
//! Buttons use glyphs from the generated `BrepIcons.ttf`, with the text label in
//! the hover tooltip. Color icons stack private-use glyph layers from that same
//! font to produce filled/outlined/shaded artwork (see [`toolbar_button`]); they
//! do not depend on an OS font or bitmap asset. All styling/sizing goes through
//! the shared [`crate::panels::toolbar_button`] helpers (the ONE place
//! toolbar-button style lives), so a change lands globally. Glyph per button:
//!   New       layered U+E010–E012 (doc)
//!   Open      layered U+E013–E015 (folder)
//!   Save      layered U+E016–E019 (disk)
//!   Save As   layered U+E016–E018 + U+E01A (disk + badge)
//!   Import    layered U+E01B/E01C/E01D (tray + down arrow)
//!   Export    layered U+E01B/E01C/E01E (tray + up arrow)
//!   Undo/Redo layered U+E01F–E022 (curved arrows)
//!   Projctn   layered U+E023–E027 (camera)
//!   Wireframe U+1F578 (single glyph)
//!   Fit       U+26F6  (single glyph)
//!   Settings  U+2699  (single glyph)
//!   Docs      U+2139  (single glyph)
//!   Submit Bug U+1F41E, auto-layered via `toolbar_button::color_layers`
//!
//! There is NO Info/Properties button here: entity inspection is opened from the
//! selection-driven CONTEXT bar (see [`crate::panels::context_bar`]), which spawns
//! a pinned per-entity window (see [`crate::panels::info_windows`]).

use crate::panels::file::FileAction;
use crate::panels::toolbar_button;
use crate::store::{ModelStore, SETTINGS_KEY};
use crate::workbench;
use brep_render::engine_state::EngineState;
use eframe::egui;
#[cfg(target_arch = "wasm32")]
use serde_json::Value;
use std::collections::HashMap;

const DOC_FILL: egui::Color32 = egui::Color32::from_rgb(112, 183, 246);
const DOC_INK: egui::Color32 = egui::Color32::from_rgb(28, 83, 132);
const DOC_LIGHT: egui::Color32 = egui::Color32::from_rgb(226, 243, 255);
const FOLDER_BACK: egui::Color32 = egui::Color32::from_rgb(205, 139, 25);
const FOLDER_FRONT: egui::Color32 = egui::Color32::from_rgb(250, 196, 55);
const FOLDER_INK: egui::Color32 = egui::Color32::from_rgb(116, 73, 12);
const DISK_FILL: egui::Color32 = egui::Color32::from_rgb(69, 151, 229);
const DISK_SHADOW: egui::Color32 = egui::Color32::from_rgb(31, 91, 151);
const DISK_LABEL: egui::Color32 = egui::Color32::from_rgb(218, 239, 255);
const DISK_INK: egui::Color32 = egui::Color32::from_rgb(19, 65, 108);
const IMPORT_TRAY: egui::Color32 = egui::Color32::from_rgb(47, 164, 105);
const IMPORT_INK: egui::Color32 = egui::Color32::from_rgb(21, 92, 57);
const IMPORT_ARROW: egui::Color32 = egui::Color32::from_rgb(123, 232, 174);
const EXPORT_TRAY: egui::Color32 = egui::Color32::from_rgb(218, 118, 42);
const EXPORT_INK: egui::Color32 = egui::Color32::from_rgb(126, 61, 17);
const EXPORT_ARROW: egui::Color32 = egui::Color32::from_rgb(255, 187, 105);
const HISTORY_SHADOW: egui::Color32 = egui::Color32::from_rgb(42, 86, 132);
const HISTORY_MAIN: egui::Color32 = egui::Color32::from_rgb(126, 184, 238);
const CAMERA_FILL: egui::Color32 = egui::Color32::from_rgb(93, 116, 145);
const CAMERA_SHADOW: egui::Color32 = egui::Color32::from_rgb(43, 57, 76);
const CAMERA_INK: egui::Color32 = egui::Color32::from_rgb(20, 30, 43);
const CAMERA_GLASS: egui::Color32 = egui::Color32::from_rgb(64, 171, 218);
const CAMERA_GLINT: egui::Color32 = egui::Color32::from_rgb(211, 245, 255);

/// What one frame of the toolbar produced for the shell to act on. Extends the
/// old `Option<FileAction>` return so a clicked WORKBENCH button can flow its id
/// out the SAME way a File button flows a [`FileAction`] — the shell matches on
/// the id. Phase 1 declares no workbench buttons, so `workbench_button` is always
/// `None`, but the return path is wired for Phase 2.
#[derive(Default)]
pub struct ToolbarOutcome {
    /// A File button click (New / Open / Save / …), dispatched to the file dialog.
    pub file: Option<FileAction>,
    /// A workbench toolbar button click, surfaced by its `WorkbenchButton::id`.
    pub workbench_button: Option<&'static str>,
    /// The "Submit Bug" button was clicked this frame — the shell begins the
    /// screenshot-capture + report flow (see [`crate::panels::bug_report`]).
    pub bug_report: bool,
}

/// The toolbar's own state: the per-frame map of egui widget screen rects,
/// published to JS for the headed verifier to drive real clicks. Rebuilt each
/// frame (there is no DOM — egui is drawn on the canvas).
#[derive(Default)]
pub struct ToolbarPanel {
    hits: HashMap<String, egui::Rect>,
}

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

    /// Draw the toolbar as a top panel of primary actions. Called FIRST in the
    /// shell's `App::ui` so the strip reserves the top before the left panel and
    /// the central viewport. Rebuilds `hits` each frame as it draws. Returns the
    /// [`FileAction`] a clicked File button requests (the shell dispatches it to
    /// the file dialog), or `None`.
    ///
    /// `settings_open` is the shell-owned open flag of the floating Settings window
    /// (see [`crate::panels::settings`]): the gear button reflects it (highlighted
    /// while open) and toggles it on click.
    pub fn show(
        &mut self,
        ui: &mut egui::Ui,
        state: &mut EngineState,
        store: &dyn ModelStore,
        settings_open: &mut bool,
    ) -> ToolbarOutcome {
        self.hits.clear();
        let mut outcome = ToolbarOutcome::default();
        egui::containers::panel::Panel::top("brep-toolbar")
            .resizable(false)
            .show(ui, |ui| {
                ui.add_space(3.0);
                ui.horizontal_wrapped(|ui| {
                    // Workbench selector FIRST (top-left). It is a UI FILTER, not a
                    // mode switch — it only trims the feature-creation palette /
                    // offers. Its extra BUTTONS render LAST (after the normal icons).
                    self.workbench_selector(ui, state, store);
                    ui.separator();
                    outcome.file = self.file_actions(ui);
                    ui.separator();
                    self.edit_actions(ui, state);
                    ui.separator();
                    self.view_actions(ui, state, store);
                    ui.separator();
                    self.settings_action(ui, settings_open);
                    self.docs_action(ui);
                    outcome.bug_report = self.bug_action(ui);
                    // The active workbench's extra buttons go at the END of the
                    // toolbar, after the standard icons (a leading separator draws
                    // only when the active workbench actually declares buttons).
                    let current = state.settings.workbench.clone();
                    outcome.workbench_button = self.workbench_buttons_row(ui, &current);
                });
                ui.add_space(3.0);
            });
        outcome
    }

    /// The WORKBENCH dropdown: iterates the per-file registry
    /// ([`workbench::WORKBENCHES`]) so labels/order are never hardcoded, shows the
    /// current selection (`state.settings.workbench`), and on change writes the id
    /// through the SAME apply+save path the wireframe/projection toggles use here
    /// (`apply_settings_json` + `store.write`), so it persists across a reload.
    /// Publishes the header + per-item hit-rects for the headed verifier.
    fn workbench_selector(
        &mut self,
        ui: &mut egui::Ui,
        state: &mut EngineState,
        store: &dyn ModelStore,
    ) {
        let current = state.settings.workbench.clone();
        let options: Vec<(&str, &str)> =
            workbench::WORKBENCHES.iter().map(|w| (w.label, w.id)).collect();
        let result = toolbar_button::select(ui, "workbench", &current, &options);
        self.hits.insert("workbench".into(), result.header_rect);
        for (id, rect) in &result.item_rects {
            self.hits.insert(format!("workbench:item:{id}"), *rect);
        }
        if let Some(next) = result.changed {
            let json = serde_json::json!({ "workbench": next }).to_string();
            let _ = state.apply_settings_json(&json);
            // Persist through the same seam wireframe/projection use so the choice
            // survives a reload and the settings blob agrees.
            let _ = store.write(SETTINGS_KEY, &state.settings_json());
        }
    }

    /// Render the ACTIVE workbench's extra toolbar buttons generically. Phase 1:
    /// every workbench declares no buttons, so this draws nothing and returns
    /// `None`.
    fn workbench_buttons_row(&mut self, ui: &mut egui::Ui, current: &str) -> Option<&'static str> {
        let buttons = workbench::workbench_buttons(current);
        // A leading separator only when there is at least one button, so an
        // empty-button workbench (e.g. Modeling) leaves no dangling separator at
        // the toolbar's end.
        if !buttons.is_empty() {
            ui.separator();
        }
        self.draw_workbench_buttons(ui, &buttons)
    }

    /// Draw an explicit list of workbench buttons via the shared button helper,
    /// publishing each hit-rect and surfacing the clicked button's `id` (the
    /// toolbar RETURN PATH — the shell dispatches on it, exactly like a
    /// [`FileAction`]). Split out from [`Self::workbench_buttons_row`] so the
    /// mechanism can be unit-tested with a synthetic button while the real
    /// registry ships none in Phase 1.
    fn draw_workbench_buttons(
        &mut self,
        ui: &mut egui::Ui,
        buttons: &[&workbench::WorkbenchButton],
    ) -> Option<&'static str> {
        let mut clicked = None;
        for button in buttons {
            let resp = toolbar_button::button(ui, button.glyph, button.tooltip);
            self.hits.insert(format!("workbench:btn:{}", button.id), resp.rect);
            if resp.clicked() {
                clicked = Some(button.id);
            }
        }
        clicked
    }

    /// The Docs button: opens the generated help site (`brep-docs` writes it to
    /// `web/help/` next to the served page) in a new tab. Info glyph (U+2139),
    /// matching the old app's toolbar entry.
    fn docs_action(&mut self, ui: &mut egui::Ui) {
        let btn = toolbar_button::button(ui, "\u{2139}", "Docs");
        self.hits.insert("docs".into(), btn.rect);
        if btn.clicked() {
            ui.ctx().open_url(egui::OpenUrl::new_tab("help/index.html"));
        }
    }

    /// The Submit Bug button: opens the in-app problem-report flow, which first
    /// grabs a screenshot of the app (UI + 3D model) BEFORE its dialog appears,
    /// then collects a description + optional email and posts the report.
    /// `bug_report` Material Symbol (base glyph U+1F41E) — `toolbar_button`
    /// auto-renders it as a layered color icon (fill U+E05A under dark ink).
    /// Returns whether it was clicked.
    fn bug_action(&mut self, ui: &mut egui::Ui) -> bool {
        let btn = toolbar_button::button(ui, "\u{1F41E}", "Submit Bug");
        self.hits.insert("bug".into(), btn.rect);
        btn.clicked()
    }

    /// The Settings toggle: opens / closes the floating Settings window. A
    /// selectable gear glyph (U+2699, bundled DejaVu font) reflecting the live
    /// open state; the label lives in the tooltip, matching the other buttons.
    fn settings_action(&mut self, ui: &mut egui::Ui, open: &mut bool) {
        // Gear (U+2699) — renders in the bundled DejaVu font (no tofu). A toggle
        // reflecting the live open state.
        let btn = toolbar_button::toggle(ui, *open, "\u{2699}", "Settings");
        self.hits.insert("settings".into(), btn.rect);
        if btn.clicked() {
            *open = !*open;
        }
    }

    /// The published widget hit-rects (egui points) for the headed verifier.
    #[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()
    }

    /// File actions (New / Open / Save / Save As) as glyph buttons. Each returns
    /// the matching [`FileAction`] on click; the shell hands it to the file
    /// dialog (which owns all storage). Glyph → label in the tooltip.
    fn file_actions(&mut self, ui: &mut egui::Ui) -> Option<FileAction> {
        let mut action = None;
        // New — page (U+1F4C4, the previous-app glyph).
        let new = toolbar_button::layered_button(
            ui,
            &[
                ("\u{E010}", DOC_FILL),
                ("\u{E011}", DOC_INK),
                ("\u{E012}", DOC_LIGHT),
            ],
            "New",
        );
        self.hits.insert("file:new".into(), new.rect);
        if new.clicked() {
            action = Some(FileAction::New);
        }
        // Open — open folder (U+1F5C1). Kept: the previous app had no Open.
        let open = toolbar_button::layered_button(
            ui,
            &[
                ("\u{E013}", FOLDER_BACK),
                ("\u{E015}", FOLDER_INK),
                ("\u{E014}", FOLDER_FRONT),
            ],
            "Open",
        );
        self.hits.insert("file:open".into(), open.rect);
        if open.clicked() {
            action = Some(FileAction::Open);
        }
        // Save — floppy disk (U+1F4BE, the previous-app glyph).
        let save = toolbar_button::layered_button(
            ui,
            &[
                ("\u{E016}", DISK_FILL),
                ("\u{E017}", DISK_SHADOW),
                ("\u{E018}", DISK_LABEL),
                ("\u{E019}", DISK_INK),
            ],
            "Save",
        );
        self.hits.insert("file:save".into(), save.rect);
        if save.clicked() {
            action = Some(FileAction::Save);
        }
        // Save As has a dedicated custom-font glyph so its plus badge shares
        // the disk's weight, alignment, and square advance.
        let save_as = toolbar_button::layered_button(
            ui,
            &[
                ("\u{E016}", DISK_FILL),
                ("\u{E017}", DISK_SHADOW),
                ("\u{E018}", DISK_LABEL),
                ("\u{E01A}", DISK_INK),
            ],
            "Save As",
        );
        self.hits.insert("file:saveas".into(), save_as.rect);
        if save_as.clicked() {
            action = Some(FileAction::SaveAs);
        }
        ui.separator();
        // Import — neutral CAD files use their native readers; meshes run through
        // RANSAC reconstruction before being appended as an IMPORT3D feature.
        let import = toolbar_button::layered_button(
            ui,
            &[
                ("\u{E01B}", IMPORT_TRAY),
                ("\u{E01C}", IMPORT_INK),
                ("\u{E01D}", IMPORT_ARROW),
            ],
            "Import STEP / IGES / STL / OBJ\u{2026}",
        );
        self.hits.insert("file:import".into(), import.rect);
        if import.clicked() {
            action = Some(FileAction::Import);
        }
        // Export — outbox tray (U+1F4E4): write the model OUT as STEP / IGES / STL.
        let export = toolbar_button::layered_button(
            ui,
            &[
                ("\u{E01B}", EXPORT_TRAY),
                ("\u{E01C}", EXPORT_INK),
                ("\u{E01E}", EXPORT_ARROW),
            ],
            "Export\u{2026} (STEP / IGES / STL)",
        );
        self.hits.insert("file:export".into(), export.rect);
        if export.clicked() {
            action = Some(FileAction::Export);
        }
        action
    }

    /// Undo / Redo — trigger the engine-owned undo history. Buttons enable only
    /// when a step is available so the affordance reflects the real stack. Glyph
    /// only; the label lives in the tooltip.
    ///
    /// While a sketch is open these SAME buttons drive the per-session SKETCH
    /// history instead of the model-level undo — the sketch has no undo/redo of
    /// its own; it shares this toolbar pair (matching the Ctrl+Z / Ctrl+Shift+Z
    /// keyboard router in `app.rs`).
    fn edit_actions(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
        let sketch = state.sketch_mode();
        let can_undo = if sketch { state.sketch_can_undo() } else { state.can_undo() };
        let undo = toolbar_button::layered_button_enabled(
            ui,
            can_undo,
            &[
                ("\u{E01F}", HISTORY_SHADOW),
                ("\u{E020}", HISTORY_MAIN),
            ],
            if sketch { "Undo sketch edit (Ctrl+Z)" } else { "Undo" },
        );
        self.hits.insert("undo".into(), undo.rect);
        if undo.clicked() {
            if sketch {
                state.sketch_undo();
            } else {
                state.undo();
            }
        }
        let can_redo = if sketch { state.sketch_can_redo() } else { state.can_redo() };
        let redo = toolbar_button::layered_button_enabled(
            ui,
            can_redo,
            &[
                ("\u{E021}", HISTORY_SHADOW),
                ("\u{E022}", HISTORY_MAIN),
            ],
            if sketch { "Redo sketch edit (Ctrl+Y)" } else { "Redo" },
        );
        self.hits.insert("redo".into(), redo.rect);
        if redo.clicked() {
            if sketch {
                state.sketch_redo();
            } else {
                state.redo();
            }
        }
    }

    /// View actions: the Wireframe toggle (drives `settings.wireframe` through the
    /// settings-apply path + persists it like the settings panel), the Projection
    /// toggle (orthographic ↔ perspective via `set_projection`), Zoom-to-fit, and
    /// quick standard-view buttons.
    fn view_actions(&mut self, ui: &mut egui::Ui, state: &mut EngineState, store: &dyn ModelStore) {
        // Wireframe: reflect the LIVE engine value so the toggle is always honest,
        // and flip it via the same apply-path the settings panel uses (bumps
        // settings_generation + dirty so the GPU re-derives styles).
        let wire = state.settings.wireframe;
        // Spider web (U+1F578, Symbols 2) — the previous-app wireframe glyph.
        let wf = toolbar_button::toggle(ui, wire, "\u{1F578}", "Wireframe");
        self.hits.insert("wireframe".into(), wf.rect);
        if wf.clicked() {
            let next = !wire;
            let _ = state.apply_settings_json(&format!("{{\"wireframe\": {next}}}"));
            // Persist the full settings through the same seam the settings panel
            // uses, so the toggle survives a reload and both views agree.
            let _ = store.write(SETTINGS_KEY, &state.settings_json());
        }

        // Projection: reflect the LIVE camera mode — the toggle is highlighted while
        // in perspective. Flip it through the SAME settings-apply path wireframe uses
        // (`apply_settings_json` reads `orthographic` and drives the camera), then
        // persist the full settings — so, like wireframe, the projection is now a
        // real setting that survives a reload and agrees with the settings panel.
        let is_persp =
            matches!(state.camera.projection, brep_render::view::Projection::Perspective { .. });
        // Layered still camera from BrepIcons.ttf — the ortho ↔ perspective
        // toggle. The tooltip names the CURRENT mode.
        let proj_tip = if is_persp {
            "Perspective projection"
        } else {
            "Orthographic projection"
        };
        let proj = toolbar_button::layered_toggle(
            ui,
            is_persp,
            &[
                ("\u{E023}", CAMERA_FILL),
                ("\u{E024}", CAMERA_SHADOW),
                ("\u{E025}", CAMERA_INK),
                ("\u{E026}", CAMERA_GLASS),
                ("\u{E027}", CAMERA_GLINT),
            ],
            proj_tip,
        );
        self.hits.insert("projection".into(), proj.rect);
        if proj.clicked() {
            let want_ortho = is_persp; // currently perspective → switch to orthographic
            let _ = state.apply_settings_json(&format!("{{\"orthographic\": {want_ortho}}}"));
            let _ = store.write(SETTINGS_KEY, &state.settings_json());
        }

        // Square-with-four-corners (U+26F6) — the previous-app zoom-to-fit glyph.
        let fit = toolbar_button::button(ui, "\u{26F6}", "Zoom to fit");
        self.hits.insert("fit".into(), fit.rect);
        if fit.clicked() {
            state.zoom_to_fit();
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use brep_render::engine_state::EngineState;
    use brep_render::view::Projection;

    /// A no-op `ModelStore`: `view_actions` only touches the store on a wireframe
    /// click, and this headless draw injects no pointer input, so nothing is
    /// clicked and the store is never read/written.
    struct NullStore;
    impl ModelStore for NullStore {
        fn read(&self, _key: &str) -> Option<String> {
            None
        }
        fn write(&self, _key: &str, _val: &str) -> Result<(), String> {
            Ok(())
        }
    }

    fn is_perspective(state: &EngineState) -> bool {
        matches!(state.camera.projection, Projection::Perspective { .. })
    }

    /// The new projection toggle must (a) publish a `projection` hit-rect each
    /// frame (the headed verifier drives it by that rect) and (b) flip the camera
    /// mode through the exact `set_projection` strings the button passes — which
    /// marks the view dirty so the viewport re-renders.
    #[test]
    fn projection_toggle_publishes_hit_and_flips_mode() {
        let mut panel = ToolbarPanel::new();
        let mut state = EngineState::new();
        let store = NullStore;

        // The seed camera is orthographic (view.rs `Default`).
        assert!(!is_perspective(&state), "seed camera should be orthographic");

        // Draw one headless egui frame of the view actions; layout records the
        // per-frame hit-rects (no pointer input → nothing is clicked).
        let ctx = egui::Context::default();
        let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
            panel.view_actions(ui, &mut state, &store);
        });
        assert!(
            panel.hits.contains_key("projection"),
            "toolbar must publish a `projection` hit-rect, got {:?}",
            panel.hits.keys().collect::<Vec<_>>()
        );

        // The toggle path round-trips the camera projection and marks it dirty.
        state.dirty = false;
        state.set_projection("perspective");
        assert!(is_perspective(&state), "set_projection(perspective) → perspective");
        assert!(state.dirty, "a projection flip must mark the view dirty for a redraw");
        state.set_projection("orthographic");
        assert!(!is_perspective(&state), "set_projection(orthographic) → orthographic");
    }

    /// A synthetic workbench button (test-only, since Phase 1 ships no real
    /// buttons): drawing it publishes a hit-rect, and a real press+release click
    /// surfaces its `id` through the toolbar return path.
    #[test]
    fn workbench_button_click_surfaces_its_id() {
        let button = workbench::WorkbenchButton {
            id: "test:flat",
            glyph: "\u{2699}",
            tooltip: "Flat pattern (test)",
        };
        let buttons: Vec<&workbench::WorkbenchButton> = vec![&button];
        let ctx = egui::Context::default();

        let draw = |panel: &mut ToolbarPanel, events: Vec<egui::Event>| -> Option<&'static str> {
            let raw = egui::RawInput {
                screen_rect: Some(egui::Rect::from_min_size(
                    egui::pos2(0.0, 0.0),
                    egui::vec2(400.0, 120.0),
                )),
                events,
                ..Default::default()
            };
            let mut out = None;
            let _ = ctx.run_ui(raw, |ui| {
                out = panel.draw_workbench_buttons(ui, &buttons);
            });
            out
        };

        // Frame 1: lay out, capture the button rect, no click yet.
        let mut panel = ToolbarPanel::new();
        assert!(draw(&mut panel, vec![]).is_none(), "a passive render is not a click");
        let rect = *panel
            .hits
            .get("workbench:btn:test:flat")
            .expect("the button publishes a hit-rect");
        let pos = rect.center();

        // Frame 2: press.
        draw(
            &mut panel,
            vec![
                egui::Event::PointerMoved(pos),
                egui::Event::PointerButton {
                    pos,
                    button: egui::PointerButton::Primary,
                    pressed: true,
                    modifiers: egui::Modifiers::default(),
                },
            ],
        );
        // Frame 3: release → the click fires and the id flows out the return path.
        let clicked = draw(
            &mut panel,
            vec![egui::Event::PointerButton {
                pos,
                button: egui::PointerButton::Primary,
                pressed: false,
                modifiers: egui::Modifiers::default(),
            }],
        );
        assert_eq!(clicked, Some("test:flat"), "the clicked button's id surfaces via the return path");
    }

    /// Sanity: the Sheet Metal workbench now declares the real flat-pattern
    /// button (Phase 2), but a PASSIVE render (no pointer input) is not a click,
    /// so the row draws it and returns `None`.
    #[test]
    fn real_workbench_button_passive_render_no_click() {
        let mut panel = ToolbarPanel::new();
        let ctx = egui::Context::default();
        let mut clicked = Some("sentinel");
        let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
            clicked = panel.workbench_buttons_row(ui, "sheetMetal");
        });
        assert_eq!(clicked, None, "a passive render is not a click");
        // The button was nonetheless laid out and published its hit-rect.
        assert!(panel.hits.contains_key("workbench:btn:sheetmetal.flat_pattern"));
    }
}