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
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
//! The generic, schema-driven egui form engine — ONE per-field renderer for BOTH
//! the display-settings dialog AND the schema-driven feature dialogs (the user's
//! principle: the structure of a thing drives the UI; you don't rewrite UI code
//! per field, and you don't write two dialog engines).
//!
//! [`field_input`] emits ONLY the input widget(s) for one [`FormField`] — no
//! leading label, no forced row layout — so the CALLER owns the placement. Both
//! side trees drive it DIRECTLY: the history feature tree (`panels::history`) and
//! the settings tree (`panels::settings`) each render a field as a `tree` LEAF
//! whose node label is the field label and whose right-aligned content is this
//! input. The widget per [`FieldKind`]:
//!   * `Color`   → `color_edit_button_srgb`
//!   * `Bool`    → checkbox
//!   * `Enum`    → combo box
//!   * `Number`/`Range` → slider (bounded settings)
//!   * `Scalar`  → an EXPRESSION-CAPABLE single-line field (unbounded feature
//!     numbers): the user types a plain number OR a variable name / inline
//!     equation (`width * 2`) that the kernel evaluates against the history's
//!     `expressions` sheet; while focused, the mouse wheel steps a pure number.
//!   * `Text`    → single-line edit (disabled for a read-only `id`)
//!   * `Vec3`    → three drag values (transform position/rotation/scale)
//!   * `Button`  → an action button; a click is surfaced to the caller by the
//!     field key (via the `clicked` out-param) — e.g. `editSketch` opens the
//!     engine-native sketcher.
//!   * `Reference` → delegated to a [`ReferenceRenderer`] (the engine-native
//!     picker) when the caller supplies one, else an inert placeholder.
//!
//! A field binds to a `path` (a chain of JSON object keys), so it can read/write
//! a NESTED value (`["transform","position"]`, `["boolean","operation"]`), not
//! just a top-level key. On any change it writes back into `current` (the JSON
//! document being edited — settings JSON, or a feature's `inputParams`) and
//! returns `true`; the caller re-applies / re-runs. There is deliberately NO
//! per-field code, so this file is reusable verbatim by a later `brep-ui` crate.

use brep_render::style::{parse_css_hex, FieldKind, FormField};
use eframe::egui;
use serde_json::Value;
use std::collections::HashMap;

/// The engine-agnostic seam for a REAL reference-selection widget. [`field_input`]
/// stays free of any engine dependency (a later `brep-ui` crate reuses it
/// verbatim); a caller that CAN pick — the feature dialog, which holds
/// `&mut EngineState` — passes a `&mut dyn ReferenceRenderer` and `field_input`
/// delegates each [`FieldKind::Reference`] field to it. When no renderer is
/// supplied (the settings panel, which has no reference fields) it falls back to
/// the inert placeholder.
pub trait ReferenceRenderer {
    /// Draw the COLLAPSED reference field — an activation button plus the current
    /// selection listed one-per-line, each line with an X to remove it. Returns
    /// `(changed, activation_button_rect)`: `changed` is true when a name was
    /// removed (so the caller commits the edited `current` back to the engine).
    /// `probe`, when present, receives the activation + per-line-X widget rects
    /// (keyed `"<path>#activate"`, `"<path>#x<index>"`) for the headed verifier.
    fn render_reference(
        &mut self,
        ui: &mut egui::Ui,
        field: &FormField,
        filter: &[String],
        multiple: bool,
        current: &mut Value,
        probe: Option<&mut HashMap<String, egui::Rect>>,
    ) -> (bool, egui::Rect);
}

