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
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
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
//! 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` → a full-width activation button with the current selection
//!     listed BENEATH it, one line per entity, each with an `✕` to remove it.
//!     Pressing the button is surfaced to the caller by the field key (the same
//!     `clicked` out-param a `Button` uses) — entering the picker needs the
//!     engine, and this file deliberately has none.
//!
//! # Width, and who decides it
//!
//! An input fills the width it is given in a TOP-DOWN layout (the label-above
//! form view: [`crate::form_view`]) and stays COMPACT in a RIGHT-TO-LEFT row
//! (the tree/settings idiom, where the label is the tree node and the input sits
//! at the panel edge). `ui.layout().prefer_right_to_left()` is the one
//! discriminator — the same one `Vec3` has always used to order its components —
//! so neither caller passes a layout flag and neither can drift from the other.
//!
//! 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 red of a reference line's remove `✕` — the destructive red the history
/// tree's delete affordance uses (theme-independent on purpose: it must read as
/// "removes something" in both light and dark).
const REMOVE_RED: egui::Color32 = egui::Color32::from_rgb(0xd8, 0x54, 0x4f);

/// The width an input should take: whatever the caller has left in a TOP-DOWN
/// form (label above, full-width field), or `compact` in a RIGHT-TO-LEFT tree
/// row, where the input shares the row with its label.
fn input_width(ui: &egui::Ui, compact: f32) -> f32 {
    if ui.layout().prefer_right_to_left() {
        compact
    } else {
        ui.available_width()
    }
}

