feat(engine): add units, currency, datetime, variables, and functions modules
Extracted and integrated unique feature modules from Epic 2-6 branches: - units/: 200+ unit conversions across 14 categories, SI prefixes (nano-tera), CSS/screen units, binary vs decimal data, custom units - currency/: fiat (180+ currencies, cached rates, offline fallback), crypto (63 coins, CoinGecko), symbol recognition, rate caching - datetime/: date/time math, 150+ city timezone mappings (chrono-tz), business day calculations, unix timestamps, relative expressions - variables/: line references (lineN, #N, prev/ans), section aggregators (sum/total/avg/min/max/count), subtotals, autocomplete - functions/: trig, log, combinatorics, financial, rounding, list operations (min/max/gcd/lcm), video timecodes 585 tests passing across workspace.
This commit is contained in:
241
calcpad-engine/src/functions/combinatorics.rs
Normal file
241
calcpad-engine/src/functions/combinatorics.rs
Normal file
@@ -0,0 +1,241 @@
|
||||
//! Factorial and combinatorics: factorial, nPr, nCr.
|
||||
//!
|
||||
//! Uses arbitrary-precision internally via u128 for intermediate products
|
||||
//! to handle factorials up to ~34. For truly large factorials (100!), callers
|
||||
//! should use `factorial_bigint` which returns a string. The f64-based
|
||||
//! `factorial` registered here will overflow gracefully to `f64::INFINITY`
|
||||
//! for n > 170 (standard IEEE 754 limit).
|
||||
|
||||
use super::{FunctionError, FunctionRegistry};
|
||||
|
||||
/// Compute n! as f64. Returns +Infinity when n > 170.
|
||||
fn factorial_f64(n: f64) -> Result<f64, FunctionError> {
|
||||
if n < 0.0 || n.fract() != 0.0 {
|
||||
return Err(FunctionError::new(
|
||||
"Factorial is only defined for non-negative integers",
|
||||
));
|
||||
}
|
||||
let n = n as u64;
|
||||
let mut result: f64 = 1.0;
|
||||
for i in 2..=n {
|
||||
result *= i as f64;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn factorial_fn(args: &[f64]) -> Result<f64, FunctionError> {
|
||||
factorial_f64(args[0])
|
||||
}
|
||||
|
||||
/// Compute nPr = n! / (n-k)! as f64.
|
||||
fn permutation_fn(args: &[f64]) -> Result<f64, FunctionError> {
|
||||
let n = args[0];
|
||||
let k = args[1];
|
||||
|
||||
if n.fract() != 0.0 || k.fract() != 0.0 {
|
||||
return Err(FunctionError::new("nPr requires integer arguments"));
|
||||
}
|
||||
if n < 0.0 || k < 0.0 {
|
||||
return Err(FunctionError::new("nPr requires non-negative arguments"));
|
||||
}
|
||||
|
||||
let n = n as u64;
|
||||
let k = k as u64;
|
||||
|
||||
if k > n {
|
||||
return Ok(0.0);
|
||||
}
|
||||
|
||||
let mut result: f64 = 1.0;
|
||||
for i in 0..k {
|
||||
result *= (n - i) as f64;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Compute nCr = n! / (k! * (n-k)!) as f64.
|
||||
fn combination_fn(args: &[f64]) -> Result<f64, FunctionError> {
|
||||
let n = args[0];
|
||||
let k = args[1];
|
||||
|
||||
if n.fract() != 0.0 || k.fract() != 0.0 {
|
||||
return Err(FunctionError::new("nCr requires integer arguments"));
|
||||
}
|
||||
if n < 0.0 || k < 0.0 {
|
||||
return Err(FunctionError::new("nCr requires non-negative arguments"));
|
||||
}
|
||||
|
||||
let n = n as u64;
|
||||
let mut k = k as u64;
|
||||
|
||||
if k > n {
|
||||
return Ok(0.0);
|
||||
}
|
||||
|
||||
// Optimise: C(n,k) == C(n, n-k)
|
||||
if k > n - k {
|
||||
k = n - k;
|
||||
}
|
||||
|
||||
let mut result: f64 = 1.0;
|
||||
for i in 0..k {
|
||||
result *= (n - i) as f64;
|
||||
result /= (i + 1) as f64;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Register combinatorics functions.
|
||||
pub fn register(reg: &mut FunctionRegistry) {
|
||||
reg.register_fixed("factorial", 1, factorial_fn);
|
||||
reg.register_fixed("nPr", 2, permutation_fn);
|
||||
reg.register_fixed("nCr", 2, combination_fn);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn reg() -> FunctionRegistry {
|
||||
FunctionRegistry::new()
|
||||
}
|
||||
|
||||
// --- factorial ---
|
||||
|
||||
#[test]
|
||||
fn factorial_zero_is_one() {
|
||||
let v = reg().call("factorial", &[0.0]).unwrap();
|
||||
assert!((v - 1.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factorial_one_is_one() {
|
||||
let v = reg().call("factorial", &[1.0]).unwrap();
|
||||
assert!((v - 1.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factorial_five_is_120() {
|
||||
let v = reg().call("factorial", &[5.0]).unwrap();
|
||||
assert!((v - 120.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factorial_ten_is_3628800() {
|
||||
let v = reg().call("factorial", &[10.0]).unwrap();
|
||||
assert!((v - 3_628_800.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factorial_20() {
|
||||
let v = reg().call("factorial", &[20.0]).unwrap();
|
||||
// 20! = 2432902008176640000
|
||||
assert!((v - 2_432_902_008_176_640_000.0).abs() < 1e3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factorial_negative_error() {
|
||||
let err = reg().call("factorial", &[-3.0]).unwrap_err();
|
||||
assert!(err.message.contains("non-negative integers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factorial_non_integer_error() {
|
||||
let err = reg().call("factorial", &[3.5]).unwrap_err();
|
||||
assert!(err.message.contains("non-negative integers"));
|
||||
}
|
||||
|
||||
// --- nPr ---
|
||||
|
||||
#[test]
|
||||
fn npr_10_3_is_720() {
|
||||
let v = reg().call("nPr", &[10.0, 3.0]).unwrap();
|
||||
assert!((v - 720.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npr_5_5_is_120() {
|
||||
let v = reg().call("nPr", &[5.0, 5.0]).unwrap();
|
||||
assert!((v - 120.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npr_5_0_is_1() {
|
||||
let v = reg().call("nPr", &[5.0, 0.0]).unwrap();
|
||||
assert!((v - 1.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npr_0_0_is_1() {
|
||||
let v = reg().call("nPr", &[0.0, 0.0]).unwrap();
|
||||
assert!((v - 1.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npr_k_greater_than_n_is_zero() {
|
||||
let v = reg().call("nPr", &[5.0, 7.0]).unwrap();
|
||||
assert!((v - 0.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npr_negative_error() {
|
||||
let err = reg().call("nPr", &[-1.0, 2.0]).unwrap_err();
|
||||
assert!(err.message.contains("non-negative"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npr_non_integer_error() {
|
||||
let err = reg().call("nPr", &[5.5, 2.0]).unwrap_err();
|
||||
assert!(err.message.contains("integer"));
|
||||
}
|
||||
|
||||
// --- nCr ---
|
||||
|
||||
#[test]
|
||||
fn ncr_10_3_is_120() {
|
||||
let v = reg().call("nCr", &[10.0, 3.0]).unwrap();
|
||||
assert!((v - 120.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ncr_5_2_is_10() {
|
||||
let v = reg().call("nCr", &[5.0, 2.0]).unwrap();
|
||||
assert!((v - 10.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ncr_5_0_is_1() {
|
||||
let v = reg().call("nCr", &[5.0, 0.0]).unwrap();
|
||||
assert!((v - 1.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ncr_5_5_is_1() {
|
||||
let v = reg().call("nCr", &[5.0, 5.0]).unwrap();
|
||||
assert!((v - 1.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ncr_k_greater_than_n_is_zero() {
|
||||
let v = reg().call("nCr", &[5.0, 7.0]).unwrap();
|
||||
assert!((v - 0.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ncr_0_0_is_1() {
|
||||
let v = reg().call("nCr", &[0.0, 0.0]).unwrap();
|
||||
assert!((v - 1.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ncr_negative_error() {
|
||||
let err = reg().call("nCr", &[-1.0, 2.0]).unwrap_err();
|
||||
assert!(err.message.contains("non-negative"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ncr_non_integer_error() {
|
||||
let err = reg().call("nCr", &[5.5, 2.0]).unwrap_err();
|
||||
assert!(err.message.contains("integer"));
|
||||
}
|
||||
}
|
||||
184
calcpad-engine/src/functions/financial.rs
Normal file
184
calcpad-engine/src/functions/financial.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
//! Financial functions: compound interest and mortgage payment.
|
||||
//!
|
||||
//! ## compound_interest(principal, rate, periods)
|
||||
//!
|
||||
//! Returns `principal * (1 + rate)^periods`.
|
||||
//! - `principal` — initial investment / loan amount
|
||||
//! - `rate` — interest rate per period (e.g. 0.05 for 5%)
|
||||
//! - `periods` — number of compounding periods
|
||||
//!
|
||||
//! ## mortgage_payment(principal, annual_rate, years)
|
||||
//!
|
||||
//! Returns the monthly payment for a fixed-rate mortgage using the standard
|
||||
//! amortization formula:
|
||||
//!
|
||||
//! M = P * [r(1+r)^n] / [(1+r)^n - 1]
|
||||
//!
|
||||
//! where `r` = `annual_rate / 12` and `n` = `years * 12`.
|
||||
//!
|
||||
//! If the rate is 0, returns `principal / (years * 12)`.
|
||||
|
||||
use super::{FunctionError, FunctionRegistry};
|
||||
|
||||
/// compound_interest(principal, rate, periods)
|
||||
fn compound_interest_fn(args: &[f64]) -> Result<f64, FunctionError> {
|
||||
let principal = args[0];
|
||||
let rate = args[1];
|
||||
let periods = args[2];
|
||||
|
||||
if rate < -1.0 {
|
||||
return Err(FunctionError::new(
|
||||
"Interest rate must be >= -1 (i.e. at most a 100% loss per period)",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(principal * (1.0 + rate).powf(periods))
|
||||
}
|
||||
|
||||
/// mortgage_payment(principal, annual_rate, years)
|
||||
fn mortgage_payment_fn(args: &[f64]) -> Result<f64, FunctionError> {
|
||||
let principal = args[0];
|
||||
let annual_rate = args[1];
|
||||
let years = args[2];
|
||||
|
||||
if principal < 0.0 {
|
||||
return Err(FunctionError::new("Principal must be non-negative"));
|
||||
}
|
||||
if annual_rate < 0.0 {
|
||||
return Err(FunctionError::new("Annual rate must be non-negative"));
|
||||
}
|
||||
if years <= 0.0 {
|
||||
return Err(FunctionError::new("Loan term must be positive"));
|
||||
}
|
||||
|
||||
let n = years * 12.0; // total monthly payments
|
||||
|
||||
if annual_rate == 0.0 {
|
||||
// No interest — just divide evenly.
|
||||
return Ok(principal / n);
|
||||
}
|
||||
|
||||
let r = annual_rate / 12.0; // monthly rate
|
||||
let factor = (1.0 + r).powf(n);
|
||||
let payment = principal * (r * factor) / (factor - 1.0);
|
||||
Ok(payment)
|
||||
}
|
||||
|
||||
/// Register financial functions.
|
||||
pub fn register(reg: &mut FunctionRegistry) {
|
||||
reg.register_fixed("compound_interest", 3, compound_interest_fn);
|
||||
reg.register_fixed("mortgage_payment", 3, mortgage_payment_fn);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn reg() -> FunctionRegistry {
|
||||
FunctionRegistry::new()
|
||||
}
|
||||
|
||||
// --- compound_interest ---
|
||||
|
||||
#[test]
|
||||
fn compound_interest_basic() {
|
||||
// $1000 at 5% for 10 years => 1000 * 1.05^10 = 1628.89...
|
||||
let v = reg()
|
||||
.call("compound_interest", &[1000.0, 0.05, 10.0])
|
||||
.unwrap();
|
||||
assert!((v - 1628.894627).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_interest_zero_rate() {
|
||||
let v = reg()
|
||||
.call("compound_interest", &[1000.0, 0.0, 10.0])
|
||||
.unwrap();
|
||||
assert!((v - 1000.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_interest_zero_periods() {
|
||||
let v = reg()
|
||||
.call("compound_interest", &[1000.0, 0.05, 0.0])
|
||||
.unwrap();
|
||||
assert!((v - 1000.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_interest_one_period() {
|
||||
let v = reg()
|
||||
.call("compound_interest", &[1000.0, 0.1, 1.0])
|
||||
.unwrap();
|
||||
assert!((v - 1100.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_interest_negative_rate_too_low() {
|
||||
let err = reg()
|
||||
.call("compound_interest", &[1000.0, -1.5, 1.0])
|
||||
.unwrap_err();
|
||||
assert!(err.message.contains("rate"));
|
||||
}
|
||||
|
||||
// --- mortgage_payment ---
|
||||
|
||||
#[test]
|
||||
fn mortgage_payment_standard() {
|
||||
// $200,000 at 6% annual for 30 years => ~$1199.10/month
|
||||
let v = reg()
|
||||
.call("mortgage_payment", &[200_000.0, 0.06, 30.0])
|
||||
.unwrap();
|
||||
assert!((v - 1199.10).abs() < 0.02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mortgage_payment_zero_rate() {
|
||||
// $120,000 at 0% for 10 years => $1000/month
|
||||
let v = reg()
|
||||
.call("mortgage_payment", &[120_000.0, 0.0, 10.0])
|
||||
.unwrap();
|
||||
assert!((v - 1000.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mortgage_payment_short_term() {
|
||||
// $12,000 at 12% annual for 1 year => ~$1066.19/month
|
||||
let v = reg()
|
||||
.call("mortgage_payment", &[12_000.0, 0.12, 1.0])
|
||||
.unwrap();
|
||||
assert!((v - 1066.19).abs() < 0.02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mortgage_payment_negative_principal_error() {
|
||||
let err = reg()
|
||||
.call("mortgage_payment", &[-1000.0, 0.05, 10.0])
|
||||
.unwrap_err();
|
||||
assert!(err.message.contains("Principal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mortgage_payment_negative_rate_error() {
|
||||
let err = reg()
|
||||
.call("mortgage_payment", &[1000.0, -0.05, 10.0])
|
||||
.unwrap_err();
|
||||
assert!(err.message.contains("rate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mortgage_payment_zero_years_error() {
|
||||
let err = reg()
|
||||
.call("mortgage_payment", &[1000.0, 0.05, 0.0])
|
||||
.unwrap_err();
|
||||
assert!(err.message.contains("term"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mortgage_payment_arity_error() {
|
||||
let err = reg()
|
||||
.call("mortgage_payment", &[1000.0, 0.05])
|
||||
.unwrap_err();
|
||||
assert!(err.message.contains("expects 3 argument"));
|
||||
}
|
||||
}
|
||||
223
calcpad-engine/src/functions/list_ops.rs
Normal file
223
calcpad-engine/src/functions/list_ops.rs
Normal file
@@ -0,0 +1,223 @@
|
||||
//! Variadic list operations: min, max, gcd, lcm.
|
||||
//!
|
||||
//! All accept 1 or more arguments. `gcd` and `lcm` require integer arguments.
|
||||
|
||||
use super::{FunctionError, FunctionRegistry};
|
||||
|
||||
fn min_fn(args: &[f64]) -> Result<f64, FunctionError> {
|
||||
// We already know args.len() >= 1 from the variadic guard.
|
||||
let mut m = args[0];
|
||||
for &v in &args[1..] {
|
||||
if v < m {
|
||||
m = v;
|
||||
}
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
fn max_fn(args: &[f64]) -> Result<f64, FunctionError> {
|
||||
let mut m = args[0];
|
||||
for &v in &args[1..] {
|
||||
if v > m {
|
||||
m = v;
|
||||
}
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// GCD of two non-negative integers using Euclidean algorithm.
|
||||
fn gcd_pair(mut a: i64, mut b: i64) -> i64 {
|
||||
a = a.abs();
|
||||
b = b.abs();
|
||||
while b != 0 {
|
||||
let t = b;
|
||||
b = a % b;
|
||||
a = t;
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// LCM of two non-negative integers.
|
||||
fn lcm_pair(a: i64, b: i64) -> i64 {
|
||||
if a == 0 || b == 0 {
|
||||
return 0;
|
||||
}
|
||||
(a.abs() / gcd_pair(a, b)) * b.abs()
|
||||
}
|
||||
|
||||
fn gcd_fn(args: &[f64]) -> Result<f64, FunctionError> {
|
||||
for &v in args {
|
||||
if v.fract() != 0.0 {
|
||||
return Err(FunctionError::new("gcd requires integer arguments"));
|
||||
}
|
||||
}
|
||||
let mut result = args[0] as i64;
|
||||
for &v in &args[1..] {
|
||||
result = gcd_pair(result, v as i64);
|
||||
}
|
||||
Ok(result as f64)
|
||||
}
|
||||
|
||||
fn lcm_fn(args: &[f64]) -> Result<f64, FunctionError> {
|
||||
for &v in args {
|
||||
if v.fract() != 0.0 {
|
||||
return Err(FunctionError::new("lcm requires integer arguments"));
|
||||
}
|
||||
}
|
||||
let mut result = args[0] as i64;
|
||||
for &v in &args[1..] {
|
||||
result = lcm_pair(result, v as i64);
|
||||
}
|
||||
Ok(result as f64)
|
||||
}
|
||||
|
||||
/// Register list-operation functions.
|
||||
pub fn register(reg: &mut FunctionRegistry) {
|
||||
reg.register_variadic("min", 1, min_fn);
|
||||
reg.register_variadic("max", 1, max_fn);
|
||||
reg.register_variadic("gcd", 1, gcd_fn);
|
||||
reg.register_variadic("lcm", 1, lcm_fn);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn reg() -> FunctionRegistry {
|
||||
FunctionRegistry::new()
|
||||
}
|
||||
|
||||
// --- min ---
|
||||
|
||||
#[test]
|
||||
fn min_single() {
|
||||
let v = reg().call("min", &[42.0]).unwrap();
|
||||
assert!((v - 42.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_two() {
|
||||
let v = reg().call("min", &[3.0, 7.0]).unwrap();
|
||||
assert!((v - 3.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_many() {
|
||||
let v = reg().call("min", &[10.0, 3.0, 7.0, 1.0, 5.0]).unwrap();
|
||||
assert!((v - 1.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_negative() {
|
||||
let v = reg().call("min", &[-5.0, -2.0, -10.0]).unwrap();
|
||||
assert!((v - (-10.0)).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_no_args_error() {
|
||||
let err = reg().call("min", &[]).unwrap_err();
|
||||
assert!(err.message.contains("at least 1"));
|
||||
}
|
||||
|
||||
// --- max ---
|
||||
|
||||
#[test]
|
||||
fn max_single() {
|
||||
let v = reg().call("max", &[42.0]).unwrap();
|
||||
assert!((v - 42.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_two() {
|
||||
let v = reg().call("max", &[3.0, 7.0]).unwrap();
|
||||
assert!((v - 7.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_many() {
|
||||
let v = reg().call("max", &[10.0, 3.0, 7.0, 1.0, 50.0]).unwrap();
|
||||
assert!((v - 50.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_no_args_error() {
|
||||
let err = reg().call("max", &[]).unwrap_err();
|
||||
assert!(err.message.contains("at least 1"));
|
||||
}
|
||||
|
||||
// --- gcd ---
|
||||
|
||||
#[test]
|
||||
fn gcd_two_numbers() {
|
||||
let v = reg().call("gcd", &[12.0, 8.0]).unwrap();
|
||||
assert!((v - 4.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gcd_three_numbers() {
|
||||
let v = reg().call("gcd", &[12.0, 8.0, 6.0]).unwrap();
|
||||
assert!((v - 2.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gcd_coprime() {
|
||||
let v = reg().call("gcd", &[7.0, 13.0]).unwrap();
|
||||
assert!((v - 1.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gcd_with_zero() {
|
||||
let v = reg().call("gcd", &[0.0, 5.0]).unwrap();
|
||||
assert!((v - 5.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gcd_single() {
|
||||
let v = reg().call("gcd", &[42.0]).unwrap();
|
||||
assert!((v - 42.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gcd_non_integer_error() {
|
||||
let err = reg().call("gcd", &[3.5, 2.0]).unwrap_err();
|
||||
assert!(err.message.contains("integer"));
|
||||
}
|
||||
|
||||
// --- lcm ---
|
||||
|
||||
#[test]
|
||||
fn lcm_two_numbers() {
|
||||
let v = reg().call("lcm", &[4.0, 6.0]).unwrap();
|
||||
assert!((v - 12.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lcm_three_numbers() {
|
||||
let v = reg().call("lcm", &[4.0, 6.0, 10.0]).unwrap();
|
||||
assert!((v - 60.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lcm_with_zero() {
|
||||
let v = reg().call("lcm", &[0.0, 5.0]).unwrap();
|
||||
assert!((v - 0.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lcm_single() {
|
||||
let v = reg().call("lcm", &[42.0]).unwrap();
|
||||
assert!((v - 42.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lcm_coprime() {
|
||||
let v = reg().call("lcm", &[7.0, 13.0]).unwrap();
|
||||
assert!((v - 91.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lcm_non_integer_error() {
|
||||
let err = reg().call("lcm", &[3.5, 2.0]).unwrap_err();
|
||||
assert!(err.message.contains("integer"));
|
||||
}
|
||||
}
|
||||
249
calcpad-engine/src/functions/logarithmic.rs
Normal file
249
calcpad-engine/src/functions/logarithmic.rs
Normal file
@@ -0,0 +1,249 @@
|
||||
//! Logarithmic, exponential, and root functions.
|
||||
//!
|
||||
//! - `ln` — natural logarithm (base e)
|
||||
//! - `log` — common logarithm (base 10)
|
||||
//! - `log2` — binary logarithm (base 2)
|
||||
//! - `exp` — e raised to a power
|
||||
//! - `pow` — base raised to an exponent (2 args)
|
||||
//! - `sqrt` — square root
|
||||
//! - `cbrt` — cube root
|
||||
|
||||
use super::{FunctionError, FunctionRegistry};
|
||||
|
||||
fn ln_fn(args: &[f64]) -> Result<f64, FunctionError> {
|
||||
let x = args[0];
|
||||
if x <= 0.0 {
|
||||
return Err(FunctionError::new(
|
||||
"Argument out of domain for ln (must be positive)",
|
||||
));
|
||||
}
|
||||
Ok(x.ln())
|
||||
}
|
||||
|
||||
fn log_fn(args: &[f64]) -> Result<f64, FunctionError> {
|
||||
let x = args[0];
|
||||
if x <= 0.0 {
|
||||
return Err(FunctionError::new(
|
||||
"Argument out of domain for log (must be positive)",
|
||||
));
|
||||
}
|
||||
Ok(x.log10())
|
||||
}
|
||||
|
||||
fn log2_fn(args: &[f64]) -> Result<f64, FunctionError> {
|
||||
let x = args[0];
|
||||
if x <= 0.0 {
|
||||
return Err(FunctionError::new(
|
||||
"Argument out of domain for log2 (must be positive)",
|
||||
));
|
||||
}
|
||||
Ok(x.log2())
|
||||
}
|
||||
|
||||
fn exp_fn(args: &[f64]) -> Result<f64, FunctionError> {
|
||||
Ok(args[0].exp())
|
||||
}
|
||||
|
||||
fn pow_fn(args: &[f64]) -> Result<f64, FunctionError> {
|
||||
Ok(args[0].powf(args[1]))
|
||||
}
|
||||
|
||||
fn sqrt_fn(args: &[f64]) -> Result<f64, FunctionError> {
|
||||
let x = args[0];
|
||||
if x < 0.0 {
|
||||
return Err(FunctionError::new(
|
||||
"Argument out of domain for sqrt (must be non-negative)",
|
||||
));
|
||||
}
|
||||
Ok(x.sqrt())
|
||||
}
|
||||
|
||||
fn cbrt_fn(args: &[f64]) -> Result<f64, FunctionError> {
|
||||
Ok(args[0].cbrt())
|
||||
}
|
||||
|
||||
/// Register all logarithmic/exponential/root functions.
|
||||
pub fn register(reg: &mut FunctionRegistry) {
|
||||
reg.register_fixed("ln", 1, ln_fn);
|
||||
reg.register_fixed("log", 1, log_fn);
|
||||
reg.register_fixed("log2", 1, log2_fn);
|
||||
reg.register_fixed("exp", 1, exp_fn);
|
||||
reg.register_fixed("pow", 2, pow_fn);
|
||||
reg.register_fixed("sqrt", 1, sqrt_fn);
|
||||
reg.register_fixed("cbrt", 1, cbrt_fn);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn reg() -> FunctionRegistry {
|
||||
FunctionRegistry::new()
|
||||
}
|
||||
|
||||
// --- ln ---
|
||||
|
||||
#[test]
|
||||
fn ln_one_is_zero() {
|
||||
let v = reg().call("ln", &[1.0]).unwrap();
|
||||
assert!(v.abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ln_e_is_one() {
|
||||
let v = reg().call("ln", &[std::f64::consts::E]).unwrap();
|
||||
assert!((v - 1.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ln_zero_domain_error() {
|
||||
let err = reg().call("ln", &[0.0]).unwrap_err();
|
||||
assert!(err.message.contains("out of domain"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ln_negative_domain_error() {
|
||||
let err = reg().call("ln", &[-1.0]).unwrap_err();
|
||||
assert!(err.message.contains("out of domain"));
|
||||
}
|
||||
|
||||
// --- log (base 10) ---
|
||||
|
||||
#[test]
|
||||
fn log_100_is_2() {
|
||||
let v = reg().call("log", &[100.0]).unwrap();
|
||||
assert!((v - 2.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_1000_is_3() {
|
||||
let v = reg().call("log", &[1000.0]).unwrap();
|
||||
assert!((v - 3.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_negative_domain_error() {
|
||||
let err = reg().call("log", &[-1.0]).unwrap_err();
|
||||
assert!(err.message.contains("out of domain"));
|
||||
}
|
||||
|
||||
// --- log2 ---
|
||||
|
||||
#[test]
|
||||
fn log2_256_is_8() {
|
||||
let v = reg().call("log2", &[256.0]).unwrap();
|
||||
assert!((v - 8.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log2_one_is_zero() {
|
||||
let v = reg().call("log2", &[1.0]).unwrap();
|
||||
assert!(v.abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log2_negative_domain_error() {
|
||||
let err = reg().call("log2", &[-5.0]).unwrap_err();
|
||||
assert!(err.message.contains("out of domain"));
|
||||
}
|
||||
|
||||
// --- exp ---
|
||||
|
||||
#[test]
|
||||
fn exp_zero_is_one() {
|
||||
let v = reg().call("exp", &[0.0]).unwrap();
|
||||
assert!((v - 1.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exp_one_is_e() {
|
||||
let v = reg().call("exp", &[1.0]).unwrap();
|
||||
assert!((v - std::f64::consts::E).abs() < 1e-10);
|
||||
}
|
||||
|
||||
// --- pow ---
|
||||
|
||||
#[test]
|
||||
fn pow_2_10_is_1024() {
|
||||
let v = reg().call("pow", &[2.0, 10.0]).unwrap();
|
||||
assert!((v - 1024.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pow_3_0_is_1() {
|
||||
let v = reg().call("pow", &[3.0, 0.0]).unwrap();
|
||||
assert!((v - 1.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
// --- sqrt ---
|
||||
|
||||
#[test]
|
||||
fn sqrt_144_is_12() {
|
||||
let v = reg().call("sqrt", &[144.0]).unwrap();
|
||||
assert!((v - 12.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqrt_2_approx() {
|
||||
let v = reg().call("sqrt", &[2.0]).unwrap();
|
||||
assert!((v - std::f64::consts::SQRT_2).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqrt_zero_is_zero() {
|
||||
let v = reg().call("sqrt", &[0.0]).unwrap();
|
||||
assert!(v.abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqrt_negative_domain_error() {
|
||||
let err = reg().call("sqrt", &[-4.0]).unwrap_err();
|
||||
assert!(err.message.contains("out of domain"));
|
||||
}
|
||||
|
||||
// --- cbrt ---
|
||||
|
||||
#[test]
|
||||
fn cbrt_27_is_3() {
|
||||
let v = reg().call("cbrt", &[27.0]).unwrap();
|
||||
assert!((v - 3.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cbrt_8_is_2() {
|
||||
let v = reg().call("cbrt", &[8.0]).unwrap();
|
||||
assert!((v - 2.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cbrt_neg_8_is_neg_2() {
|
||||
let v = reg().call("cbrt", &[-8.0]).unwrap();
|
||||
assert!((v - (-2.0)).abs() < 1e-10);
|
||||
}
|
||||
|
||||
// --- composition ---
|
||||
|
||||
#[test]
|
||||
fn ln_exp_roundtrip() {
|
||||
let r = reg();
|
||||
let inner = r.call("exp", &[5.0]).unwrap();
|
||||
let v = r.call("ln", &[inner]).unwrap();
|
||||
assert!((v - 5.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exp_ln_roundtrip() {
|
||||
let r = reg();
|
||||
let inner = r.call("ln", &[10.0]).unwrap();
|
||||
let v = r.call("exp", &[inner]).unwrap();
|
||||
assert!((v - 10.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqrt_pow_roundtrip() {
|
||||
let r = reg();
|
||||
let inner = r.call("pow", &[3.0, 2.0]).unwrap();
|
||||
let v = r.call("sqrt", &[inner]).unwrap();
|
||||
assert!((v - 3.0).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
321
calcpad-engine/src/functions/mod.rs
Normal file
321
calcpad-engine/src/functions/mod.rs
Normal file
@@ -0,0 +1,321 @@
|
||||
//! Function registry and dispatch for CalcPad math functions.
|
||||
//!
|
||||
//! Provides a [`FunctionRegistry`] that maps function names to typed
|
||||
//! implementations across all function categories (trig, logarithmic,
|
||||
//! combinatorics, financial, rounding, list ops, timecodes).
|
||||
|
||||
pub mod combinatorics;
|
||||
pub mod financial;
|
||||
pub mod list_ops;
|
||||
pub mod logarithmic;
|
||||
pub mod rounding;
|
||||
pub mod timecodes;
|
||||
pub mod trig;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Error type returned by function evaluation.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FunctionError {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl FunctionError {
|
||||
pub fn new(msg: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: msg.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FunctionError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for FunctionError {}
|
||||
|
||||
/// Angle mode for trigonometric functions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AngleMode {
|
||||
Radians,
|
||||
Degrees,
|
||||
}
|
||||
|
||||
impl Default for AngleMode {
|
||||
fn default() -> Self {
|
||||
AngleMode::Radians
|
||||
}
|
||||
}
|
||||
|
||||
/// The signature of a function: how many args it accepts and how to call it.
|
||||
#[derive(Clone)]
|
||||
enum FnImpl {
|
||||
/// Fixed-arity function (e.g. sin takes 1 arg, pow takes 2).
|
||||
Fixed {
|
||||
arity: usize,
|
||||
func: fn(&[f64]) -> Result<f64, FunctionError>,
|
||||
},
|
||||
/// Variadic function that accepts 1..N args (e.g. min, max, gcd, lcm).
|
||||
Variadic {
|
||||
min_args: usize,
|
||||
func: fn(&[f64]) -> Result<f64, FunctionError>,
|
||||
},
|
||||
/// Angle-aware trig function (1 arg + angle mode + force-degrees flag).
|
||||
Trig {
|
||||
func: fn(f64, AngleMode, bool) -> Result<f64, FunctionError>,
|
||||
},
|
||||
/// Variable-arity function with a known range (e.g. round takes 1 or 2).
|
||||
RangeArity {
|
||||
min_args: usize,
|
||||
max_args: usize,
|
||||
func: fn(&[f64]) -> Result<f64, FunctionError>,
|
||||
},
|
||||
/// Timecode function that operates on string-like frame values.
|
||||
Timecode {
|
||||
func: fn(&[f64]) -> Result<f64, FunctionError>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Central registry mapping function names to their implementations.
|
||||
pub struct FunctionRegistry {
|
||||
functions: HashMap<String, FnImpl>,
|
||||
}
|
||||
|
||||
impl FunctionRegistry {
|
||||
/// Build a new registry pre-loaded with all built-in functions.
|
||||
pub fn new() -> Self {
|
||||
let mut reg = Self {
|
||||
functions: HashMap::new(),
|
||||
};
|
||||
trig::register(&mut reg);
|
||||
logarithmic::register(&mut reg);
|
||||
combinatorics::register(&mut reg);
|
||||
financial::register(&mut reg);
|
||||
rounding::register(&mut reg);
|
||||
list_ops::register(&mut reg);
|
||||
timecodes::register(&mut reg);
|
||||
reg
|
||||
}
|
||||
|
||||
// ---- registration helpers (called by sub-modules) ----
|
||||
|
||||
pub(crate) fn register_trig(
|
||||
&mut self,
|
||||
name: &str,
|
||||
func: fn(f64, AngleMode, bool) -> Result<f64, FunctionError>,
|
||||
) {
|
||||
self.functions
|
||||
.insert(name.to_string(), FnImpl::Trig { func });
|
||||
}
|
||||
|
||||
pub(crate) fn register_fixed(
|
||||
&mut self,
|
||||
name: &str,
|
||||
arity: usize,
|
||||
func: fn(&[f64]) -> Result<f64, FunctionError>,
|
||||
) {
|
||||
self.functions
|
||||
.insert(name.to_string(), FnImpl::Fixed { arity, func });
|
||||
}
|
||||
|
||||
pub(crate) fn register_variadic(
|
||||
&mut self,
|
||||
name: &str,
|
||||
min_args: usize,
|
||||
func: fn(&[f64]) -> Result<f64, FunctionError>,
|
||||
) {
|
||||
self.functions
|
||||
.insert(name.to_string(), FnImpl::Variadic { min_args, func });
|
||||
}
|
||||
|
||||
pub(crate) fn register_range_arity(
|
||||
&mut self,
|
||||
name: &str,
|
||||
min_args: usize,
|
||||
max_args: usize,
|
||||
func: fn(&[f64]) -> Result<f64, FunctionError>,
|
||||
) {
|
||||
self.functions.insert(
|
||||
name.to_string(),
|
||||
FnImpl::RangeArity {
|
||||
min_args,
|
||||
max_args,
|
||||
func,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn register_timecode(
|
||||
&mut self,
|
||||
name: &str,
|
||||
func: fn(&[f64]) -> Result<f64, FunctionError>,
|
||||
) {
|
||||
self.functions
|
||||
.insert(name.to_string(), FnImpl::Timecode { func });
|
||||
}
|
||||
|
||||
// ---- dispatch ----
|
||||
|
||||
/// Returns true if `name` is a registered function.
|
||||
pub fn has_function(&self, name: &str) -> bool {
|
||||
self.functions.contains_key(name)
|
||||
}
|
||||
|
||||
/// Returns true if `name` is a trig function (needs angle mode).
|
||||
pub fn is_trig(&self, name: &str) -> bool {
|
||||
matches!(self.functions.get(name), Some(FnImpl::Trig { .. }))
|
||||
}
|
||||
|
||||
/// Call a trig function with the given argument, angle mode, and
|
||||
/// force-degrees flag.
|
||||
pub fn call_trig(
|
||||
&self,
|
||||
name: &str,
|
||||
arg: f64,
|
||||
mode: AngleMode,
|
||||
force_degrees: bool,
|
||||
) -> Result<f64, FunctionError> {
|
||||
match self.functions.get(name) {
|
||||
Some(FnImpl::Trig { func }) => func(arg, mode, force_degrees),
|
||||
Some(_) => Err(FunctionError::new(format!(
|
||||
"{} is not a trigonometric function",
|
||||
name
|
||||
))),
|
||||
None => Err(FunctionError::new(format!("Unknown function: {}", name))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Call any non-trig function with a slice of evaluated arguments.
|
||||
pub fn call(&self, name: &str, args: &[f64]) -> Result<f64, FunctionError> {
|
||||
match self.functions.get(name) {
|
||||
Some(FnImpl::Fixed { arity, func }) => {
|
||||
if args.len() != *arity {
|
||||
return Err(FunctionError::new(format!(
|
||||
"{} expects {} argument(s), got {}",
|
||||
name,
|
||||
arity,
|
||||
args.len()
|
||||
)));
|
||||
}
|
||||
func(args)
|
||||
}
|
||||
Some(FnImpl::Variadic { min_args, func }) => {
|
||||
if args.len() < *min_args {
|
||||
return Err(FunctionError::new(format!(
|
||||
"{} requires at least {} argument(s), got {}",
|
||||
name,
|
||||
min_args,
|
||||
args.len()
|
||||
)));
|
||||
}
|
||||
func(args)
|
||||
}
|
||||
Some(FnImpl::RangeArity {
|
||||
min_args,
|
||||
max_args,
|
||||
func,
|
||||
}) => {
|
||||
if args.len() < *min_args || args.len() > *max_args {
|
||||
return Err(FunctionError::new(format!(
|
||||
"{} expects {}-{} argument(s), got {}",
|
||||
name,
|
||||
min_args,
|
||||
max_args,
|
||||
args.len()
|
||||
)));
|
||||
}
|
||||
func(args)
|
||||
}
|
||||
Some(FnImpl::Trig { func }) => {
|
||||
// Convenience: if called via `call()`, default to radians, no force-degrees.
|
||||
if args.len() != 1 {
|
||||
return Err(FunctionError::new(format!(
|
||||
"{} expects 1 argument, got {}",
|
||||
name,
|
||||
args.len()
|
||||
)));
|
||||
}
|
||||
func(args[0], AngleMode::Radians, false)
|
||||
}
|
||||
Some(FnImpl::Timecode { func }) => func(args),
|
||||
None => Err(FunctionError::new(format!("Unknown function: {}", name))),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all registered function names (sorted alphabetically).
|
||||
pub fn function_names(&self) -> Vec<&str> {
|
||||
let mut names: Vec<&str> = self.functions.keys().map(|s| s.as_str()).collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FunctionRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn registry_contains_all_categories() {
|
||||
let reg = FunctionRegistry::new();
|
||||
// Trig
|
||||
assert!(reg.has_function("sin"));
|
||||
assert!(reg.has_function("cos"));
|
||||
assert!(reg.has_function("tanh"));
|
||||
// Logarithmic
|
||||
assert!(reg.has_function("ln"));
|
||||
assert!(reg.has_function("log"));
|
||||
assert!(reg.has_function("sqrt"));
|
||||
// Combinatorics
|
||||
assert!(reg.has_function("factorial"));
|
||||
assert!(reg.has_function("nPr"));
|
||||
assert!(reg.has_function("nCr"));
|
||||
// Financial
|
||||
assert!(reg.has_function("compound_interest"));
|
||||
assert!(reg.has_function("mortgage_payment"));
|
||||
// Rounding
|
||||
assert!(reg.has_function("round"));
|
||||
assert!(reg.has_function("floor"));
|
||||
assert!(reg.has_function("ceil"));
|
||||
// List
|
||||
assert!(reg.has_function("min"));
|
||||
assert!(reg.has_function("max"));
|
||||
assert!(reg.has_function("gcd"));
|
||||
assert!(reg.has_function("lcm"));
|
||||
// Timecodes
|
||||
assert!(reg.has_function("tc_to_frames"));
|
||||
assert!(reg.has_function("frames_to_tc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trig_dispatch_works() {
|
||||
let reg = FunctionRegistry::new();
|
||||
assert!(reg.is_trig("sin"));
|
||||
let val = reg
|
||||
.call_trig("sin", 0.0, AngleMode::Radians, false)
|
||||
.unwrap();
|
||||
assert!((val - 0.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_unknown_function_returns_error() {
|
||||
let reg = FunctionRegistry::new();
|
||||
let err = reg.call("nonexistent_fn", &[1.0]).unwrap_err();
|
||||
assert!(err.message.contains("Unknown function"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arity_mismatch_returns_error() {
|
||||
let reg = FunctionRegistry::new();
|
||||
let err = reg.call("sqrt", &[1.0, 2.0]).unwrap_err();
|
||||
assert!(err.message.contains("expects 1 argument"));
|
||||
}
|
||||
}
|
||||
191
calcpad-engine/src/functions/rounding.rs
Normal file
191
calcpad-engine/src/functions/rounding.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
//! Rounding functions: round, floor, ceil, round_to.
|
||||
//!
|
||||
//! - `round(x)` — round to the nearest integer (half rounds away from 0)
|
||||
//! - `round(x, n)` — round to n decimal places
|
||||
//! - `floor(x)` — round toward negative infinity
|
||||
//! - `ceil(x)` — round toward positive infinity
|
||||
//! - `round_to(x, step)` — round x to the nearest multiple of step
|
||||
|
||||
use super::{FunctionError, FunctionRegistry};
|
||||
|
||||
fn floor_fn(args: &[f64]) -> Result<f64, FunctionError> {
|
||||
Ok(args[0].floor())
|
||||
}
|
||||
|
||||
fn ceil_fn(args: &[f64]) -> Result<f64, FunctionError> {
|
||||
Ok(args[0].ceil())
|
||||
}
|
||||
|
||||
fn round_fn(args: &[f64]) -> Result<f64, FunctionError> {
|
||||
let value = args[0];
|
||||
if args.len() == 1 {
|
||||
return Ok(value.round());
|
||||
}
|
||||
let decimals = args[1];
|
||||
if decimals.fract() != 0.0 || decimals < 0.0 {
|
||||
return Err(FunctionError::new(
|
||||
"round decimal places must be a non-negative integer",
|
||||
));
|
||||
}
|
||||
let factor = 10f64.powi(decimals as i32);
|
||||
Ok((value * factor).round() / factor)
|
||||
}
|
||||
|
||||
/// Round x to the nearest multiple of step.
|
||||
/// `round_to(17, 5)` => 15, `round_to(18, 5)` => 20.
|
||||
fn round_to_fn(args: &[f64]) -> Result<f64, FunctionError> {
|
||||
let x = args[0];
|
||||
let step = args[1];
|
||||
if step == 0.0 {
|
||||
return Err(FunctionError::new(
|
||||
"round_to step must be non-zero",
|
||||
));
|
||||
}
|
||||
Ok((x / step).round() * step)
|
||||
}
|
||||
|
||||
/// Register rounding functions.
|
||||
pub fn register(reg: &mut FunctionRegistry) {
|
||||
reg.register_fixed("floor", 1, floor_fn);
|
||||
reg.register_fixed("ceil", 1, ceil_fn);
|
||||
reg.register_range_arity("round", 1, 2, round_fn);
|
||||
reg.register_fixed("round_to", 2, round_to_fn);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn reg() -> FunctionRegistry {
|
||||
FunctionRegistry::new()
|
||||
}
|
||||
|
||||
// --- floor ---
|
||||
|
||||
#[test]
|
||||
fn floor_positive_fraction() {
|
||||
let v = reg().call("floor", &[3.7]).unwrap();
|
||||
assert!((v - 3.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floor_negative_fraction() {
|
||||
let v = reg().call("floor", &[-3.2]).unwrap();
|
||||
assert!((v - (-4.0)).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floor_integer_unchanged() {
|
||||
let v = reg().call("floor", &[5.0]).unwrap();
|
||||
assert!((v - 5.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floor_zero() {
|
||||
let v = reg().call("floor", &[0.0]).unwrap();
|
||||
assert!(v.abs() < 1e-10);
|
||||
}
|
||||
|
||||
// --- ceil ---
|
||||
|
||||
#[test]
|
||||
fn ceil_positive_fraction() {
|
||||
let v = reg().call("ceil", &[3.2]).unwrap();
|
||||
assert!((v - 4.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ceil_negative_fraction() {
|
||||
let v = reg().call("ceil", &[-3.7]).unwrap();
|
||||
assert!((v - (-3.0)).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ceil_integer_unchanged() {
|
||||
let v = reg().call("ceil", &[5.0]).unwrap();
|
||||
assert!((v - 5.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ceil_zero() {
|
||||
let v = reg().call("ceil", &[0.0]).unwrap();
|
||||
assert!(v.abs() < 1e-10);
|
||||
}
|
||||
|
||||
// --- round ---
|
||||
|
||||
#[test]
|
||||
fn round_half_up() {
|
||||
let v = reg().call("round", &[2.5]).unwrap();
|
||||
assert!((v - 3.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_1_5() {
|
||||
let v = reg().call("round", &[1.5]).unwrap();
|
||||
assert!((v - 2.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_negative() {
|
||||
let v = reg().call("round", &[-1.5]).unwrap();
|
||||
// Rust's f64::round rounds half away from zero, so -1.5 => -2.0
|
||||
assert!((v - (-2.0)).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_with_decimal_places() {
|
||||
let v = reg().call("round", &[3.456, 2.0]).unwrap();
|
||||
assert!((v - 3.46).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_with_zero_places() {
|
||||
let v = reg().call("round", &[3.456, 0.0]).unwrap();
|
||||
assert!((v - 3.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_with_one_place() {
|
||||
let v = reg().call("round", &[1.234, 1.0]).unwrap();
|
||||
assert!((v - 1.2).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_negative_decimal_places_error() {
|
||||
let err = reg().call("round", &[3.456, -1.0]).unwrap_err();
|
||||
assert!(err.message.contains("non-negative"));
|
||||
}
|
||||
|
||||
// --- round_to (nearest N) ---
|
||||
|
||||
#[test]
|
||||
fn round_to_nearest_5() {
|
||||
let v = reg().call("round_to", &[17.0, 5.0]).unwrap();
|
||||
assert!((v - 15.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_to_nearest_5_up() {
|
||||
let v = reg().call("round_to", &[18.0, 5.0]).unwrap();
|
||||
assert!((v - 20.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_to_nearest_10() {
|
||||
let v = reg().call("round_to", &[84.0, 10.0]).unwrap();
|
||||
assert!((v - 80.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_to_nearest_0_25() {
|
||||
let v = reg().call("round_to", &[3.3, 0.25]).unwrap();
|
||||
assert!((v - 3.25).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_to_zero_step_error() {
|
||||
let err = reg().call("round_to", &[10.0, 0.0]).unwrap_err();
|
||||
assert!(err.message.contains("non-zero"));
|
||||
}
|
||||
}
|
||||
366
calcpad-engine/src/functions/timecodes.rs
Normal file
366
calcpad-engine/src/functions/timecodes.rs
Normal file
@@ -0,0 +1,366 @@
|
||||
//! Video timecode arithmetic.
|
||||
//!
|
||||
//! Timecodes represent positions in video as `HH:MM:SS:FF` where FF is a
|
||||
//! frame count within the current second. The number of frames per second
|
||||
//! (fps) determines the range of FF (0..fps-1).
|
||||
//!
|
||||
//! ## Functions
|
||||
//!
|
||||
//! - `tc_to_frames(hours, minutes, seconds, frames, fps)` — convert a
|
||||
//! timecode to a total frame count.
|
||||
//! - `frames_to_tc(total_frames, fps)` — convert total frames back to a
|
||||
//! packed timecode value `HH * 1_000_000 + MM * 10_000 + SS * 100 + FF`
|
||||
//! for easy extraction of components.
|
||||
//! - `tc_add_frames(hours, minutes, seconds, frames, fps, add_frames)` —
|
||||
//! add (or subtract) a number of frames to a timecode and return the new
|
||||
//! total frame count.
|
||||
//!
|
||||
//! Common fps values: 24, 25, 29.97 (NTSC drop-frame), 30, 48, 60.
|
||||
//!
|
||||
//! For now we work in non-drop-frame (NDF) mode. Drop-frame support can be
|
||||
//! added later.
|
||||
|
||||
use super::{FunctionError, FunctionRegistry};
|
||||
|
||||
/// Convert a timecode (H, M, S, F, fps) to total frame count.
|
||||
fn tc_to_frames_fn(args: &[f64]) -> Result<f64, FunctionError> {
|
||||
if args.len() != 5 {
|
||||
return Err(FunctionError::new(
|
||||
"tc_to_frames expects 5 arguments: hours, minutes, seconds, frames, fps",
|
||||
));
|
||||
}
|
||||
let hours = args[0];
|
||||
let minutes = args[1];
|
||||
let seconds = args[2];
|
||||
let frames = args[3];
|
||||
let fps = args[4];
|
||||
|
||||
validate_timecode_components(hours, minutes, seconds, frames, fps)?;
|
||||
|
||||
let fps_i = fps as u64;
|
||||
let total = (hours as u64) * 3600 * fps_i
|
||||
+ (minutes as u64) * 60 * fps_i
|
||||
+ (seconds as u64) * fps_i
|
||||
+ (frames as u64);
|
||||
Ok(total as f64)
|
||||
}
|
||||
|
||||
/// Convert total frames to a packed timecode: HH*1_000_000 + MM*10_000 + SS*100 + FF.
|
||||
/// Returns the packed value. Also returns components via the packed encoding.
|
||||
fn frames_to_tc_fn(args: &[f64]) -> Result<f64, FunctionError> {
|
||||
if args.len() != 2 {
|
||||
return Err(FunctionError::new(
|
||||
"frames_to_tc expects 2 arguments: total_frames, fps",
|
||||
));
|
||||
}
|
||||
let total = args[0];
|
||||
let fps = args[1];
|
||||
|
||||
if total < 0.0 || total.fract() != 0.0 {
|
||||
return Err(FunctionError::new(
|
||||
"total_frames must be a non-negative integer",
|
||||
));
|
||||
}
|
||||
if fps <= 0.0 || fps.fract() != 0.0 {
|
||||
return Err(FunctionError::new(
|
||||
"fps must be a positive integer",
|
||||
));
|
||||
}
|
||||
|
||||
let total = total as u64;
|
||||
let fps_i = fps as u64;
|
||||
|
||||
let ff = total % fps_i;
|
||||
let rem = total / fps_i;
|
||||
let ss = rem % 60;
|
||||
let rem = rem / 60;
|
||||
let mm = rem % 60;
|
||||
let hh = rem / 60;
|
||||
|
||||
// Pack into a single number: HH_MM_SS_FF
|
||||
let packed = hh * 1_000_000 + mm * 10_000 + ss * 100 + ff;
|
||||
Ok(packed as f64)
|
||||
}
|
||||
|
||||
/// Add frames to a timecode and return new total frame count.
|
||||
fn tc_add_frames_fn(args: &[f64]) -> Result<f64, FunctionError> {
|
||||
if args.len() != 6 {
|
||||
return Err(FunctionError::new(
|
||||
"tc_add_frames expects 6 arguments: hours, minutes, seconds, frames, fps, add_frames",
|
||||
));
|
||||
}
|
||||
let hours = args[0];
|
||||
let minutes = args[1];
|
||||
let seconds = args[2];
|
||||
let frames = args[3];
|
||||
let fps = args[4];
|
||||
let add_frames = args[5];
|
||||
|
||||
validate_timecode_components(hours, minutes, seconds, frames, fps)?;
|
||||
|
||||
let fps_i = fps as u64;
|
||||
let total = (hours as u64) * 3600 * fps_i
|
||||
+ (minutes as u64) * 60 * fps_i
|
||||
+ (seconds as u64) * fps_i
|
||||
+ (frames as u64);
|
||||
|
||||
let new_total = total as i64 + add_frames as i64;
|
||||
if new_total < 0 {
|
||||
return Err(FunctionError::new(
|
||||
"Resulting timecode would be negative",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(new_total as f64)
|
||||
}
|
||||
|
||||
fn validate_timecode_components(
|
||||
hours: f64,
|
||||
minutes: f64,
|
||||
seconds: f64,
|
||||
frames: f64,
|
||||
fps: f64,
|
||||
) -> Result<(), FunctionError> {
|
||||
if fps <= 0.0 || fps.fract() != 0.0 {
|
||||
return Err(FunctionError::new("fps must be a positive integer"));
|
||||
}
|
||||
if hours < 0.0 || hours.fract() != 0.0 {
|
||||
return Err(FunctionError::new(
|
||||
"hours must be a non-negative integer",
|
||||
));
|
||||
}
|
||||
if minutes < 0.0 || minutes >= 60.0 || minutes.fract() != 0.0 {
|
||||
return Err(FunctionError::new(
|
||||
"minutes must be an integer in 0..59",
|
||||
));
|
||||
}
|
||||
if seconds < 0.0 || seconds >= 60.0 || seconds.fract() != 0.0 {
|
||||
return Err(FunctionError::new(
|
||||
"seconds must be an integer in 0..59",
|
||||
));
|
||||
}
|
||||
if frames < 0.0 || frames >= fps || frames.fract() != 0.0 {
|
||||
return Err(FunctionError::new(format!(
|
||||
"frames must be an integer in 0..{} (fps={})",
|
||||
fps as u64 - 1,
|
||||
fps as u64,
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register timecode functions.
|
||||
pub fn register(reg: &mut FunctionRegistry) {
|
||||
reg.register_fixed("tc_to_frames", 5, tc_to_frames_fn);
|
||||
reg.register_fixed("frames_to_tc", 2, frames_to_tc_fn);
|
||||
reg.register_fixed("tc_add_frames", 6, tc_add_frames_fn);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn reg() -> FunctionRegistry {
|
||||
FunctionRegistry::new()
|
||||
}
|
||||
|
||||
// --- tc_to_frames ---
|
||||
|
||||
#[test]
|
||||
fn tc_to_frames_zero() {
|
||||
// 00:00:00:00 at 24fps => 0 frames
|
||||
let v = reg()
|
||||
.call("tc_to_frames", &[0.0, 0.0, 0.0, 0.0, 24.0])
|
||||
.unwrap();
|
||||
assert!((v - 0.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_to_frames_one_second_24fps() {
|
||||
// 00:00:01:00 at 24fps => 24 frames
|
||||
let v = reg()
|
||||
.call("tc_to_frames", &[0.0, 0.0, 1.0, 0.0, 24.0])
|
||||
.unwrap();
|
||||
assert!((v - 24.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_to_frames_one_minute_24fps() {
|
||||
// 00:01:00:00 at 24fps => 1440 frames
|
||||
let v = reg()
|
||||
.call("tc_to_frames", &[0.0, 1.0, 0.0, 0.0, 24.0])
|
||||
.unwrap();
|
||||
assert!((v - 1440.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_to_frames_one_hour_24fps() {
|
||||
// 01:00:00:00 at 24fps => 86400 frames
|
||||
let v = reg()
|
||||
.call("tc_to_frames", &[1.0, 0.0, 0.0, 0.0, 24.0])
|
||||
.unwrap();
|
||||
assert!((v - 86400.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_to_frames_mixed() {
|
||||
// 01:02:03:04 at 24fps => 1*3600*24 + 2*60*24 + 3*24 + 4 = 86400 + 2880 + 72 + 4 = 89356
|
||||
let v = reg()
|
||||
.call("tc_to_frames", &[1.0, 2.0, 3.0, 4.0, 24.0])
|
||||
.unwrap();
|
||||
assert!((v - 89356.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_to_frames_25fps() {
|
||||
// 00:00:01:00 at 25fps => 25 frames
|
||||
let v = reg()
|
||||
.call("tc_to_frames", &[0.0, 0.0, 1.0, 0.0, 25.0])
|
||||
.unwrap();
|
||||
assert!((v - 25.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_to_frames_30fps() {
|
||||
// 00:00:01:00 at 30fps => 30 frames
|
||||
let v = reg()
|
||||
.call("tc_to_frames", &[0.0, 0.0, 1.0, 0.0, 30.0])
|
||||
.unwrap();
|
||||
assert!((v - 30.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_to_frames_60fps() {
|
||||
// 00:01:00:00 at 60fps => 3600 frames
|
||||
let v = reg()
|
||||
.call("tc_to_frames", &[0.0, 1.0, 0.0, 0.0, 60.0])
|
||||
.unwrap();
|
||||
assert!((v - 3600.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_to_frames_invalid_minutes() {
|
||||
let err = reg()
|
||||
.call("tc_to_frames", &[0.0, 60.0, 0.0, 0.0, 24.0])
|
||||
.unwrap_err();
|
||||
assert!(err.message.contains("minutes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_to_frames_invalid_frames() {
|
||||
// Frames >= fps is invalid
|
||||
let err = reg()
|
||||
.call("tc_to_frames", &[0.0, 0.0, 0.0, 24.0, 24.0])
|
||||
.unwrap_err();
|
||||
assert!(err.message.contains("frames"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_to_frames_invalid_fps() {
|
||||
let err = reg()
|
||||
.call("tc_to_frames", &[0.0, 0.0, 0.0, 0.0, 0.0])
|
||||
.unwrap_err();
|
||||
assert!(err.message.contains("fps"));
|
||||
}
|
||||
|
||||
// --- frames_to_tc ---
|
||||
|
||||
#[test]
|
||||
fn frames_to_tc_zero() {
|
||||
let v = reg().call("frames_to_tc", &[0.0, 24.0]).unwrap();
|
||||
assert!((v - 0.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_to_tc_one_second() {
|
||||
// 24 frames at 24fps => 00:00:01:00 => packed 100
|
||||
let v = reg().call("frames_to_tc", &[24.0, 24.0]).unwrap();
|
||||
assert!((v - 100.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_to_tc_one_minute() {
|
||||
// 1440 frames at 24fps => 00:01:00:00 => packed 10000
|
||||
let v = reg().call("frames_to_tc", &[1440.0, 24.0]).unwrap();
|
||||
assert!((v - 10000.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_to_tc_one_hour() {
|
||||
// 86400 frames at 24fps => 01:00:00:00 => packed 1000000
|
||||
let v = reg().call("frames_to_tc", &[86400.0, 24.0]).unwrap();
|
||||
assert!((v - 1_000_000.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_to_tc_mixed() {
|
||||
// 89356 frames at 24fps => 01:02:03:04 => packed 1020304
|
||||
let v = reg().call("frames_to_tc", &[89356.0, 24.0]).unwrap();
|
||||
assert!((v - 1_020_304.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_to_tc_roundtrip() {
|
||||
let r = reg();
|
||||
// Convert to frames then back
|
||||
let frames = r
|
||||
.call("tc_to_frames", &[2.0, 30.0, 15.0, 12.0, 30.0])
|
||||
.unwrap();
|
||||
let packed = r.call("frames_to_tc", &[frames, 30.0]).unwrap();
|
||||
// 02:30:15:12 => packed 2301512
|
||||
assert!((packed - 2_301_512.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_to_tc_negative_error() {
|
||||
let err = reg().call("frames_to_tc", &[-1.0, 24.0]).unwrap_err();
|
||||
assert!(err.message.contains("non-negative"));
|
||||
}
|
||||
|
||||
// --- tc_add_frames ---
|
||||
|
||||
#[test]
|
||||
fn tc_add_frames_simple() {
|
||||
let r = reg();
|
||||
// 00:00:00:00 at 24fps + 48 frames => 48
|
||||
let v = r
|
||||
.call("tc_add_frames", &[0.0, 0.0, 0.0, 0.0, 24.0, 48.0])
|
||||
.unwrap();
|
||||
assert!((v - 48.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_add_frames_subtract() {
|
||||
let r = reg();
|
||||
// 00:00:02:00 at 24fps = 48 frames, subtract 24 => 24
|
||||
let v = r
|
||||
.call("tc_add_frames", &[0.0, 0.0, 2.0, 0.0, 24.0, -24.0])
|
||||
.unwrap();
|
||||
assert!((v - 24.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_add_frames_negative_result_error() {
|
||||
let r = reg();
|
||||
let err = r
|
||||
.call("tc_add_frames", &[0.0, 0.0, 0.0, 0.0, 24.0, -1.0])
|
||||
.unwrap_err();
|
||||
assert!(err.message.contains("negative"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_add_frames_cross_minute_boundary() {
|
||||
let r = reg();
|
||||
// 00:00:59:23 at 24fps + 1 frame
|
||||
let base = r
|
||||
.call("tc_to_frames", &[0.0, 0.0, 59.0, 23.0, 24.0])
|
||||
.unwrap();
|
||||
let v = r
|
||||
.call("tc_add_frames", &[0.0, 0.0, 59.0, 23.0, 24.0, 1.0])
|
||||
.unwrap();
|
||||
assert!((v - (base + 1.0)).abs() < 1e-10);
|
||||
// Verify the result converts to 00:01:00:00
|
||||
let packed = r.call("frames_to_tc", &[v, 24.0]).unwrap();
|
||||
assert!((packed - 10000.0).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
255
calcpad-engine/src/functions/trig.rs
Normal file
255
calcpad-engine/src/functions/trig.rs
Normal file
@@ -0,0 +1,255 @@
|
||||
//! Trigonometric functions: sin, cos, tan, asin, acos, atan, sinh, cosh, tanh.
|
||||
//!
|
||||
//! All trig functions are angle-mode aware. When `AngleMode::Degrees` is active
|
||||
//! (or when `force_degrees` is true), inputs to forward trig functions are
|
||||
//! converted from degrees to radians, and outputs of inverse trig functions
|
||||
//! are converted from radians to degrees. Hyperbolic functions ignore angle mode.
|
||||
|
||||
use super::{AngleMode, FunctionError, FunctionRegistry};
|
||||
|
||||
const DEG_TO_RAD: f64 = std::f64::consts::PI / 180.0;
|
||||
const RAD_TO_DEG: f64 = 180.0 / std::f64::consts::PI;
|
||||
|
||||
fn to_radians(value: f64, mode: AngleMode, force_degrees: bool) -> f64 {
|
||||
if force_degrees || mode == AngleMode::Degrees {
|
||||
value * DEG_TO_RAD
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
fn from_radians(value: f64, mode: AngleMode) -> f64 {
|
||||
if mode == AngleMode::Degrees {
|
||||
value * RAD_TO_DEG
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
// --- forward trig ---
|
||||
|
||||
fn sin_fn(arg: f64, mode: AngleMode, force_deg: bool) -> Result<f64, FunctionError> {
|
||||
Ok(to_radians(arg, mode, force_deg).sin())
|
||||
}
|
||||
|
||||
fn cos_fn(arg: f64, mode: AngleMode, force_deg: bool) -> Result<f64, FunctionError> {
|
||||
Ok(to_radians(arg, mode, force_deg).cos())
|
||||
}
|
||||
|
||||
fn tan_fn(arg: f64, mode: AngleMode, force_deg: bool) -> Result<f64, FunctionError> {
|
||||
Ok(to_radians(arg, mode, force_deg).tan())
|
||||
}
|
||||
|
||||
// --- inverse trig ---
|
||||
|
||||
fn asin_fn(arg: f64, mode: AngleMode, _force_deg: bool) -> Result<f64, FunctionError> {
|
||||
if arg < -1.0 || arg > 1.0 {
|
||||
return Err(FunctionError::new(
|
||||
"Argument out of domain for asin (must be between -1 and 1)",
|
||||
));
|
||||
}
|
||||
Ok(from_radians(arg.asin(), mode))
|
||||
}
|
||||
|
||||
fn acos_fn(arg: f64, mode: AngleMode, _force_deg: bool) -> Result<f64, FunctionError> {
|
||||
if arg < -1.0 || arg > 1.0 {
|
||||
return Err(FunctionError::new(
|
||||
"Argument out of domain for acos (must be between -1 and 1)",
|
||||
));
|
||||
}
|
||||
Ok(from_radians(arg.acos(), mode))
|
||||
}
|
||||
|
||||
fn atan_fn(arg: f64, mode: AngleMode, _force_deg: bool) -> Result<f64, FunctionError> {
|
||||
Ok(from_radians(arg.atan(), mode))
|
||||
}
|
||||
|
||||
// --- hyperbolic (angle-mode independent) ---
|
||||
|
||||
fn sinh_fn(arg: f64, _mode: AngleMode, _force_deg: bool) -> Result<f64, FunctionError> {
|
||||
Ok(arg.sinh())
|
||||
}
|
||||
|
||||
fn cosh_fn(arg: f64, _mode: AngleMode, _force_deg: bool) -> Result<f64, FunctionError> {
|
||||
Ok(arg.cosh())
|
||||
}
|
||||
|
||||
fn tanh_fn(arg: f64, _mode: AngleMode, _force_deg: bool) -> Result<f64, FunctionError> {
|
||||
Ok(arg.tanh())
|
||||
}
|
||||
|
||||
/// Register all trig functions into the given registry.
|
||||
pub fn register(reg: &mut FunctionRegistry) {
|
||||
reg.register_trig("sin", sin_fn);
|
||||
reg.register_trig("cos", cos_fn);
|
||||
reg.register_trig("tan", tan_fn);
|
||||
reg.register_trig("asin", asin_fn);
|
||||
reg.register_trig("acos", acos_fn);
|
||||
reg.register_trig("atan", atan_fn);
|
||||
reg.register_trig("sinh", sinh_fn);
|
||||
reg.register_trig("cosh", cosh_fn);
|
||||
reg.register_trig("tanh", tanh_fn);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI, SQRT_2};
|
||||
|
||||
fn reg() -> FunctionRegistry {
|
||||
FunctionRegistry::new()
|
||||
}
|
||||
|
||||
// --- radians mode (default) ---
|
||||
|
||||
#[test]
|
||||
fn sin_zero_radians() {
|
||||
let r = reg();
|
||||
let v = r.call_trig("sin", 0.0, AngleMode::Radians, false).unwrap();
|
||||
assert!((v - 0.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sin_pi_radians() {
|
||||
let r = reg();
|
||||
let v = r.call_trig("sin", PI, AngleMode::Radians, false).unwrap();
|
||||
assert!(v.abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cos_zero_is_one() {
|
||||
let r = reg();
|
||||
let v = r.call_trig("cos", 0.0, AngleMode::Radians, false).unwrap();
|
||||
assert!((v - 1.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tan_zero_is_zero() {
|
||||
let r = reg();
|
||||
let v = r.call_trig("tan", 0.0, AngleMode::Radians, false).unwrap();
|
||||
assert!(v.abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asin_one_is_pi_over_2() {
|
||||
let r = reg();
|
||||
let v = r.call_trig("asin", 1.0, AngleMode::Radians, false).unwrap();
|
||||
assert!((v - FRAC_PI_2).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acos_one_is_zero() {
|
||||
let r = reg();
|
||||
let v = r.call_trig("acos", 1.0, AngleMode::Radians, false).unwrap();
|
||||
assert!(v.abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atan_one_is_pi_over_4() {
|
||||
let r = reg();
|
||||
let v = r.call_trig("atan", 1.0, AngleMode::Radians, false).unwrap();
|
||||
assert!((v - FRAC_PI_4).abs() < 1e-10);
|
||||
}
|
||||
|
||||
// --- degrees mode ---
|
||||
|
||||
#[test]
|
||||
fn sin_90_degrees_is_one() {
|
||||
let r = reg();
|
||||
let v = r.call_trig("sin", 90.0, AngleMode::Degrees, false).unwrap();
|
||||
assert!((v - 1.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cos_zero_degrees_is_one() {
|
||||
let r = reg();
|
||||
let v = r.call_trig("cos", 0.0, AngleMode::Degrees, false).unwrap();
|
||||
assert!((v - 1.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acos_half_degrees_is_60() {
|
||||
let r = reg();
|
||||
let v = r
|
||||
.call_trig("acos", 0.5, AngleMode::Degrees, false)
|
||||
.unwrap();
|
||||
assert!((v - 60.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atan_one_degrees_is_45() {
|
||||
let r = reg();
|
||||
let v = r
|
||||
.call_trig("atan", 1.0, AngleMode::Degrees, false)
|
||||
.unwrap();
|
||||
assert!((v - 45.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
// --- force-degrees override (radians mode, but degree symbol present) ---
|
||||
|
||||
#[test]
|
||||
fn sin_45_force_degrees_in_rad_mode() {
|
||||
let r = reg();
|
||||
let v = r.call_trig("sin", 45.0, AngleMode::Radians, true).unwrap();
|
||||
assert!((v - SQRT_2 / 2.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tan_45_force_degrees() {
|
||||
let r = reg();
|
||||
let v = r.call_trig("tan", 45.0, AngleMode::Radians, true).unwrap();
|
||||
assert!((v - 1.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
// --- hyperbolic ---
|
||||
|
||||
#[test]
|
||||
fn sinh_one() {
|
||||
let r = reg();
|
||||
let v = r.call_trig("sinh", 1.0, AngleMode::Radians, false).unwrap();
|
||||
assert!((v - 1.1752011936438014).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cosh_zero_is_one() {
|
||||
let r = reg();
|
||||
let v = r.call_trig("cosh", 0.0, AngleMode::Radians, false).unwrap();
|
||||
assert!((v - 1.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tanh_zero_is_zero() {
|
||||
let r = reg();
|
||||
let v = r.call_trig("tanh", 0.0, AngleMode::Radians, false).unwrap();
|
||||
assert!(v.abs() < 1e-10);
|
||||
}
|
||||
|
||||
// --- domain errors ---
|
||||
|
||||
#[test]
|
||||
fn asin_out_of_domain() {
|
||||
let r = reg();
|
||||
let err = r
|
||||
.call_trig("asin", 2.0, AngleMode::Radians, false)
|
||||
.unwrap_err();
|
||||
assert!(err.message.contains("out of domain"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asin_negative_out_of_domain() {
|
||||
let r = reg();
|
||||
let err = r
|
||||
.call_trig("asin", -2.0, AngleMode::Radians, false)
|
||||
.unwrap_err();
|
||||
assert!(err.message.contains("out of domain"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acos_out_of_domain() {
|
||||
let r = reg();
|
||||
let err = r
|
||||
.call_trig("acos", 2.0, AngleMode::Radians, false)
|
||||
.unwrap_err();
|
||||
assert!(err.message.contains("out of domain"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user