/// Emit ONLY the input widget(s) for one field — NO leading label, NO forced row
/// layout (the caller supplies the layout — the history + settings trees place
/// the field label in the tree node and this input in the row's right-aligned
/// content). Writes any change back into `current` at the field's
/// `path`, returns `(changed, interactive_widget_rect)`. `probe`, when present,
/// additionally receives each OPEN enum item's rect (keyed `"<path>#<variant>"`)
/// for the headed verifier. A `Reference` delegates to `ref_renderer` (the
/// engine-native picker) when one is supplied — it draws its own label + Select +
/// per-line-X list — else an inert placeholder.
pub fn field_input(
    ui: &mut egui::Ui,
    field: &FormField,
    current: &mut Value,
    mut probe: Option<&mut HashMap<String, egui::Rect>>,
    ref_renderer: Option<&mut dyn ReferenceRenderer>,
    clicked: &mut Option<String>,
) -> (bool, egui::Rect) {
    let path = &field.path;
    match &field.kind {
        FieldKind::Color => {
            let mut rgb = read_rgb(value_at(current, path));
            let r = ui.color_edit_button_srgb(&mut rgb);
            if r.changed() {
                set_at(current, path, Value::String(rgb_to_hex(rgb)));
            }
            (r.changed(), r.rect)
        }
        FieldKind::Bool => {
            let mut b = value_at(current, path).and_then(Value::as_bool).unwrap_or(false);
            let r = ui.checkbox(&mut b, "");
            if r.changed() {
                set_at(current, path, Value::Bool(b));
            }
            (r.changed(), r.rect)
        }
        FieldKind::Enum { variants } => {
            let orig = value_at(current, path)
                .and_then(Value::as_str)
                .unwrap_or("")
                .to_string();
            let mut sel = orig.clone();
            let combo = egui::ComboBox::from_id_salt(("form-enum", field.key()))
                .selected_text(&sel)
                .show_ui(ui, |ui| {
                    for v in variants {
                        let item = ui.selectable_value(&mut sel, (*v).to_string(), *v);
                        if let Some(map) = probe.as_deref_mut() {
                            map.insert(format!("{}#{}", path.join("."), v), item.rect);
                        }
                    }
                });
            let changed = sel != orig;
            if changed {
                set_at(current, path, Value::String(sel));
            }
            (changed, combo.response.rect)
        }
        FieldKind::Number { min, max, step } | FieldKind::Range { min, max, step } => {
            let mut v = value_at(current, path).and_then(Value::as_f64).unwrap_or(*min);
            let r = ui.add(egui::Slider::new(&mut v, *min..=*max).step_by(*step));
            if r.changed() {
                set_at(current, path, serde_json::json!(v));
            }
            (r.changed(), r.rect)
        }
        FieldKind::Scalar { step } => {
            // An EXPRESSION-CAPABLE numeric field. It shows a stored NUMBER as text
            // and a stored EXPRESSION (`width * 2`) VERBATIM — never clobbering a
            // string param to `0` the way the old number-only `DragValue` did — and
            // lets the user type either. A transient edit buffer lives in egui
            // memory (keyed per widget location) and is committed on focus-loss
            // (Enter / click-away), mirroring `panels::expressions`, so a half-typed
            // expression (`width *`) never re-runs the history mid-keystroke. While
            // the field is focused, the mouse wheel STEPS a pure number by `step`
            // (recovering the old drag-value stepping); a scroll notch is a
            // complete, valid edit, so it commits immediately.
            let buf_id = ui.make_persistent_id(("scalar-edit", path.join(".")));
            let stored_text = scalar_display(value_at(current, path));
            // Seed from the live buffer while editing; otherwise from the stored
            // value (an undo / gizmo edit may have changed it out from under us).
            let mut buf = ui
                .data_mut(|d| d.get_temp::<String>(buf_id))
                .unwrap_or_else(|| stored_text.clone());

            let r = ui.add(
                egui::TextEdit::singleline(&mut buf)
                    .id(buf_id)
                    .desired_width(72.0),
            );

            let mut changed = false;
            if r.gained_focus() {
                // Start each edit from the current stored value.
                buf = stored_text.clone();
            }
            // Scroll-to-step a PURE number by `step` (wheel up = +step) while the
            // field is focused AND the pointer is over it ("scroll over the field
            // to step it"). A non-numeric expression is un-steppable
            // (`scroll_step_scalar` → `None`) and left untouched — typing still works.
            // Step ONLY when the cursor is actually over this field, so a focused
            // field doesn't swallow panel scrolling while the user scrolls
            // elsewhere to navigate (that would silently edit the value). Two
            // independent "pointer is over me" signals for robustness — a real
            // wheel carries the cursor position on native/desktop; only synthetic
            // (headless-test) wheels lack it.
            let pointer_over_field = r.hovered()
                || ui
                    .input(|i| i.pointer.latest_pos())
                    .is_some_and(|pos| r.rect.contains(pos));
            if r.has_focus() && pointer_over_field {
                let notches = wheel_notches(ui);
                // EAT the wheel so the enclosing side-panel `ScrollArea` can't ALSO
                // scroll the panel — the field owns the scroll while the cursor is
                // over it. Do this EVERY frame the cursor is here, not only on the
                // notch frame: egui SMOOTHS a wheel notch across several frames, and
                // only the first carries a `MouseWheel` event, so zeroing just that
                // frame let the smoothed TAIL leak into the panel (visible on the
                // desktop build; the web scroll wasn't smoothed so it looked fine).
                // On non-scroll frames these are harmless no-ops. The ScrollArea
                // reads the smoothed delta in its epilogue (after this content), so
                // zeroing here blocks it; we also drop the wheel events.
                ui.input_mut(|i| {
                    i.smooth_scroll_delta = egui::Vec2::ZERO;
                    i.events
                        .retain(|e| !matches!(e, egui::Event::MouseWheel { .. }));
                });
                if notches != 0.0 {
                    if let Some(stepped) = scroll_step_scalar(&buf, notches, *step) {
                        buf = stepped;
                        set_at(current, path, scalar_store(&buf));
                        changed = true;
                    }
                }
            }
            if r.lost_focus() {
                // Commit on Enter / click-away. Skip an EMPTY buffer (a `String("")`
                // is a guaranteed kernel eval error) and a NO-OP (compare by the
                // DISPLAYED text so a whole-float `20.0` vs a typed `20` — same
                // display — doesn't re-run the history for nothing).
                let trimmed = buf.trim();
                if !trimmed.is_empty() && trimmed != stored_text {
                    set_at(current, path, scalar_store(&buf));
                    changed = true;
                }
                ui.data_mut(|d| d.remove::<String>(buf_id));
            } else if r.has_focus() {
                // Keep the in-progress buffer (incl. any scroll step) across frames.
                ui.data_mut(|d| d.insert_temp(buf_id, buf.clone()));
            } else {
                // Unfocused and not committing: drop any transient buffer so the
                // next edit reseeds from the (possibly externally changed) value.
                ui.data_mut(|d| d.remove::<String>(buf_id));
            }
            (changed, r.rect)
        }
        FieldKind::Text { read_only } => {
            let mut s = value_at(current, path)
                .and_then(Value::as_str)
                .unwrap_or("")
                .to_string();
            if *read_only {
                let r = ui.add_enabled(false, egui::TextEdit::singleline(&mut s));
                (false, r.rect)
            } else {
                let r = ui.add(egui::TextEdit::singleline(&mut s));
                if r.changed() {
                    set_at(current, path, Value::String(s));
                }
                (r.changed(), r.rect)
            }
        }
        FieldKind::Vec3 { step } => {
            let mut v = read_vec3(value_at(current, path));
            let mut edited = false;
            let mut rect = egui::Rect::NOTHING;
            // In a RIGHT-TO-LEFT row (the right-aligned tree / settings content) egui
            // lays widgets from the right, which would show the components as z,y,x.
            // Add them in reverse there so they still READ x, y, z left-to-right.
            let order: [usize; 3] = if ui.layout().prefer_right_to_left() {
                [2, 1, 0]
            } else {
                [0, 1, 2]
            };
            for &i in &order {
                let r = ui.add(egui::DragValue::new(&mut v[i]).speed(*step));
                edited |= r.changed();
                rect = rect.union(r.rect);
            }
            if edited {
                set_at(current, path, serde_json::json!([v[0], v[1], v[2]]));
            }
            (edited, rect)
        }
        FieldKind::Button { label } => {
            // An action button binds to no value; a click is surfaced via `clicked`
            // (set to the field key) so the host — the history tree, which holds
            // `&mut EngineState` — can act on it (e.g. `editSketch` → sketch mode).
            let r = ui.add(egui::Button::new(label.as_str()));
            if r.clicked() {
                *clicked = Some(field.key().to_string());
            }
            (false, r.rect)
        }
        FieldKind::Reference { filter, multiple } => {
            // The real engine-native picker draws here when a renderer is wired
            // (the feature dialog / history tree, which hold `&mut EngineState`);
            // the settings panel — which never has reference fields — passes
            // `None` and gets the inert placeholder below.
            if let Some(renderer) = ref_renderer {
                renderer.render_reference(ui, field, filter, *multiple, current, probe)
            } else {
                ui.vertical(|ui| {
                    ui.label(&field.label);
                    let hint = format!(
                        "▣ selection ({}{})",
                        filter.join("/"),
                        if *multiple { ", multiple" } else { "" }
                    );
                    let r = ui.add_enabled(false, egui::Button::new(hint));
                    for name in reference_names(value_at(current, path)) {
                        ui.add_enabled(false, egui::Label::new(format!("{name}")));
                    }
                    (false, r.rect)
                })
                .inner
            }
        }
    }
}

