From d326931dc2a24c2a73bdad04e6bbde4af84d8eb7 Mon Sep 17 00:00:00 2001 From: jess Date: Tue, 9 Jun 2026 22:29:37 -0700 Subject: [PATCH] added 2 arg trig ops --- compile/src/lib.rs | 28 ++++++++++- core/src/interp/builtins/math.rs | 55 +++++++++++++++++++++- core/src/interp/tests/builtins_math.rs | 64 ++++++++++++++++++++++++++ viewport/src/syntax.rs | 1 + 4 files changed, 145 insertions(+), 3 deletions(-) diff --git a/compile/src/lib.rs b/compile/src/lib.rs index 4396ac5..42db527 100644 --- a/compile/src/lib.rs +++ b/compile/src/lib.rs @@ -364,6 +364,7 @@ fn collect_lvalue_path(expr: &Expr, steps: &mut Vec, cx: &Ctx) -> Result /// builtin names lowered to a single runtime dispatch call. const VALUE_BUILTINS: &[&str] = &[ "sin", "cos", "tan", "asin", "acos", "atan", "sqrt", "abs", "ln", "log", + "atan2", "hypot", "pow", "copysign", "fmod", "floor", "ceil", "round", "rand", "seed", "sum", "avg", "min", "max", "count", "std_devp", "std_devs", "len", "range", "push", @@ -954,10 +955,15 @@ fn v_builtin_call(name: &str, a: &[V]) -> Option { "asin" => V::Num(v_num(&v_arg(a, 0)).asin()), "acos" => V::Num(v_num(&v_arg(a, 0)).acos()), "atan" => V::Num(v_num(&v_arg(a, 0)).atan()), + "atan2" => V::Num(v_num(&v_arg(a, 0)).atan2(v_num(&v_arg(a, 1)))), + "hypot" => V::Num(v_num(&v_arg(a, 0)).hypot(v_num(&v_arg(a, 1)))), + "pow" => { let x = v_num(&v_arg(a, 0)); let y = if a.len() >= 2 { v_num(&v_arg(a, 1)) } else { 2.0 }; V::Num(x.powf(y)) } + "copysign" => { let x = v_num(&v_arg(a, 0)); let y = if a.len() >= 2 { v_num(&v_arg(a, 1)) } else { 1.0 }; V::Num(x.copysign(y)) } + "fmod" => { let x = v_num(&v_arg(a, 0)); let y = if a.len() >= 2 { v_num(&v_arg(a, 1)) } else { 1.0 }; V::Num(x % y) } "sqrt" => V::Num(v_num(&v_arg(a, 0)).sqrt()), "abs" => V::Num(v_num(&v_arg(a, 0)).abs()), "ln" => V::Num(v_num(&v_arg(a, 0)).ln()), - "log" => V::Num(v_num(&v_arg(a, 0)).log10()), + "log" => { let x = v_num(&v_arg(a, 0)); V::Num(if a.len() >= 2 { x.log(v_num(&v_arg(a, 1))) } else { x.log10() }) } "floor" | "ceil" | "round" => { let n = v_num(&v_arg(a, 0)); let digits = if a.len() >= 2 { v_num(&v_arg(a, 1)) as i32 } else { 0 }; let f = 10f64.powi(digits); let s = n * f; V::Num(match name { "floor" => s.floor() / f, "ceil" => s.ceil() / f, _ => s.round() / f }) } "rand" => v_rand(a), "seed" => { v_rng_state(Some(v_num(&v_arg(a, 0)).to_bits())); V::Void } @@ -1062,6 +1068,26 @@ mod tests { assert!(out.contains("v_field(")); } + #[test] + fn two_arg_math_builtins_lower_to_dispatch() { + // every new 2-arg math builtin must route through the runtime dispatcher, + // keeping the compiled prelude in parity with the interpreter. + for src in [ + "let r = atan2(1, 1)", + "let r = hypot(3, 4)", + "let r = pow(2, 8)", + "let r = pow(5)", + "let r = copysign(3, -1)", + "let r = fmod(7, 3)", + "let r = log(8, 2)", + ] { + let out = dec(src); + assert!(out.contains("v_builtin_call("), "`{src}` did not route through v_builtin_call:\n{out}"); + } + assert!(dec("let r = atan2(1, 1)").contains("v_builtin_call(\"atan2\"")); + assert!(dec("let r = hypot(3, 4)").contains("v_builtin_call(\"hypot\"")); + } + #[test] fn reserved_word_raw_ident() { let out = dec("let type = 1"); diff --git a/core/src/interp/builtins/math.rs b/core/src/interp/builtins/math.rs index 781eebb..b5a748e 100644 --- a/core/src/interp/builtins/math.rs +++ b/core/src/interp/builtins/math.rs @@ -12,7 +12,9 @@ pub(crate) fn try_call( ) -> Option> { match name { "sin" | "cos" | "tan" | "asin" | "acos" | "atan" - | "sqrt" | "abs" | "ln" | "log" => Some(call_transcendental(interp, name, args, depth)), + | "sqrt" | "abs" | "ln" => Some(call_transcendental(interp, name, args, depth)), + "atan2" | "hypot" | "pow" | "log" | "copysign" | "fmod" + => Some(call_binary(interp, name, args, depth)), "floor" | "ceil" | "round" => Some(call_rounding(interp, name, args, depth)), _ => None, } @@ -43,7 +45,56 @@ fn call_transcendental( "sqrt" => n.sqrt(), "abs" => n.abs(), "ln" => n.ln(), - "log" => n.log10(), + _ => unreachable!(), + }; + Ok(retag_spice(Value::Number(result), unit)) +} + +/// two-argument math builtins. atan2/hypot require both arguments; pow/log/copysign/fmod +/// take an optional second argument that falls back to a per-function default. +fn call_binary( + interp: &mut Interpreter, + name: &str, + args: &[Expr], + depth: u32, +) -> Result { + let two_required = matches!(name, "atan2" | "hypot"); + if two_required && args.len() != 2 { + return Err(format!("{}() expects 2 arguments", name)); + } + if !two_required && (args.is_empty() || args.len() > 2) { + return Err(format!("{}() expects 1 or 2 arguments", name)); + } + let v = interp.eval_expr(&args[0], depth)?; + let (raw, unit) = unwrap_spice(&v); + let a = match raw { + Value::Number(n) => n, + _ => return Err(format!("{}() expects a number", name)), + }; + // second argument, defaulting per-function when omitted. + let b = if args.len() == 2 { + let v2 = interp.eval_expr(&args[1], depth)?; + let (raw2, _) = unwrap_spice(&v2); + match raw2 { + Value::Number(n) => n, + _ => return Err(format!("{}() expects a number", name)), + } + } else { + match name { + "pow" => 2.0, + "log" => 10.0, + "copysign" | "fmod" => 1.0, + _ => unreachable!(), + } + }; + let result = match name { + "atan2" => a.atan2(b), + "hypot" => a.hypot(b), + "pow" => a.powf(b), + // log10 keeps full precision for the implicit base. + "log" => if args.len() == 2 { a.log(b) } else { a.log10() }, + "copysign" => a.copysign(b), + "fmod" => a % b, _ => unreachable!(), }; Ok(retag_spice(Value::Number(result), unit)) diff --git a/core/src/interp/tests/builtins_math.rs b/core/src/interp/tests/builtins_math.rs index e6f9754..3548b69 100644 --- a/core/src/interp/tests/builtins_math.rs +++ b/core/src/interp/tests/builtins_math.rs @@ -11,6 +11,70 @@ use super::helpers::*; assert_eq!(eval_one("sqrt(16)"), "4"); } + #[test] + fn two_arg_trig_atan2_and_hypot() { + let mut i = Interpreter::new(); + // atan2(y, x): angle of (1, 1) is pi/4. + let v = i.eval("atan2(1, 1)").unwrap(); + match v { Value::Number(n) => assert!(approx(n, std::f64::consts::FRAC_PI_4)), _ => panic!() } + // UFCS form mirrors the free call: y.atan2(x) == atan2(y, x). + i.exec("let y = 1").unwrap(); + let v = i.eval("y.atan2(1)").unwrap(); + match v { Value::Number(n) => assert!(approx(n, std::f64::consts::FRAC_PI_4)), _ => panic!() } + // 3-4-5 triangle. + let v = i.eval("hypot(3, 4)").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 5.0)); + i.exec("let a = 3").unwrap(); + let v = i.eval("a.hypot(4)").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 5.0)); + } + + #[test] + fn atan2_and_hypot_require_two_args() { + let mut i = Interpreter::new(); + assert!(i.eval("atan2(1)").is_err()); + assert!(i.eval("hypot(3)").is_err()); + } + + #[test] + fn pow_with_optional_exponent() { + let mut i = Interpreter::new(); + let v = i.eval("pow(2, 10)").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 1024.0)); + // omitted exponent squares. + let v = i.eval("pow(5)").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 25.0)); + i.exec("let b = 5").unwrap(); + let v = i.eval("b.pow(3)").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 125.0)); + } + + #[test] + fn log_with_optional_base() { + let mut i = Interpreter::new(); + // implicit base 10. + let v = i.eval("log(1000)").unwrap(); + match v { Value::Number(n) => assert!(approx(n, 3.0)), _ => panic!() } + // explicit base. + let v = i.eval("log(8, 2)").unwrap(); + match v { Value::Number(n) => assert!(approx(n, 3.0)), _ => panic!() } + } + + #[test] + fn copysign_and_fmod_with_optional_second_arg() { + let mut i = Interpreter::new(); + let v = i.eval("copysign(3, -1)").unwrap(); + assert!(matches!(v, Value::Number(n) if n == -3.0)); + // omitted sign source defaults positive. + let v = i.eval("copysign(-3)").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 3.0)); + let v = i.eval("fmod(7, 3)").unwrap(); + assert!(matches!(v, Value::Number(n) if n == 1.0)); + // omitted divisor defaults to 1, yielding the fractional part. + let v = i.eval("fmod(2.75)").unwrap(); + match v { Value::Number(n) => assert!(approx(n, 0.75)), _ => panic!() } + } + #[test] fn round_with_digits() { let mut i = Interpreter::new(); diff --git a/viewport/src/syntax.rs b/viewport/src/syntax.rs index 8f962c9..17d4f26 100644 --- a/viewport/src/syntax.rs +++ b/viewport/src/syntax.rs @@ -727,6 +727,7 @@ fn is_cordial_builtin(w: &str) -> bool { matches!(w, // math "sin" | "cos" | "tan" | "asin" | "acos" | "atan" + | "atan2" | "hypot" | "pow" | "copysign" | "fmod" | "sqrt" | "abs" | "floor" | "ceil" | "round" | "ln" | "log" // collections | "len" | "range" | "push"