Add math expression evaluation to NumberInput boxes (#1472)

* Add math expression parsing to NumberInput boxes

* Prevent NaN results

* Add support for implicit multiplication in expressions
This commit is contained in:
Keavon Chambers 2023-11-25 14:37:54 -08:00 committed by GitHub
parent ab3410cffe
commit 2515620a77
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 116 additions and 16 deletions

41
Cargo.lock generated
View file

@ -2286,6 +2286,7 @@ dependencies = [
"graphite-editor",
"js-sys",
"log",
"meval",
"ron",
"serde",
"serde-wasm-bindgen",
@ -3101,9 +3102,9 @@ dependencies = [
[[package]]
name = "memchr"
version = "2.5.0"
version = "2.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167"
[[package]]
name = "memmap2"
@ -3165,6 +3166,16 @@ dependencies = [
"paste",
]
[[package]]
name = "meval"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f79496a5651c8d57cd033c5add8ca7ee4e3d5f7587a4777484640d9cb60392d9"
dependencies = [
"fnv",
"nom 1.2.4",
]
[[package]]
name = "mime"
version = "0.3.17"
@ -3401,6 +3412,12 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "nom"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5b8c256fd9471521bcb84c3cdba98921497f1a331cbc15b8030fc63b82050ce"
[[package]]
name = "nom"
version = "7.1.3"
@ -4340,14 +4357,14 @@ dependencies = [
[[package]]
name = "regex"
version = "1.9.3"
version = "1.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81bc1d4caf89fac26a70747fe603c130093b53c773888797a6329091246d651a"
checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata 0.3.6",
"regex-syntax 0.7.4",
"regex-automata 0.4.3",
"regex-syntax 0.8.2",
]
[[package]]
@ -4361,13 +4378,13 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.3.6"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fed1ceff11a1dddaee50c9dc8e4938bd106e9d89ae372f192311e7da498e3b69"
checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax 0.7.4",
"regex-syntax 0.8.2",
]
[[package]]
@ -4378,9 +4395,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
[[package]]
name = "regex-syntax"
version = "0.7.4"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2"
checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f"
[[package]]
name = "remain"
@ -7466,7 +7483,7 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "463705a63313cd4301184381c5e8042f0a7e9b4bb63653f216311d4ae74690b7"
dependencies = [
"nom",
"nom 7.1.3",
]
[[package]]

View file

@ -2,6 +2,7 @@
import { createEventDispatcher, onMount, onDestroy } from "svelte";
import { type NumberInputMode, type NumberInputIncrementBehavior } from "@graphite/wasm-communication/messages";
import { evaluateMathExpression } from "@graphite-frontend/wasm/pkg/graphite_wasm.js";
import FieldInput from "@graphite/components/widgets/inputs/FieldInput.svelte";
@ -185,13 +186,11 @@
// The `unFocus()` call at the bottom of this function and in `onTextChangeCanceled()` causes this function to be run again, so this check skips a second run.
if (!editing) return;
const parsed = parseFloat(text);
const newValue = Number.isNaN(parsed) ? undefined : parsed;
let newValue = evaluateMathExpression(text);
if (newValue !== undefined && isNaN(newValue)) newValue = undefined; // Rejects `sqrt(-1)`
updateValue(newValue);
editing = false;
self?.unFocus();
}

View file

@ -37,6 +37,7 @@ ron = { version = "0.8", optional = true }
bezier-rs = { path = "../../libraries/bezier-rs" }
# We don't have wgpu on multiple threads (yet) https://github.com/gfx-rs/wgpu/blob/trunk/CHANGELOG.md#wgpu-types-now-send-sync-on-wasm
wgpu = { version = "0.17", features = ["fragile-send-sync-non-atomic-wasm"] }
meval = "0.2.0"
[dependencies.web-sys]
version = "0.3.4"

View file

@ -755,3 +755,86 @@ impl Drop for JsEditorHandle {
EDITOR_INSTANCES.with(|instances| instances.borrow_mut().remove(&self.editor_id));
}
}
#[wasm_bindgen(js_name = evaluateMathExpression)]
pub fn evaluate_math_expression(expression: &str) -> Option<f64> {
// TODO: Rewrite our own purpose-built math expression parser that supports unit conversions.
let mut context = meval::Context::new();
context.var("tau", std::f64::consts::TAU);
context.func("log", f64::log10);
context.func("log10", f64::log10);
context.func("log2", f64::log2);
// Insert asterisks where implicit multiplication is used in the expression string
let expression = implicit_multiplication_preprocess(expression);
meval::eval_str_with_context(&expression, &context).ok()
}
// Modified from this public domain snippet: <https://gist.github.com/Titaniumtown/c181be5d06505e003d8c4d1e372684ff>
// Discussion: <https://github.com/rekka/meval-rs/issues/28#issuecomment-1826381922>
pub fn implicit_multiplication_preprocess(expression: &str) -> String {
let function = expression.to_lowercase().replace("log10(", "log(").replace("log2(", "logtwo(").replace("pi", "π").replace("tau", "τ");
let valid_variables: Vec<char> = "eπτ".chars().collect();
let letters: Vec<char> = ('a'..='z').chain('A'..='Z').collect();
let numbers: Vec<char> = ('0'..='9').collect();
let function_chars: Vec<char> = function.chars().collect();
let mut output_string: String = String::new();
let mut prev_chars: Vec<char> = Vec::new();
for c in function_chars {
let mut add_asterisk: bool = false;
let prev_chars_len = prev_chars.len();
let prev_prev_char = if prev_chars_len >= 2 { *prev_chars.get(prev_chars_len - 2).unwrap() } else { ' ' };
let prev_char = if prev_chars_len >= 1 { *prev_chars.get(prev_chars_len - 1).unwrap() } else { ' ' };
let c_letters_var = letters.contains(&c) | valid_variables.contains(&c);
let prev_letters_var = valid_variables.contains(&prev_char) | letters.contains(&prev_char);
if prev_char == ')' {
if (c == '(') | numbers.contains(&c) | c_letters_var {
add_asterisk = true;
}
} else if c == '(' {
if (valid_variables.contains(&prev_char) | (')' == prev_char) | numbers.contains(&prev_char)) && !letters.contains(&prev_prev_char) {
add_asterisk = true;
}
} else if numbers.contains(&prev_char) {
if (c == '(') | c_letters_var {
add_asterisk = true;
}
} else if letters.contains(&c) {
if numbers.contains(&prev_char) | (valid_variables.contains(&prev_char) && valid_variables.contains(&c)) {
add_asterisk = true;
}
} else if (numbers.contains(&c) | c_letters_var) && prev_letters_var {
add_asterisk = true;
}
if add_asterisk {
output_string += "*";
}
prev_chars.push(c);
output_string += &c.to_string();
}
// We have to convert the Greek symbols back to ASCII because meval doesn't support unicode symbols as context constants
output_string.replace("logtwo(", "log2(").replace("π", "pi").replace("τ", "tau")
}
#[test]
fn implicit_multiplication_preprocess_tests() {
assert_eq!(implicit_multiplication_preprocess("2pi"), "2*pi");
assert_eq!(implicit_multiplication_preprocess("sin(2pi)"), "sin(2*pi)");
assert_eq!(implicit_multiplication_preprocess("2sin(pi)"), "2*sin(pi)");
assert_eq!(implicit_multiplication_preprocess("2sin(3(4 + 5))"), "2*sin(3*(4 + 5))");
assert_eq!(implicit_multiplication_preprocess("3abs(-4)"), "3*abs(-4)");
assert_eq!(implicit_multiplication_preprocess("-1(4)"), "-1*(4)");
assert_eq!(implicit_multiplication_preprocess("(-1)4"), "(-1)*4");
assert_eq!(implicit_multiplication_preprocess("(((-1)))(4)"), "(((-1)))*(4)");
assert_eq!(implicit_multiplication_preprocess("2sin(pi) + 2cos(tau)"), "2*sin(pi) + 2*cos(tau)");
}