// --- nested JSON read / write ------------------------------------------------

/// Resolve `path` (object keys) to a value inside `root`, if present.
pub(crate) fn value_at<'a>(root: &'a Value, path: &[String]) -> Option<&'a Value> {
    let mut cur = root;
    for seg in path {
        cur = cur.get(seg.as_str())?;
    }
    Some(cur)
}

/// Write `new_val` into `root` at `path`, auto-vivifying intermediate objects
/// (so a feature whose `inputParams` omits `transform` still accepts an edit).
pub(crate) fn set_at(root: &mut Value, path: &[String], new_val: Value) {
    if path.is_empty() {
        *root = new_val;
        return;
    }
    if !root.is_object() {
        *root = Value::Object(serde_json::Map::new());
    }
    let mut cur = root;
    for seg in &path[..path.len() - 1] {
        let obj = cur.as_object_mut().expect("object by construction");
        cur = obj
            .entry(seg.clone())
            .or_insert_with(|| Value::Object(serde_json::Map::new()));
        if !cur.is_object() {
            *cur = Value::Object(serde_json::Map::new());
        }
    }
    cur.as_object_mut()
        .expect("object by construction")
        .insert(path[path.len() - 1].clone(), new_val);
}

// --- Scalar (expression-capable feature number) helpers ----------------------

