Skip to main content

brep_render/engine_state/
expressions.rs

1use super::*;
2
3// ============================================================================
4// Expressions / parameters (appended — the expressions/parameters panel slice).
5// A SEPARATE `impl` block so concurrent edits to the primary block do not
6// conflict; additive over the existing history API. The engine-owned `History`
7// carries an `expressions` source that feature params evaluate against (a numeric
8// param may be the string `"boxW"`, resolved by the pipeline's shared expression
9// env). Editing it here re-runs the rolled-to prefix so every var-referencing
10// param rebuilds live.
11// ============================================================================
12impl EngineState {
13    /// The history's `expressions` source string (the variable sheet the panel's
14    /// editor binds to). Raw source text — despite the `_json` suffix it mirrors
15    /// the other engine readouts' naming; the verifier reads it verbatim.
16    pub fn expressions_json(&self) -> String {
17        self.history.expressions()
18    }
19
20    /// Replace the `expressions` source and re-run the rolled-to prefix so every
21    /// feature param referencing a variable (e.g. `sizeX = "boxW"`) rebuilds with
22    /// the new value — the panel's live-update path. Returns the build-report JSON.
23    pub fn set_expressions(&mut self, expressions: &str) -> String {
24        self.history.set_expressions(expressions);
25        self.rerun_history()
26    }
27
28    /// The history's `configurator` object (typed named inputs) as JSON — a
29    /// read/display surface for the panel; deeper configurator editing is deferred.
30    pub fn configurator_json(&self) -> String {
31        self.history.configurator().to_string()
32    }
33
34    /// The parsed variable list for the sheet's name/value view:
35    /// `[{ "name": "...", "expr": "..." }, …]` — one entry per `name = rhs;`
36    /// assignment in the expressions source, in source order. The RHS is shown
37    /// verbatim (its DEFINING expression); evaluating it to a live scalar needs the
38    /// kernel's private `Env`, so a computed value column is deferred — the text
39    /// editor + re-run is the source of truth for applied values.
40    pub fn expression_variables_json(&self) -> String {
41        let vars = parse_expression_variables(&self.history.expressions());
42        serde_json::to_string(&vars).unwrap_or_else(|_| "[]".to_string())
43    }
44}
45
46/// Parse the `name = rhs;` assignments from an expressions source into an ordered
47/// `[{name, expr}]` list for the sheet's variable view. Mirrors the kernel
48/// evaluator's statement grammar (`IDENT '=' expr ';'`) without reaching into its
49/// private `Env`: `//` line comments are stripped, statements split on `;`, and
50/// each `IDENT = rhs` yields one entry (a non-identifier LHS or empty RHS is
51/// skipped, matching what the evaluator would reject).
52fn parse_expression_variables(source: &str) -> Vec<serde_json::Value> {
53    // Strip `//` line comments line-by-line (the DSL has no strings, so a bare
54    // `//` always starts a comment), then split statements on ';'.
55    let mut cleaned = String::with_capacity(source.len());
56    for line in source.lines() {
57        let code = match line.find("//") {
58            Some(idx) => &line[..idx],
59            None => line,
60        };
61        cleaned.push_str(code);
62        cleaned.push('\n');
63    }
64    let mut out = Vec::new();
65    for stmt in cleaned.split(';') {
66        let stmt = stmt.trim();
67        if stmt.is_empty() {
68            continue;
69        }
70        let Some(eq) = stmt.find('=') else {
71            continue;
72        };
73        let name = stmt[..eq].trim();
74        let expr = stmt[eq + 1..].trim();
75        if name.is_empty() || expr.is_empty() || !is_identifier(name) {
76            continue;
77        }
78        out.push(serde_json::json!({ "name": name, "expr": expr }));
79    }
80    out
81}
82
83/// Whether `s` is a single expression-DSL identifier (`[A-Za-z_$][A-Za-z0-9_$]*`)
84/// — the valid LHS of an assignment statement.
85fn is_identifier(s: &str) -> bool {
86    let mut chars = s.chars();
87    match chars.next() {
88        Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$' => {}
89        _ => return false,
90    }
91    chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
92}
93
94#[cfg(test)]
95mod expressions_tests {
96    use super::*;
97
98    /// The X-extent (bbox width) of a solid in the current scene.
99    fn extent_x(engine: &EngineState, name: &str) -> f64 {
100        let s = engine.scene.solid(name).expect("solid present");
101        s.bbox.max[0] - s.bbox.min[0]
102    }
103
104    /// A single-cube history whose `sizeX` is an EXPRESSION STRING referencing the
105    /// variable `boxW` (defined in `expressions`); sizeY/sizeZ are plain numbers.
106    fn var_cube_request(expressions: &str) -> String {
107        serde_json::json!({
108            "expressions": expressions,
109            "configurator": {},
110            "features": [{
111                "type": "P.CU",
112                "inputParams": {
113                    "id": "Var",
114                    "sizeX": "boxW", "sizeY": 20.0, "sizeZ": 20.0,
115                    "transform": {
116                        "position": [0.0, 0.0, 0.0],
117                        "rotationEuler": [0.0, 0.0, 0.0],
118                        "scale": [1.0, 1.0, 1.0]
119                    },
120                    "boolean": { "targets": [], "operation": "NONE" }
121                },
122                "persistentData": {}
123            }]
124        })
125        .to_string()
126    }
127
128    #[test]
129    fn expressions_drive_feature_param_and_resize_live() {
130        let mut engine = EngineState::new();
131        // A cube whose width is the variable `boxW = 30`.
132        engine.set_history_json(&var_cube_request("boxW = 30;")).unwrap();
133        let x0 = extent_x(&engine, "Var");
134        assert!((x0 - 30.0).abs() < 1e-6, "sizeX resolved from boxW=30: {x0}");
135
136        // EDIT the sheet (the panel's path): boxW = 50 → the cube grows LIVE, no
137        // feature-param edit needed (the param still reads `"boxW"`).
138        engine.set_expressions("boxW = 50;");
139        let x1 = extent_x(&engine, "Var");
140        assert!(x1 > x0 + 10.0, "raising boxW resized the cube: {x1} > {x0}");
141        assert!((x1 - 50.0).abs() < 1e-6, "sizeX now resolves to 50: {x1}");
142
143        // The getter reflects the applied source, and the variable list parses it.
144        assert_eq!(engine.expressions_json(), "boxW = 50;");
145        let vars: serde_json::Value =
146            serde_json::from_str(&engine.expression_variables_json()).unwrap();
147        assert_eq!(vars[0]["name"], "boxW");
148        assert_eq!(vars[0]["expr"], "50");
149    }
150
151    #[test]
152    fn parse_variables_handles_comments_and_computed_rhs() {
153        let vars = parse_expression_variables(
154            "boxW = 30; // width\nboxH = boxW / 2;\n; \n7 = bad;",
155        );
156        // Two valid assignments; the empty statement and the numeric LHS are dropped.
157        assert_eq!(vars.len(), 2);
158        assert_eq!(vars[0]["name"], "boxW");
159        assert_eq!(vars[0]["expr"], "30");
160        assert_eq!(vars[1]["name"], "boxH");
161        assert_eq!(vars[1]["expr"], "boxW / 2");
162    }
163}
164