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
//! Expressions / parameters panel — the **variable sheet** that drives feature
//! params. The engine-owned history document carries an `expressions` source
//! string (a small `name = expr;` DSL) plus a `configurator` object; the pipeline
//! evaluates it when running features, so a numeric feature field may be the
//! expression string `"boxW"` — resolved against the variables defined here.
//!
//! Following the panel pattern, this owns NO model state: it edits + reads the
//! engine (`state.expressions_json()` / `state.set_expressions()` /
//! `state.expression_variables_json()` / `state.configurator_json()`), which write
//! into the single-source-of-truth `EngineState.history` and re-run the rolled-to
//! prefix so var-referencing params update live. The panel holds only a transient
//! editor buffer (mirrored from the engine when unfocused, committed on
//! Apply / focus-loss) and the per-frame widget hit-rects the headed verifier reads.

use brep_render::engine_state::EngineState;
use eframe::egui;
use serde_json::Value;
#[cfg(target_arch = "wasm32")]
use std::collections::HashMap;

/// The expressions panel's transient UI state (the model lives in the engine).
pub struct ExpressionsPanel {
    /// The multiline editor buffer. Mirrored from the engine's `expressions`
    /// whenever the editor is UNFOCUSED (so an Open / undo / redo that changed the
    /// document reflects here), and committed BACK to the engine on Apply or when
    /// the editor loses focus.
    buf: String,
    /// Per-frame widget hit-rects, published to JS for the headed verifier (wasm).
    #[cfg(target_arch = "wasm32")]
    hits: HashMap<String, egui::Rect>,
}

impl Default for ExpressionsPanel {
    fn default() -> Self {
        Self::new()
    }
}

impl ExpressionsPanel {
    pub fn new() -> Self {
        Self {
            buf: String::new(),
            #[cfg(target_arch = "wasm32")]
            hits: HashMap::new(),
        }
    }

    /// Draw the panel under its own collapsing header (matches the sibling panels).
    pub fn show(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
        #[cfg(target_arch = "wasm32")]
        self.hits.clear();

        egui::CollapsingHeader::new("Expressions / parameters")
            .id_salt("expressions-panel")
            .default_open(true)
            .show(ui, |ui| self.body(ui, state));
    }

    fn body(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
        ui.label("Variable sheet — feature params may reference these (e.g. a size field set to `boxW`).");

        // --- the editor, bound to the engine-owned `expressions` source --------
        // JavaScript syntax highlighting via egui's BUILT-IN facility
        // (`egui_extras::syntax_highlighting`, syntect/fancy-regex backend). A
        // `layouter` re-lays every frame: `highlight(...)` returns a colored
        // `LayoutJob` for the `"js"` grammar (resolved by extension), and the
        // theme is read from egui memory so it tracks the light/dark visuals.
        // Results are memoized inside `highlight`, so this is cheap per-frame.
        let mut layouter = |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {
            let theme =
                egui_extras::syntax_highlighting::CodeTheme::from_memory(ui.ctx(), ui.style());
            let mut job = egui_extras::syntax_highlighting::highlight(
                ui.ctx(),
                ui.style(),
                &theme,
                buf.as_str(),
                "js",
            );
            job.wrap.max_width = wrap_width;
            ui.fonts_mut(|f| f.layout_job(job))
        };
        let editor = ui.add(
            egui::TextEdit::multiline(&mut self.buf)
                .id_salt("expressions-editor")
                .code_editor()
                .desired_rows(5)
                .desired_width(f32::INFINITY)
                .hint_text("boxW = 30;\nboxH = boxW / 2;")
                .layouter(&mut layouter),
        );
        #[cfg(target_arch = "wasm32")]
        self.hits.insert("expr:editor".into(), editor.rect);

        // --- commit: Apply button OR focus-loss; re-run only when text changed --
        let apply = ui.button("Apply (re-run history)");
        #[cfg(target_arch = "wasm32")]
        self.hits.insert("expr:apply".into(), apply.rect);

        let commit = editor.lost_focus() || apply.clicked();
        if commit {
            // Re-run only on a real change (each commit re-runs the rolled-to
            // prefix); a no-op commit must not thrash the kernel.
            if self.buf != state.expressions_json() {
                let _ = state.set_expressions(&self.buf);
            }
        } else if !editor.has_focus() {
            // Not editing → mirror the engine (an Open / New / undo / redo may have
            // replaced the document out from under the buffer).
            self.buf = state.expressions_json();
        }

        // --- parsed variable list (name = defining expression) -----------------
        ui.separator();
        ui.label(egui::RichText::new("Variables").strong());
        let vars: Vec<Value> =
            serde_json::from_str(&state.expression_variables_json()).unwrap_or_default();
        if vars.is_empty() {
            ui.weak("(no variables defined)");
        } else {
            egui::Grid::new("expr-vars")
                .num_columns(2)
                .striped(true)
                .show(ui, |ui| {
                    for v in &vars {
                        let name = v.get("name").and_then(Value::as_str).unwrap_or("");
                        let expr = v.get("expr").and_then(Value::as_str).unwrap_or("");
                        ui.monospace(name);
                        ui.monospace(format!("= {expr}"));
                        ui.end_row();
                    }
                });
        }

        // --- configurator (typed named inputs) — read/display; editing deferred -
        ui.separator();
        ui.collapsing("Configurator (read-only)", |ui| {
            let cfg = state.configurator_json();
            let pretty = serde_json::from_str::<Value>(&cfg)
                .and_then(|v| serde_json::to_string_pretty(&v))
                .unwrap_or(cfg);
            ui.monospace(pretty);
            ui.weak("Typed named inputs — a deeper configurator editor is deferred.");
        });
    }

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

#[cfg(test)]
mod tests {
    use eframe::egui;

    /// The editor's `layouter` runs egui's BUILT-IN highlighter over the `"js"`
    /// grammar. This asserts that facility actually COLORS JavaScript: a keyword,
    /// a numeric literal, and a line comment each land in a section with a
    /// DISTINCT color (i.e. real highlighting, not one flat color). It also
    /// guards that the syntect (fancy-regex) backend is wired — the non-syntect
    /// fallback does not recognise `"js"`, so it would leave everything one color
    /// and fail here.
    #[test]
    fn js_highlighter_colors_keyword_number_comment_distinctly() {
        let ctx = egui::Context::default();
        let style = egui::Style::default();
        let theme = egui_extras::syntax_highlighting::CodeTheme::dark(12.0);
        let src = "var boxW = 30; // size";
        let job = egui_extras::syntax_highlighting::highlight(&ctx, &style, &theme, src, "js");

        // Color of the section covering a given byte offset.
        let color_at = |byte: usize| -> egui::Color32 {
            job.sections
                .iter()
                .find(|s| byte >= s.byte_range.start.0 && byte < s.byte_range.end.0)
                .map(|s| s.format.color)
                .expect("some section covers this byte")
        };
        let kw = color_at(src.find("var").unwrap()); // keyword
        let num = color_at(src.find("30").unwrap()); // numeric literal
        let com = color_at(src.find("//").unwrap()); // line comment

        assert_ne!(kw, num, "keyword and number share a color — not highlighted");
        assert_ne!(kw, com, "keyword and comment share a color — not highlighted");
        assert_ne!(num, com, "number and comment share a color — not highlighted");

        // And the job carries several distinct token colors overall.
        let distinct: std::collections::HashSet<[u8; 4]> = job
            .sections
            .iter()
            .map(|s| s.format.color.to_array())
            .collect();
        assert!(
            distinct.len() >= 3,
            "expected >=3 distinct token colors, got {}",
            distinct.len()
        );
    }
}