/// The text to SHOW for a [`FieldKind::Scalar`] field: a stored NUMBER as its
/// shortest decimal string (`20`, not `20.0`; `20.5` as `20.5` — no precision
/// loss), a stored EXPRESSION string VERBATIM (`width * 2`, never clobbered to
/// `0`), and a missing / other value as empty.
fn scalar_display(value: Option<&Value>) -> String {
    match value {
        Some(Value::String(s)) => s.clone(),
        // Rust's `f64` Display is the shortest round-tripping form and omits a
        // trailing `.0`, so an integer OR whole-float number both show as `20`.
        Some(Value::Number(n)) => n.as_f64().map(|f| f.to_string()).unwrap_or_default(),
        _ => String::new(),
    }
}

/// Turn committed field text into the stored param [`Value`]: a PURE numeric
/// literal becomes a JSON `Number` (clean serialization + the kernel's fast
/// `as_f64` path), anything else becomes a `Value::String` the kernel evaluates
/// against the history `expressions` sheet (`ctx.number` at
/// `feature_pipeline/mod.rs`: a `String` param is `env.eval`'d). Numeric-ness is
/// decided by a strict JSON number parse, so `10.` / `1e` and other half-typed
/// forms stay strings rather than round-tripping through a reformat.
fn scalar_store(text: &str) -> Value {
    let trimmed = text.trim();
    match serde_json::from_str::<Value>(trimmed) {
        Ok(v @ Value::Number(_)) => v,
        _ => Value::String(trimmed.to_string()),
    }
}

/// Apply `notches` mouse-wheel steps of size `step` to a Scalar field's text.
/// Only a PURE numeric literal steps (wheel up = `+step`); a non-numeric
/// expression (`width * 2`) is un-steppable and yields `None` (a no-op — the
/// caller leaves the text alone so typing keeps working).
fn scroll_step_scalar(text: &str, notches: f64, step: f64) -> Option<String> {
    let base: f64 = text.trim().parse().ok()?;
    Some(format_scalar_number(base + notches * step, step))
}