/// Emit ONLY the input widget(s) for one field — NO leading label, NO forced row
/// layout (the caller supplies both: the form view puts the label ABOVE and gives
/// the input the full width; the settings tree puts the 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>"`) and a reference field's activation + per-line-remove
/// rects (`"<path>#activate"`, `"<path>#x<index>"`) for the headed verifier.
///
/// `clicked` is the out-param for the two kinds that are ACTIONS rather than
/// values: a `Button` press and a `Reference`'s `Select` press both report the
/// field key, because acting on either needs the engine and this file has none.
pub fn field_input(
    ui: &mut egui::Ui,
    field: &FormField,
    current: &mut Value,
    mut probe: Option<&mut HashMap<String, egui::Rect>>,
    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 mut combo = egui::ComboBox::from_id_salt(("form-enum", field.key()));
            if !ui.layout().prefer_right_to_left() {
                combo = combo.width(ui.available_width());
            }
            let combo = combo
                .selected_text(&sel)
                .show_ui(ui, |ui| {
                    for v in variants {
                        let item = ui.selectable_value(&mut sel, v.to_string(), v.as_str());
                        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(input_width(ui, 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();
            let width = input_width(ui, ui.spacing().text_edit_width);
            if *read_only {
                let r = ui.add_enabled(
                    false,
                    egui::TextEdit::singleline(&mut s).desired_width(width),
                );
                (false, r.rect)
            } else {
                let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(width));
                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;
            if ui.layout().prefer_right_to_left() {
                // 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 so they still READ x, y, z.
                for &i in &[2usize, 1, 0] {
                    let r = ui.add(egui::DragValue::new(&mut v[i]).speed(*step));
                    edited |= r.changed();
                    rect = rect.union(r.rect);
                }
            } else {
                // Label-above form: ONE row of three EQUAL-width drag values under
                // the single label, reading x, y, z. `ui.horizontal` is this arm's
                // own — the caller's layout is vertical, so without it the three
                // components would stack.
                let gap = ui.spacing().item_spacing.x;
                let each = ((ui.available_width() - 2.0 * gap) / 3.0).max(24.0);
                let height = ui.spacing().interact_size.y;
                ui.horizontal(|ui| {
                    for value in v.iter_mut() {
                        let r = ui.add_sized(
                            [each, height],
                            egui::DragValue::new(value).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).
            //
            // FULL WIDTH, like the reference field's `Select` below: an action
            // button is the primary thing to do in its section (Edit Sketch IS the
            // sketch form), and a content-width button floating at the left of a
            // full-width form reads as a minor control rather than the main one.
            let r = ui.add_sized(
                [ui.available_width(), ui.spacing().interact_size.y],
                egui::Button::new(label.as_str()),
            );
            if r.clicked() {
                *clicked = Some(field.key().to_string());
            }
            (false, r.rect)
        }
        FieldKind::Reference { filter, multiple } => {
            // R4 — the reference widget: a full-width activation button with the
            // chosen entities listed BENEATH it, one per line, each with an `✕`
            // to remove it. Nothing expands: the selection is what the user needs
            // to see, so it is never hidden behind a `[+]`.
            //
            // Pressing `Select` reports the field key through `clicked` (exactly
            // as a `Button` does) — entering the modal picker is the ENGINE's job
            // and the caller owns which flavour of picker to enter. Removing a
            // line, by contrast, is a pure edit of `current`, so it happens here
            // and reports `changed` like any other field.
            let names = reference_names(value_at(current, path));
            let pkey = path.join(".");
            let mut removed: Option<usize> = None;
            let mut activate_rect = egui::Rect::NOTHING;
            ui.vertical(|ui| {
                let width = ui.available_width();
                let hint = format!(
                    "▣ Select {}{}",
                    filter.join("/"),
                    if *multiple { "" } else { "" }
                );
                let select = crate::icon_text::icon_button(ui, &hint);
                let button = ui.add_sized(
                    [width, ui.spacing().interact_size.y],
                    select,
                );
                activate_rect = button.rect;
                if button.clicked() {
                    *clicked = Some(field.key().to_string());
                }
                if let Some(map) = probe.as_deref_mut() {
                    map.insert(format!("{pkey}#activate"), button.rect);
                }
                if names.is_empty() {
                    ui.label(egui::RichText::new("(none)").weak());
                    return;
                }
                for (i, name) in names.iter().enumerate() {
                    ui.horizontal(|ui| {
                        ui.label(format!("{name}"));
                        ui.with_layout(
                            egui::Layout::right_to_left(egui::Align::Center),
                            |ui| {
                                let remove = crate::icon_text::icon_button_colored(
                                    ui,
                                    "",
                                    Some(REMOVE_RED),
                                )
                                .stroke(egui::Stroke::new(1.0, REMOVE_RED))
                                .small();
                                let x = ui.add(remove);
                                if let Some(map) = probe.as_deref_mut() {
                                    map.insert(format!("{pkey}#x{i}"), x.rect);
                                }
                                if x.clicked() {
                                    removed = Some(i);
                                }
                            },
                        );
                    });
                }
            });
            if let Some(i) = removed {
                let mut kept = names;
                kept.remove(i);
                let value = if *multiple {
                    Value::Array(kept.into_iter().map(Value::String).collect())
                } else {
                    Value::String(kept.first().cloned().unwrap_or_default())
                };
                set_at(current, path, value);
                return (true, activate_rect);
            }
            (false, activate_rect)
        }
    }
}

// --- 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 = `+`), rounded to an
/// integer notch count. Read from the raw `MouseWheel` events (not
/// `smooth_scroll_delta`) so one physical notch is one discrete step. Browser
/// backends report a conventional wheel detent as roughly 100 pixels (Chromium)
/// or 3 lines (Firefox), whereas native winit reports it as 40 points or 1 line;
/// keep those platform scales separate so the web field doesn't jump by 2–3
/// schema steps for the same wheel movement. The caller gates this on the field
/// being focused.
fn wheel_notches(ui: &egui::Ui) -> f64 {
    let raw: f32 = ui.input(|i| {
        i.events
            .iter()
            .filter_map(|event| match event {
                egui::Event::MouseWheel { unit, delta, .. } => Some(wheel_delta_to_notches(
                    *unit,
                    delta.y,
                    cfg!(target_arch = "wasm32"),
                )),
                _ => None,
            })
            .sum()
    });
    (raw as f64).round()
}

fn wheel_delta_to_notches(unit: egui::MouseWheelUnit, delta_y: f32, web: bool) -> f32 {
    let (lines_per_notch, points_per_notch) = if web { (3.0, 100.0) } else { (1.0, 40.0) };
    match unit {
        egui::MouseWheelUnit::Line => delta_y / lines_per_notch,
        egui::MouseWheelUnit::Point => delta_y / points_per_notch,
        egui::MouseWheelUnit::Page => delta_y * 20.0,
    }
}

/// 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 `reference_selection` field bound to `path`, accepting many entities.
    fn reference_field() -> FormField {
        FormField {
            path: vec!["targets".into()],
            label: "Tool solids".into(),
            group: "Boolean".into(),
            kind: FieldKind::Reference {
                filter: vec!["solid".into()],
                multiple: true,
            },
        }
    }

    /// Draw ONE frame of `field` over `current`, feeding `events`, and return
    /// `(changed, clicked, probe)` — the widget rects a verifier would read.
    fn run_field_frame(
        ctx: &egui::Context,
        field: &FormField,
        current: &mut Value,
        events: Vec<egui::Event>,
    ) -> (bool, Option<String>, HashMap<String, egui::Rect>) {
        let raw = egui::RawInput {
            screen_rect: Some(egui::Rect::from_min_size(
                egui::pos2(0.0, 0.0),
                egui::vec2(320.0, 400.0),
            )),
            events,
            ..Default::default()
        };
        let mut probe = HashMap::new();
        let mut clicked = None;
        let mut changed = false;
        let _ = ctx.run_ui(raw, |ui| {
            let (ch, _) = field_input(ui, field, current, Some(&mut probe), &mut clicked);
            changed = ch;
        });
        (changed, clicked, probe)
    }

    /// A click at `pos`, as the two events egui needs (press then release in the
    /// same frame is enough for a `Sense::click` widget).
    fn click_at(pos: egui::Pos2) -> Vec<egui::Event> {
        vec![
            egui::Event::PointerMoved(pos),
            egui::Event::PointerButton {
                pos,
                button: egui::PointerButton::Primary,
                pressed: true,
                modifiers: egui::Modifiers::default(),
            },
            egui::Event::PointerButton {
                pos,
                button: egui::PointerButton::Primary,
                pressed: false,
                modifiers: egui::Modifiers::default(),
            },
        ]
    }

    /// R4: the reference widget lists its chosen entities BENEATH the activation
    /// button, with no collapse box to open — the selection is never hidden. The
    /// per-line remove rects exist on the FIRST frame, which is what says the
    /// list is not behind a `[+]`.
    #[test]
    fn reference_widget_lists_its_selection_without_expanding() {
        let ctx = egui::Context::default();
        let field = reference_field();
        let mut current = json!({ "targets": ["Cut1_solid", "Cut2_solid"] });
        let (changed, clicked, probe) = run_field_frame(&ctx, &field, &mut current, vec![]);

        assert!(!changed, "a passive render changes nothing");
        assert_eq!(clicked, None);
        assert!(probe.contains_key("targets#activate"), "the Select button: {probe:?}");
        assert!(probe.contains_key("targets#x0"), "line 0's remove ✕: {probe:?}");
        assert!(probe.contains_key("targets#x1"), "line 1's remove ✕: {probe:?}");
        assert!(
            !probe.keys().any(|k| k.ends_with("#box")),
            "the collapse box is gone — nothing expands: {probe:?}"
        );
    }

    /// Pressing `Select` is an ACTION, not an edit: it reports the field key
    /// through `clicked` (the caller owns which picker to enter) and leaves the
    /// stored selection alone.
    #[test]
    fn reference_select_reports_the_field_key_and_edits_nothing() {
        let ctx = egui::Context::default();
        let field = reference_field();
        let mut current = json!({ "targets": ["Cut1_solid"] });
        let (_, _, probe) = run_field_frame(&ctx, &field, &mut current, vec![]);
        let at = probe["targets#activate"].center();

        let (changed, clicked, _) = run_field_frame(&ctx, &field, &mut current, click_at(at));
        assert_eq!(clicked.as_deref(), Some("targets"), "activation by field key");
        assert!(!changed, "activating the picker is not itself an edit");
        assert_eq!(current["targets"], json!(["Cut1_solid"]), "selection untouched");
    }

    /// Removing a line IS an edit — it rewrites the value in place and reports
    /// `changed`, so the caller commits and the engine re-runs. A `multiple`
    /// field stays an Array.
    #[test]
    fn reference_remove_rewrites_the_value_and_reports_changed() {
        let ctx = egui::Context::default();
        let field = reference_field();
        let mut current = json!({ "targets": ["Cut1_solid", "Cut2_solid"] });
        let (_, _, probe) = run_field_frame(&ctx, &field, &mut current, vec![]);
        let at = probe["targets#x0"].center();

        let (changed, _, _) = run_field_frame(&ctx, &field, &mut current, click_at(at));
        assert!(changed, "removing a reference is a live edit");
        assert_eq!(current["targets"], json!(["Cut2_solid"]));
    }

    /// A SINGLE-valued reference stays a String when its one line is removed —
    /// the kernel reads `targetSolid` as a name, not a one-element array.
    #[test]
    fn removing_the_only_single_reference_leaves_an_empty_string() {
        let ctx = egui::Context::default();
        let mut field = reference_field();
        field.path = vec!["targetSolid".into()];
        field.kind = FieldKind::Reference {
            filter: vec!["solid".into()],
            multiple: false,
        };
        let mut current = json!({ "targetSolid": "Base_solid" });
        let (_, _, probe) = run_field_frame(&ctx, &field, &mut current, vec![]);
        let at = probe["targetSolid#x0"].center();

        let (changed, _, _) = run_field_frame(&ctx, &field, &mut current, click_at(at));
        assert!(changed);
        assert_eq!(current["targetSolid"], json!(""));
    }

    /// An EMPTY reference still shows its activation button (and says so), so a
    /// field with nothing picked is not an invisible field.
    #[test]
    fn an_empty_reference_still_offers_its_select_button() {
        let ctx = egui::Context::default();
        let field = reference_field();
        let mut current = json!({});
        let (_, _, probe) = run_field_frame(&ctx, &field, &mut current, vec![]);
        assert!(probe.contains_key("targets#activate"));
        assert!(!probe.contains_key("targets#x0"), "nothing to remove: {probe:?}");
    }

    /// THE WIDTH CONTRACT (R5): an input FILLS a top-down form and stays COMPACT
    /// in a right-to-left tree row — one `field_input`, two idioms, no flag.
    #[test]
    fn a_scalar_fills_a_form_row_and_stays_compact_in_a_tree_row() {
        let ctx = egui::Context::default();
        let field = scalar_field();
        let mut current = json!({ "distance": 20.0 });
        let raw = || egui::RawInput {
            screen_rect: Some(egui::Rect::from_min_size(
                egui::pos2(0.0, 0.0),
                egui::vec2(320.0, 400.0),
            )),
            ..Default::default()
        };

        let mut wide = egui::Rect::NOTHING;
        let _ = ctx.run_ui(raw(), |ui| {
            let (_, r) = field_input(ui, &field, &mut current, None, &mut None);
            wide = r;
        });
        let mut narrow = egui::Rect::NOTHING;
        let _ = ctx.run_ui(raw(), |ui| {
            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                let (_, r) = field_input(ui, &field, &mut current, None, &mut None);
                narrow = r;
            });
        });
        assert!(narrow.width() <= 80.0, "tree row stays compact: {}", narrow.width());
        assert!(
            wide.width() > narrow.width() * 2.0,
            "the form gets a full-width field: {} vs {}",
            wide.width(),
            narrow.width()
        );
    }

    /// 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);
    }

    #[test]
    fn browser_and_desktop_wheel_detents_are_one_notch() {
        use egui::MouseWheelUnit::{Line, Point};

        assert_eq!(wheel_delta_to_notches(Point, 100.0, true), 1.0);
        assert_eq!(wheel_delta_to_notches(Line, 3.0, true), 1.0);
        assert_eq!(wheel_delta_to_notches(Point, 40.0, false), 1.0);
        assert_eq!(wheel_delta_to_notches(Line, 1.0, false), 1.0);
    }

    // --- 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, &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"
        );
    }
}