/// Format a stepped number to the STEP's decimal precision so repeated steps do
/// not accumulate binary-float noise (`0.1` steps stay `10.1`, `10.2`, … not
/// `10.299999`). Trailing zeros / dot are trimmed (`10.50 → 10.5`, `9.0 → 9`).
fn format_scalar_number(v: f64, step: f64) -> String {
    let decimals = step_decimals(step);
    let mut s = format!("{:.*}", decimals, v);
    if s.contains('.') {
        while s.ends_with('0') {
            s.pop();
        }
        if s.ends_with('.') {
            s.pop();
        }
    }
    s
}

/// Decimal places implied by `step` (`0.5 → 1`, `0.1 → 1`, `0.01 → 2`, `1 → 0`),
/// capped so a pathological step can't ask for absurd precision.
fn step_decimals(step: f64) -> usize {
    let step = step.abs();
    if step == 0.0 || !step.is_finite() {
        return 3;
    }
    let mut d = 0usize;
    let mut s = step;
    while (s - s.round()).abs() > 1e-9 && d < 6 {
        s *= 10.0;
        d += 1;
    }
    d
}

/// Whole mouse-wheel notches scrolled this frame (wheel up = `+`), normalized the
/// SAME way the viewport's `raw_wheel_delta_y` does (Line = 1 notch, Point ÷ 40,
/// Page × 20) and rounded to an integer notch count. Read from the raw
/// `MouseWheel` events (not `smooth_scroll_delta`) so one physical notch is one
/// discrete step. The caller gates this on the field being focused.
fn wheel_notches(ui: &egui::Ui) -> f64 {
    const LINE_POINTS: f32 = 40.0;
    let raw: f32 = ui.input(|i| {
        i.events
            .iter()
            .filter_map(|event| match event {
                egui::Event::MouseWheel { unit, delta, .. } => Some(match unit {
                    egui::MouseWheelUnit::Line => delta.y,
                    egui::MouseWheelUnit::Point => delta.y / LINE_POINTS,
                    egui::MouseWheelUnit::Page => delta.y * 20.0,
                }),
                _ => None,
            })
            .sum()
    });
    (raw as f64).round()
}

/// Read a 3-element numeric array (missing entries → 0).
fn read_vec3(value: Option<&Value>) -> [f64; 3] {
    let mut out = [0.0; 3];
    if let Some(Value::Array(items)) = value {
        for (i, slot) in out.iter_mut().enumerate() {
            if let Some(n) = items.get(i).and_then(Value::as_f64) {
                *slot = n;
            }
        }
    }
    out
}

/// Names currently held by a reference field: an array of strings/`{name}`
/// objects, or a single string.
pub(crate) fn reference_names(value: Option<&Value>) -> Vec<String> {
    match value {
        Some(Value::Array(items)) => items.iter().filter_map(name_of).collect(),
        Some(other) => name_of(other).into_iter().collect(),
        None => Vec::new(),
    }
}

fn name_of(value: &Value) -> Option<String> {
    let raw = match value {
        Value::String(s) => Some(s.as_str()),
        Value::Object(map) => map.get("name").and_then(Value::as_str),
        _ => None,
    }?;
    let t = raw.trim();
    (!t.is_empty()).then(|| t.to_string())
}

/// Read a `#rrggbb` value as `[u8; 3]` for `color_edit_button_srgb`.
fn read_rgb(value: Option<&Value>) -> [u8; 3] {
    let hex = value.and_then(Value::as_str).unwrap_or("#000000");
    let rgb = parse_css_hex(hex).unwrap_or([0.0, 0.0, 0.0]);
    [
        (rgb[0] * 255.0).round() as u8,
        (rgb[1] * 255.0).round() as u8,
        (rgb[2] * 255.0).round() as u8,
    ]
}

fn rgb_to_hex(rgb: [u8; 3]) -> String {
    format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])
}

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

    /// A stored EXPRESSION string must render VERBATIM — the regression this fix
    /// targets was `as_f64().unwrap_or(0.0)` silently showing `0` for a string
    /// param and then clobbering it on the next edit.
    #[test]
    fn scalar_display_shows_expression_verbatim_not_zero() {
        assert_eq!(scalar_display(Some(&json!("width * 2"))), "width * 2");
        assert_eq!(scalar_display(Some(&json!("boxW"))), "boxW");
        assert_eq!(scalar_display(Some(&json!("10 + 5"))), "10 + 5");
    }

    /// A stored NUMBER renders as its shortest decimal string — an integer, a
    /// whole float, and a fractional value all round-trip cleanly (no `20.0`).
    #[test]
    fn scalar_display_shows_number_shortest() {
        assert_eq!(scalar_display(Some(&json!(20))), "20");
        assert_eq!(scalar_display(Some(&json!(20.0))), "20");
        assert_eq!(scalar_display(Some(&json!(20.5))), "20.5");
        assert_eq!(scalar_display(Some(&json!(0.1))), "0.1");
        assert_eq!(scalar_display(None), "");
        // A non-number / non-string (shouldn't occur) is shown empty, not panicked.
        assert_eq!(scalar_display(Some(&json!(true))), "");
    }

    /// The storage contract: a pure numeric literal is stored as a JSON `Number`
    /// (the kernel's fast path); anything else — a variable name or inline math —
    /// is stored as a `Value::String` the kernel evaluates against `expressions`.
    #[test]
    fn scalar_store_number_vs_string_contract() {
        assert_eq!(scalar_store("10"), json!(10));
        assert_eq!(scalar_store("  20  "), json!(20));
        assert_eq!(scalar_store("10.5"), json!(10.5));
        assert!(scalar_store("10").is_number());
        assert_eq!(scalar_store("width * 2"), json!("width * 2"));
        assert_eq!(scalar_store("10 + 5"), json!("10 + 5"));
        assert_eq!(scalar_store("boxW"), json!("boxW"));
        // Trimmed on the string path too, so whitespace doesn't leak into the DSL.
        assert_eq!(scalar_store("  width * 2  "), json!("width * 2"));
    }

    /// Store → display round-trips: an expression stays itself, a number shows its
    /// shortest form.
    #[test]
    fn scalar_store_display_round_trip() {
        assert_eq!(scalar_display(Some(&scalar_store("width * 2"))), "width * 2");
        assert_eq!(scalar_display(Some(&scalar_store("10"))), "10");
        assert_eq!(scalar_display(Some(&scalar_store("10.5"))), "10.5");
    }

    /// Scroll-stepping: a pure number steps by ±`step` (scaled by notch count) and
    /// stays free of float noise; an expression is untouched (`None`).
    #[test]
    fn scroll_step_numeric_and_expression() {
        // wheel up = +step, wheel down = -step, scaled by notches.
        assert_eq!(scroll_step_scalar("10", 1.0, 0.5).as_deref(), Some("10.5"));
        assert_eq!(scroll_step_scalar("10", -2.0, 0.5).as_deref(), Some("9"));
        assert_eq!(scroll_step_scalar("10.5", 1.0, 0.5).as_deref(), Some("11"));
        // An expression can't be stepped — leave it alone.
        assert_eq!(scroll_step_scalar("width * 2", 1.0, 0.5), None);
        assert_eq!(scroll_step_scalar("10 + 5", 3.0, 0.5), None);
        assert_eq!(scroll_step_scalar("", 1.0, 0.5), None);
    }

    /// A `0.1` step must not accumulate binary-float noise: `10 + 3*0.1` is
    /// `10.299999…` in `f64`, but the field shows `10.3`.
    #[test]
    fn scroll_step_kills_float_noise() {
        assert_eq!(scroll_step_scalar("10", 3.0, 0.1).as_deref(), Some("10.3"));
        assert_eq!(scroll_step_scalar("0", 7.0, 0.1).as_deref(), Some("0.7"));
        // A unit step keeps integers integral.
        assert_eq!(scroll_step_scalar("5", 2.0, 1.0).as_deref(), Some("7"));
    }

    #[test]
    fn step_decimals_matches_step_precision() {
        assert_eq!(step_decimals(1.0), 0);
        assert_eq!(step_decimals(0.5), 1);
        assert_eq!(step_decimals(0.1), 1);
        assert_eq!(step_decimals(0.01), 2);
    }

    // --- end-to-end through a real (headless) egui frame ---------------------

    use eframe::egui;

    fn scalar_field() -> FormField {
        FormField {
            path: vec!["distance".to_string()],
            label: "Distance".to_string(),
            group: "Parameters".to_string(),
            kind: FieldKind::Scalar { step: 0.5 },
        }
    }

    /// Run ONE headless egui frame that renders the Scalar `field` bound to
    /// `current`, feeding `events` as this frame's input. Returns `(changed, rect)`.
    fn run_scalar_frame(
        ctx: &egui::Context,
        field: &FormField,
        current: &mut Value,
        events: Vec<egui::Event>,
    ) -> (bool, egui::Rect) {
        let raw = egui::RawInput {
            screen_rect: Some(egui::Rect::from_min_size(
                egui::pos2(0.0, 0.0),
                egui::vec2(400.0, 300.0),
            )),
            events,
            ..Default::default()
        };
        let mut out = (false, egui::Rect::NOTHING);
        let _ = ctx.run_ui(raw, |ui| {
            let (ch, r) = field_input(ui, field, current, None, None, &mut None);
            out = (ch, r);
        });
        out
    }

    /// Merely RENDERING a Scalar bound to an expression string must NOT mutate it —
    /// the regression was the number-only widget reading `None` for a string param
    /// and writing `0` back. A passive frame reports no change and leaves the
    /// expression intact.
    #[test]
    fn scalar_field_render_does_not_clobber_string_param() {
        let ctx = egui::Context::default();
        let field = scalar_field();
        let mut current = serde_json::json!({ "distance": "width * 2" });
        let (changed, _) = run_scalar_frame(&ctx, &field, &mut current, vec![]);
        assert!(!changed, "passive render must not report a change");
        assert_eq!(
            current["distance"],
            serde_json::json!("width * 2"),
            "the expression must survive a render untouched (not clobbered to 0)"
        );
        // A numeric param is likewise untouched by a passive render.
        let mut num = serde_json::json!({ "distance": 20.0 });
        let (changed, _) = run_scalar_frame(&ctx, &field, &mut num, vec![]);
        assert!(!changed);
        assert_eq!(num["distance"], serde_json::json!(20.0));
    }

    /// Drive the full widget: click to focus an (empty) Scalar field, type `50`,
    /// press Enter — the committed value reaches `current` as a usable JSON number,
    /// through the real egui TextEdit + the commit-on-focus-loss path.
    #[test]
    fn scalar_field_typed_number_commits_on_enter() {
        let ctx = egui::Context::default();
        let field = scalar_field();
        let mut current = serde_json::json!({});

        // Frame 1: lay out, capture the field rect.
        let (_, rect) = run_scalar_frame(&ctx, &field, &mut current, vec![]);
        let pos = rect.center();

        // Frame 2/3: click (press then release) to focus the TextEdit.
        run_scalar_frame(
            &ctx,
            &field,
            &mut current,
            vec![
                egui::Event::PointerMoved(pos),
                egui::Event::PointerButton {
                    pos,
                    button: egui::PointerButton::Primary,
                    pressed: true,
                    modifiers: egui::Modifiers::default(),
                },
            ],
        );
        run_scalar_frame(
            &ctx,
            &field,
            &mut current,
            vec![egui::Event::PointerButton {
                pos,
                button: egui::PointerButton::Primary,
                pressed: false,
                modifiers: egui::Modifiers::default(),
            }],
        );

        // Frame 4: type "50" into the focused field.
        run_scalar_frame(
            &ctx,
            &field,
            &mut current,
            vec![egui::Event::Text("50".to_string())],
        );

        // Frame 5: Enter commits (singleline surrenders focus → lost_focus).
        let (changed, _) = run_scalar_frame(
            &ctx,
            &field,
            &mut current,
            vec![egui::Event::Key {
                key: egui::Key::Enter,
                physical_key: None,
                pressed: true,
                repeat: false,
                modifiers: egui::Modifiers::default(),
            }],
        );

        assert!(changed, "committing a typed number must report a change");
        assert_eq!(
            current["distance"],
            serde_json::json!(50),
            "a typed plain number must be stored as a usable JSON number"
        );
        assert!(
            current["distance"].is_number(),
            "a pure numeric literal is stored as a Number, not a String"
        );
    }
}