Skip to main content

symplex_build/
lib.rs

1//! Build-time code generation for symplex.
2//!
3//! This crate is designed to be used as a `[build-dependency]` in firmware
4//! projects. It runs the symplex CAS at build time to derive symbolic
5//! equations (Jacobians, dynamics, etc.) and generates optimized numerical
6//! Rust code for `no_std` embedded targets.
7//!
8//! # Quick Start
9//!
10//! ```toml
11//! # Cargo.toml
12//! [build-dependencies]
13//! symplex-build = "0.2"
14//! ```
15//!
16//! ```rust,no_run
17//! // build.rs
18//! use symplex_build::CodeGen;
19//! use symplex::prelude::*;
20//! use symplex::matrix::jacobian;
21//! use symplex::robotics::*;
22//!
23//! fn main() {
24//!     let ctx = Context::new();
25//!     symplex::syms!(ctx; theta1, theta2);
26//!     let zero = ctx.int(0);
27//!     let l1 = ctx.rational(3, 10);  // 0.3m
28//!     let l2 = ctx.rational(1, 4);   // 0.25m
29//!
30//!     let (x, y, _z) = fk_position(&[
31//!         (&theta1, &zero, &l1, &zero),
32//!         (&theta2, &zero, &l2, &zero),
33//!     ]);
34//!
35//!     let j = jacobian(&[&x, &y], &[&theta1, &theta2]);
36//!
37//!     CodeGen::new()
38//!         .add_matrix_fn("jacobian", &j, &["theta1", "theta2"])
39//!         .write_to_out_dir("robot_math.rs")
40//!         .unwrap();
41//! }
42//! ```
43
44use symplex::matrix::{CodegenOptions, MathBackend, Matrix, Precision};
45use symplex::prelude::*;
46
47use std::fs;
48use std::path::{Path, PathBuf};
49
50// ═══════════════════════════════════════════════════════════════════════════
51// CodeGen builder
52// ═══════════════════════════════════════════════════════════════════════════
53
54/// Builder for generating Rust source code from symbolic expressions.
55///
56/// Collects multiple functions (scalar and matrix) and writes them all
57/// to a single output file with shared preamble (cfg-gated math module, etc.).
58pub struct CodeGen {
59    functions: Vec<GeneratedFn>,
60    options: CodegenOptions,
61    preamble: Vec<String>,
62    test_points: Vec<Vec<f64>>,
63    generate_tests: bool,
64}
65
66enum GeneratedFn {
67    Scalar {
68        name: String,
69        expr: Ex,
70        params: Vec<String>,
71    },
72    Matrix {
73        name: String,
74        matrix: Matrix,
75        params: Vec<String>,
76    },
77}
78
79impl CodeGen {
80    /// Create a new `CodeGen` builder with default options.
81    pub fn new() -> Self {
82        Self {
83            functions: Vec::new(),
84            options: CodegenOptions::default(),
85            preamble: Vec::new(),
86            test_points: Vec::new(),
87            generate_tests: false,
88        }
89    }
90
91    /// Set custom code generation options.
92    ///
93    /// Every registered function is emitted with these options.  The
94    /// special-function runtime (`mod symplex_rt`, needed by `gamma`,
95    /// `lambertw`, Bessel functions, …) is emitted **once** at the top of
96    /// the file when [`CodegenOptions::emit_runtime`] is `true` (the
97    /// default) and at least one function needs it.  Set `emit_runtime:
98    /// false` to leave it out entirely — e.g. when several generated files
99    /// share one copy of [`CodegenOptions::runtime_module`]:
100    ///
101    /// ```rust,no_run
102    /// use symplex::matrix::{CodegenOptions, MathBackend};
103    /// use symplex::prelude::*;
104    /// use symplex_build::CodeGen;
105    ///
106    /// let ctx = Context::new();
107    /// let x = ctx.symbol("x");
108    /// let opts = CodegenOptions {
109    ///     math_backend: MathBackend::CfgGated,
110    ///     emit_runtime: false,
111    ///     ..Default::default()
112    /// };
113    /// let body = CodeGen::new()
114    ///     .options(opts.clone())
115    ///     .add_scalar_fn("g", &x.gamma(), &["x"])
116    ///     .add_scalar_fn("w", &x.lambertw(), &["x"])
117    ///     .generate()
118    ///     .unwrap();
119    /// std::fs::write("robot_math.rs", body).unwrap();
120    /// std::fs::write("symplex_rt.rs", opts.runtime_module()).unwrap();
121    /// ```
122    pub fn options(mut self, options: CodegenOptions) -> Self {
123        self.options = options;
124        self
125    }
126
127    /// Enable or disable `no_std`-compatible output (uses the `CfgGated` math backend).
128    pub fn no_std(mut self, enabled: bool) -> Self {
129        if enabled {
130            self.options.math_backend = MathBackend::CfgGated;
131        } else {
132            self.options.math_backend = MathBackend::Std;
133        }
134        self
135    }
136
137    /// Use `f32` precision for generated code.
138    pub fn precision_f32(mut self) -> Self {
139        self.options.precision = Precision::F32;
140        self
141    }
142
143    /// Set whether to emit `#[inline]` annotations on generated functions.
144    pub fn inline(mut self, enabled: bool) -> Self {
145        self.options.inline = enabled;
146        self
147    }
148
149    /// Add a scalar function to the output.
150    ///
151    /// The generated function will take the named parameters as `f64` (or `f32`)
152    /// arguments and return the scalar result.
153    pub fn add_scalar_fn(mut self, name: &str, expr: &Ex, params: &[&str]) -> Self {
154        self.functions.push(GeneratedFn::Scalar {
155            name: name.to_string(),
156            expr: expr.clone(),
157            params: params.iter().map(|s| s.to_string()).collect(),
158        });
159        self
160    }
161
162    /// Add a matrix function to the output.
163    ///
164    /// The generated function will take the named parameters and return
165    /// a flat array `[f64; rows*cols]` in row-major order.
166    pub fn add_matrix_fn(mut self, name: &str, matrix: &Matrix, params: &[&str]) -> Self {
167        self.functions.push(GeneratedFn::Matrix {
168            name: name.to_string(),
169            matrix: matrix.clone(),
170            params: params.iter().map(|s| s.to_string()).collect(),
171        });
172        self
173    }
174
175    /// Enable or disable companion test generation.
176    ///
177    /// When enabled, a `#[cfg(test)] mod generated_tests { ... }` block is
178    /// appended with test functions that evaluate at any configured test points.
179    pub fn with_tests(mut self, enabled: bool) -> Self {
180        self.generate_tests = enabled;
181        self
182    }
183
184    /// Add a test evaluation point.
185    ///
186    /// Each test point is a slice of `f64` values corresponding to the
187    /// function parameters in order. During test generation, each function
188    /// is called with each test point to verify it produces a finite result.
189    pub fn add_test_point(mut self, point: &[f64]) -> Self {
190        self.test_points.push(point.to_vec());
191        self
192    }
193
194    /// Generate the full source file as a `String`.
195    ///
196    /// 1. If the math backend is `CfgGated`, emits the cfg-gated math module
197    ///    (once; the per-function copies are stripped).
198    /// 2. If [`CodegenOptions::emit_runtime`] is set and any registered
199    ///    function uses a special function, emits the `mod symplex_rt`
200    ///    runtime once, containing exactly the helpers the file needs.
201    /// 3. For each registered function, calls the appropriate symplex codegen method.
202    /// 4. If test generation is enabled, emits a `#[cfg(test)]` module.
203    pub fn generate(&self) -> Result<String, Box<dyn std::error::Error>> {
204        let mut output = String::new();
205
206        // File header
207        output.push_str("// Auto-generated by symplex-build. Do not edit.\n\n");
208
209        // Add any custom preamble lines
210        for line in &self.preamble {
211            output.push_str(line);
212            output.push('\n');
213        }
214
215        let emit_cfg_module = self.options.math_backend == MathBackend::CfgGated;
216
217        if emit_cfg_module {
218            // Emit the cfg-gated math module once at the top
219            append_cfg_gated_module(&mut output, self.options.precision);
220            output.push('\n');
221        }
222
223        // Per-function codegen never embeds the runtime: it is emitted once
224        // for the whole file below, after we know which helpers are used.
225        let fn_options = CodegenOptions {
226            emit_runtime: false,
227            ..self.options.clone()
228        };
229
230        let mut functions = String::new();
231        let mut first_fn = true;
232        for gfn in &self.functions {
233            if !first_fn {
234                functions.push('\n');
235            }
236            first_fn = false;
237
238            let code = match gfn {
239                GeneratedFn::Scalar { name, expr, params } => {
240                    let param_refs: Vec<&str> = params.iter().map(|s| s.as_str()).collect();
241                    expr.to_rust_fn_with_options(name, &param_refs, &fn_options)?
242                }
243                GeneratedFn::Matrix {
244                    name,
245                    matrix,
246                    params,
247                } => {
248                    let param_refs: Vec<&str> = params.iter().map(|s| s.as_str()).collect();
249                    matrix.to_rust_fn_with_options(name, &param_refs, &fn_options)?
250                }
251            };
252
253            // If we already emitted the cfg-gated module at the top, strip it
254            // from the per-function output to avoid duplicates.
255            if emit_cfg_module {
256                let stripped = strip_cfg_gated_module(&code);
257                functions.push_str(&stripped);
258            } else {
259                functions.push_str(&code);
260            }
261            functions.push('\n');
262        }
263
264        if self.options.emit_runtime
265            && let Some(runtime) = self.options.runtime_module_for(&functions)
266        {
267            output.push_str(&runtime);
268            output.push_str("\n\n");
269        }
270        output.push_str(&functions);
271
272        // Generate test module if requested
273        if self.generate_tests && !self.test_points.is_empty() {
274            output.push('\n');
275            output.push_str("#[cfg(test)]\n");
276            output.push_str("mod generated_tests {\n");
277            output.push_str("    use super::*;\n\n");
278
279            for (fn_idx, gfn) in self.functions.iter().enumerate() {
280                let (fn_name, param_count) = match gfn {
281                    GeneratedFn::Scalar { name, params, .. } => (name.as_str(), params.len()),
282                    GeneratedFn::Matrix { name, params, .. } => (name.as_str(), params.len()),
283                };
284
285                for (pt_idx, point) in self.test_points.iter().enumerate() {
286                    if point.len() != param_count {
287                        continue;
288                    }
289                    output.push_str(&format!(
290                        "    #[test]\n    fn test_{fn_name}_point_{pt_idx}() {{\n"
291                    ));
292
293                    let args: Vec<String> = point
294                        .iter()
295                        .map(|v| {
296                            let float_ty = match self.options.precision {
297                                Precision::F64 => "f64",
298                                Precision::F32 => "f32",
299                            };
300                            format!("{v}_{float_ty}")
301                        })
302                        .collect();
303                    let args_str = args.join(", ");
304
305                    match &self.functions[fn_idx] {
306                        GeneratedFn::Scalar { .. } => {
307                            output.push_str(&format!(
308                                "        let result = {fn_name}({args_str});\n"
309                            ));
310                            output.push_str("        assert!(result.is_finite(), \"expected finite result, got {}\", result);\n");
311                        }
312                        GeneratedFn::Matrix { matrix, .. } => {
313                            let total = matrix.nrows() * matrix.ncols();
314                            output.push_str(&format!(
315                                "        let result = {fn_name}({args_str});\n"
316                            ));
317                            output.push_str(&format!("        for i in 0..{total} {{\n"));
318                            output.push_str("            assert!(result[i].is_finite(), \"entry {} is not finite: {}\", i, result[i]);\n");
319                            output.push_str("        }\n");
320                        }
321                    }
322
323                    output.push_str("    }\n\n");
324                }
325            }
326
327            output.push_str("}\n");
328        }
329
330        Ok(output)
331    }
332
333    /// Generate code and write it to `$OUT_DIR/<filename>`.
334    ///
335    /// Also prints `cargo:rerun-if-changed=build.rs` so Cargo knows when to
336    /// re-run the build script.
337    pub fn write_to_out_dir(&self, filename: &str) -> Result<(), Box<dyn std::error::Error>> {
338        let out_dir = std::env::var("OUT_DIR")
339            .map_err(|_| "OUT_DIR not set — this function must be called from a build script")?;
340        let path = PathBuf::from(out_dir).join(filename);
341        let code = self.generate()?;
342        fs::write(&path, code)?;
343        println!("cargo:rerun-if-changed=build.rs");
344        Ok(())
345    }
346
347    /// Generate code and write it to an explicit path.
348    pub fn write_to_path(&self, path: impl AsRef<Path>) -> Result<(), Box<dyn std::error::Error>> {
349        let code = self.generate()?;
350        if let Some(parent) = path.as_ref().parent() {
351            fs::create_dir_all(parent)?;
352        }
353        fs::write(path, code)?;
354        Ok(())
355    }
356}
357
358impl Default for CodeGen {
359    fn default() -> Self {
360        Self::new()
361    }
362}
363
364// ═══════════════════════════════════════════════════════════════════════════
365// Helpers for cfg-gated module emission
366// ═══════════════════════════════════════════════════════════════════════════
367
368/// Emit the cfg-gated math wrapper module into the given string buffer.
369///
370/// The module must provide every `math::*` function the symplex Rust backend
371/// can emit with [`MathBackend::CfgGated`]: the elementary functions,
372/// `atan2`, `powf`/`powi`, `min`/`max`, the numerically-optimised forms
373/// `expm1`, `log1p`, `log2`, `exp2`, the fused multiply-add `fma`, and
374/// `sin_cos` (used when both `sin(x)` and `cos(x)` appear).  The `std`
375/// variant delegates to inherent `f64`/`f32` methods; the `no_std` variant
376/// delegates to the `libm` crate.
377fn append_cfg_gated_module(out: &mut String, precision: Precision) {
378    let ft = match precision {
379        Precision::F64 => "f64",
380        Precision::F32 => "f32",
381    };
382
383    let funcs = [
384        "sin", "cos", "tan", "exp", "ln", "abs", "sqrt", "cbrt", "asin", "acos", "atan", "sinh",
385        "cosh", "tanh", "asinh", "acosh", "atanh", "floor", "ceil", "signum",
386    ];
387
388    // std version
389    out.push_str("#[cfg(feature = \"std\")]\n");
390    out.push_str("mod math {\n");
391    for func in &funcs {
392        out.push_str(&format!(
393            "    #[inline] pub fn {func}(x: {ft}) -> {ft} {{ x.{func}() }}\n"
394        ));
395    }
396    out.push_str(&format!(
397        "    #[inline] pub fn atan2(y: {ft}, x: {ft}) -> {ft} {{ y.atan2(x) }}\n"
398    ));
399    out.push_str(&format!(
400        "    #[inline] pub fn powf(base: {ft}, exp: {ft}) -> {ft} {{ base.powf(exp) }}\n"
401    ));
402    out.push_str(&format!(
403        "    #[inline] pub fn powi(base: {ft}, exp: i32) -> {ft} {{ base.powi(exp) }}\n"
404    ));
405    out.push_str(&format!(
406        "    #[inline] pub fn min(a: {ft}, b: {ft}) -> {ft} {{ a.min(b) }}\n"
407    ));
408    out.push_str(&format!(
409        "    #[inline] pub fn max(a: {ft}, b: {ft}) -> {ft} {{ a.max(b) }}\n"
410    ));
411    out.push_str(&format!(
412        "    #[inline] pub fn expm1(x: {ft}) -> {ft} {{ x.exp_m1() }}\n"
413    ));
414    out.push_str(&format!(
415        "    #[inline] pub fn log1p(x: {ft}) -> {ft} {{ x.ln_1p() }}\n"
416    ));
417    out.push_str(&format!(
418        "    #[inline] pub fn log2(x: {ft}) -> {ft} {{ x.log2() }}\n"
419    ));
420    out.push_str(&format!(
421        "    #[inline] pub fn exp2(x: {ft}) -> {ft} {{ x.exp2() }}\n"
422    ));
423    out.push_str(&format!(
424        "    #[inline] pub fn fma(a: {ft}, b: {ft}, c: {ft}) -> {ft} {{ a.mul_add(b, c) }}\n"
425    ));
426    out.push_str(&format!(
427        "    #[inline] pub fn sin_cos(x: {ft}) -> ({ft}, {ft}) {{ x.sin_cos() }}\n"
428    ));
429    out.push_str("}\n\n");
430
431    // no_std (libm) version.  `libm` names differ from the inherent methods
432    // for `abs` (`fabs`) and `ln` (`log`); `signum` and `sin_cos` have no
433    // direct counterpart and are composed.
434    out.push_str("#[cfg(not(feature = \"std\"))]\n");
435    out.push_str("mod math {\n");
436    let libm_funcs = [
437        "sin", "cos", "tan", "exp", "sqrt", "cbrt", "asin", "acos", "atan", "sinh", "cosh", "tanh",
438        "asinh", "acosh", "atanh", "floor", "ceil",
439    ];
440    for func in &libm_funcs {
441        out.push_str(&format!(
442            "    #[inline] pub fn {func}(x: {ft}) -> {ft} {{ libm::{func}(x as f64) as {ft} }}\n"
443        ));
444    }
445    out.push_str(&format!(
446        "    #[inline] pub fn abs(x: {ft}) -> {ft} {{ libm::fabs(x as f64) as {ft} }}\n"
447    ));
448    out.push_str(&format!(
449        "    #[inline] pub fn ln(x: {ft}) -> {ft} {{ libm::log(x as f64) as {ft} }}\n"
450    ));
451    out.push_str(&format!(
452        "    #[inline] pub fn signum(x: {ft}) -> {ft} {{ if x > 0.0 {{ 1.0 }} else if x < 0.0 {{ -1.0 }} else {{ 0.0 }} }}\n"
453    ));
454    out.push_str(&format!(
455        "    #[inline] pub fn atan2(y: {ft}, x: {ft}) -> {ft} {{ libm::atan2(y as f64, x as f64) as {ft} }}\n"
456    ));
457    out.push_str(&format!(
458        "    #[inline] pub fn powf(base: {ft}, exp: {ft}) -> {ft} {{ libm::pow(base as f64, exp as f64) as {ft} }}\n"
459    ));
460    out.push_str(&format!(
461        "    #[inline] pub fn powi(base: {ft}, exp: i32) -> {ft} {{ libm::pow(base as f64, exp as f64) as {ft} }}\n"
462    ));
463    out.push_str(&format!(
464        "    #[inline] pub fn min(a: {ft}, b: {ft}) -> {ft} {{ libm::fmin(a as f64, b as f64) as {ft} }}\n"
465    ));
466    out.push_str(&format!(
467        "    #[inline] pub fn max(a: {ft}, b: {ft}) -> {ft} {{ libm::fmax(a as f64, b as f64) as {ft} }}\n"
468    ));
469    out.push_str(&format!(
470        "    #[inline] pub fn expm1(x: {ft}) -> {ft} {{ libm::expm1(x as f64) as {ft} }}\n"
471    ));
472    out.push_str(&format!(
473        "    #[inline] pub fn log1p(x: {ft}) -> {ft} {{ libm::log1p(x as f64) as {ft} }}\n"
474    ));
475    out.push_str(&format!(
476        "    #[inline] pub fn log2(x: {ft}) -> {ft} {{ libm::log2(x as f64) as {ft} }}\n"
477    ));
478    out.push_str(&format!(
479        "    #[inline] pub fn exp2(x: {ft}) -> {ft} {{ libm::exp2(x as f64) as {ft} }}\n"
480    ));
481    out.push_str(&format!(
482        "    #[inline] pub fn fma(a: {ft}, b: {ft}, c: {ft}) -> {ft} {{ libm::fma(a as f64, b as f64, c as f64) as {ft} }}\n"
483    ));
484    out.push_str(&format!(
485        "    #[inline] pub fn sin_cos(x: {ft}) -> ({ft}, {ft}) {{ (libm::sin(x as f64) as {ft}, libm::cos(x as f64) as {ft}) }}\n"
486    ));
487    out.push_str("}\n");
488}
489
490/// Strip the cfg-gated module block from per-function generated code.
491///
492/// When the module has already been emitted at the file level, we need to
493/// remove duplicates from individual codegen output that also contains it.
494fn strip_cfg_gated_module(code: &str) -> String {
495    let mut result = String::new();
496    let mut lines = code.lines().peekable();
497
498    while let Some(line) = lines.next() {
499        if line.starts_with("#[cfg(") && line.contains("feature") {
500            // Check if next line is "mod math {"
501            if let Some(&next) = lines.peek()
502                && next.starts_with("mod math {")
503            {
504                // Consume the "mod math {" line and skip the whole block
505                lines.next();
506                let mut brace_depth = 1;
507                while brace_depth > 0 {
508                    if let Some(inner) = lines.next() {
509                        for ch in inner.chars() {
510                            if ch == '{' {
511                                brace_depth += 1;
512                            } else if ch == '}' {
513                                brace_depth -= 1;
514                            }
515                        }
516                    } else {
517                        break;
518                    }
519                }
520                // After closing brace, skip any blank line
521                if let Some(&next_after) = lines.peek()
522                    && next_after.trim().is_empty()
523                {
524                    lines.next();
525                }
526                continue;
527            }
528        }
529
530        result.push_str(line);
531        result.push('\n');
532    }
533
534    // Remove leading blank lines
535    let trimmed = result.trim_start_matches('\n');
536    trimmed.to_string()
537}
538
539// ═══════════════════════════════════════════════════════════════════════════
540// TOML robot config reader
541// ═══════════════════════════════════════════════════════════════════════════
542
543/// Robot configuration loaded from a TOML file.
544#[derive(serde::Deserialize)]
545struct RobotConfig {
546    #[allow(dead_code)]
547    robot: RobotInfo,
548    joints: Vec<JointConfig>,
549    generate: GenerateConfig,
550}
551
552/// Basic robot metadata.
553#[derive(serde::Deserialize)]
554struct RobotInfo {
555    #[allow(dead_code)]
556    name: String,
557}
558
559/// Configuration for a single joint using DH parameters.
560#[derive(serde::Deserialize)]
561struct JointConfig {
562    theta: String,
563    #[serde(default)]
564    d: f64,
565    #[serde(default)]
566    a: f64,
567    #[serde(default)]
568    alpha: f64,
569}
570
571fn default_functions() -> Vec<String> {
572    vec!["fk".to_string(), "jacobian".to_string()]
573}
574
575fn default_output() -> String {
576    "robot_math.rs".to_string()
577}
578
579/// What to generate from the robot definition.
580#[derive(serde::Deserialize)]
581struct GenerateConfig {
582    #[serde(default = "default_functions")]
583    functions: Vec<String>,
584    #[serde(default = "default_output")]
585    #[allow(dead_code)]
586    output: String,
587}
588
589/// Load a robot configuration from a TOML file and generate code.
590///
591/// Returns a `CodeGen` builder pre-populated with the functions requested
592/// in the TOML config. Call `.write_to_out_dir()` or `.generate()` on the
593/// result to produce the final source file.
594///
595/// # Example TOML
596///
597/// ```toml
598/// [robot]
599/// name = "two_link"
600///
601/// [[joints]]
602/// theta = "theta1"
603/// a = 0.3
604///
605/// [[joints]]
606/// theta = "theta2"
607/// a = 0.25
608///
609/// [generate]
610/// functions = ["fk", "jacobian"]   # also: "fk_matrix" (full 4×4 transform)
611/// output = "robot_math.rs"
612/// ```
613///
614/// Numeric DH parameters are converted to exact rationals with
615/// [`Context::from_f64_approx`] (`0.3` → `3/10`).
616pub fn from_toml(path: impl AsRef<Path>) -> Result<CodeGen, Box<dyn std::error::Error>> {
617    let content = fs::read_to_string(path.as_ref())?;
618    let config: RobotConfig = toml::from_str(&content)?;
619
620    // All symbolic work for this robot lives in a single private context.
621    let ctx = Context::new();
622
623    // Build DH parameters from config — create symbolic variables for each theta
624    let theta_vars: Vec<Ex> = config.joints.iter().map(|j| ctx.symbol(&j.theta)).collect();
625
626    // Hold the numeric constants in vecs so the borrows below stay valid.
627    let d_vals: Vec<Ex> = config
628        .joints
629        .iter()
630        .map(|j| float_to_expr(&ctx, j.d))
631        .collect();
632    let a_vals: Vec<Ex> = config
633        .joints
634        .iter()
635        .map(|j| float_to_expr(&ctx, j.a))
636        .collect();
637    let alpha_vals: Vec<Ex> = config
638        .joints
639        .iter()
640        .map(|j| float_to_expr(&ctx, j.alpha))
641        .collect();
642
643    let dh_params: Vec<(&Ex, &Ex, &Ex, &Ex)> = theta_vars
644        .iter()
645        .enumerate()
646        .map(|(i, theta)| {
647            (
648                theta as &Ex,
649                &d_vals[i] as &Ex,
650                &a_vals[i] as &Ex,
651                &alpha_vals[i] as &Ex,
652            )
653        })
654        .collect();
655
656    let theta_names: Vec<&str> = config.joints.iter().map(|j| j.theta.as_str()).collect();
657
658    let mut codegen = CodeGen::new();
659
660    for func in &config.generate.functions {
661        match func.as_str() {
662            "fk" => {
663                let (x, y, z) = symplex::robotics::fk_position(&dh_params);
664                codegen = codegen.add_scalar_fn("fk_x", &x, &theta_names);
665                codegen = codegen.add_scalar_fn("fk_y", &y, &theta_names);
666                codegen = codegen.add_scalar_fn("fk_z", &z, &theta_names);
667            }
668            "jacobian" => {
669                let (x, y, _z) = symplex::robotics::fk_position(&dh_params);
670                let theta_refs: Vec<&Ex> = theta_vars.iter().collect();
671                let j = symplex::matrix::jacobian(&[&x, &y], &theta_refs);
672                codegen = codegen.add_matrix_fn("jacobian", &j, &theta_names);
673            }
674            "fk_matrix" => {
675                let t = symplex::robotics::fk_chain(&dh_params);
676                codegen = codegen.add_matrix_fn("fk_matrix", &t, &theta_names);
677            }
678            other => {
679                return Err(format!("unknown generate function: {other}").into());
680            }
681        }
682    }
683
684    Ok(codegen)
685}
686
687/// Convert an `f64` DH parameter to an exact symplex expression.
688///
689/// Uses [`Context::from_f64_approx`] with a denominator bound of one
690/// million, so the value becomes the reduced rational a human-written robot
691/// spec almost always means (`0.3` → `3/10`, `0.25` → `1/4`, `2.0` → `2`)
692/// rather than the exact binary expansion of the float.
693///
694/// # Panics
695///
696/// Panics on `NaN`: a DH table with a NaN entry is a configuration error.
697fn float_to_expr(ctx: &Context, v: f64) -> Ex {
698    ctx.from_f64_approx(v, 1_000_000)
699        .unwrap_or_else(|e| panic!("symplex-build: invalid DH parameter {v}: {e}"))
700}
701
702// ═══════════════════════════════════════════════════════════════════════════
703// robot_arm convenience builder
704// ═══════════════════════════════════════════════════════════════════════════
705
706/// Quick builder for a serial robot arm from DH parameters.
707///
708/// Each tuple is `(theta_name, d, a, alpha)`.
709///
710/// # Examples
711///
712/// ```rust,no_run
713/// // build.rs
714/// symplex_build::robot_arm(&[
715///     ("theta1", 0.0, 0.3, 0.0),
716///     ("theta2", 0.0, 0.25, 0.0),
717/// ])
718/// .generate_all()
719/// .write_to_out_dir("arm.rs")
720/// .unwrap();
721/// ```
722pub fn robot_arm(joints: &[(&str, f64, f64, f64)]) -> RobotArmBuilder {
723    let owned: Vec<(String, f64, f64, f64)> = joints
724        .iter()
725        .map(|(name, d, a, alpha)| (name.to_string(), *d, *a, *alpha))
726        .collect();
727    RobotArmBuilder::new(owned)
728}
729
730/// Builder for generating code for a serial robot arm.
731///
732/// Created by [`robot_arm()`]. Accumulates requested functions and then
733/// delegates to [`CodeGen`] for final output.  All symbolic work happens
734/// in a private [`Context`] owned by the builder.
735pub struct RobotArmBuilder {
736    ctx: Context,
737    joints: Vec<(String, f64, f64, f64)>,
738    codegen: CodeGen,
739    generated_fk: bool,
740    generated_jacobian: bool,
741}
742
743impl RobotArmBuilder {
744    fn new(joints: Vec<(String, f64, f64, f64)>) -> Self {
745        Self {
746            ctx: Context::new(),
747            joints,
748            codegen: CodeGen::new(),
749            generated_fk: false,
750            generated_jacobian: false,
751        }
752    }
753
754    /// Build the symbolic DH parameter tuples and theta variable list.
755    fn build_dh(&self) -> (Vec<Ex>, Vec<Ex>, Vec<Ex>, Vec<Ex>) {
756        let ctx = &self.ctx;
757        let thetas: Vec<Ex> = self
758            .joints
759            .iter()
760            .map(|(name, _, _, _)| ctx.symbol(name))
761            .collect();
762        let d_vals: Vec<Ex> = self
763            .joints
764            .iter()
765            .map(|(_, d, _, _)| float_to_expr(ctx, *d))
766            .collect();
767        let a_vals: Vec<Ex> = self
768            .joints
769            .iter()
770            .map(|(_, _, a, _)| float_to_expr(ctx, *a))
771            .collect();
772        let alpha_vals: Vec<Ex> = self
773            .joints
774            .iter()
775            .map(|(_, _, _, alpha)| float_to_expr(ctx, *alpha))
776            .collect();
777        (thetas, d_vals, a_vals, alpha_vals)
778    }
779
780    fn theta_names_owned(&self) -> Vec<String> {
781        self.joints
782            .iter()
783            .map(|(name, _, _, _)| name.clone())
784            .collect()
785    }
786
787    /// Generate forward kinematics position functions (`fk_x`, `fk_y`, `fk_z`).
788    pub fn generate_fk(mut self, name: &str) -> Self {
789        let (thetas, d_vals, a_vals, alpha_vals) = self.build_dh();
790        let dh: Vec<(&Ex, &Ex, &Ex, &Ex)> = thetas
791            .iter()
792            .enumerate()
793            .map(|(i, t)| {
794                (
795                    t as &Ex,
796                    &d_vals[i] as &Ex,
797                    &a_vals[i] as &Ex,
798                    &alpha_vals[i] as &Ex,
799                )
800            })
801            .collect();
802        let owned_names = self.theta_names_owned();
803        let theta_names: Vec<&str> = owned_names.iter().map(|s| s.as_str()).collect();
804        let (x, y, z) = symplex::robotics::fk_position(&dh);
805
806        let name_x = format!("{name}_x");
807        let name_y = format!("{name}_y");
808        let name_z = format!("{name}_z");
809
810        self.codegen = self.codegen.add_scalar_fn(&name_x, &x, &theta_names);
811        self.codegen = self.codegen.add_scalar_fn(&name_y, &y, &theta_names);
812        self.codegen = self.codegen.add_scalar_fn(&name_z, &z, &theta_names);
813        self.generated_fk = true;
814        self
815    }
816
817    /// Generate the full 4×4 homogeneous forward-kinematics transform as a
818    /// matrix function `name(theta…) -> [f64; 16]` (row-major), via
819    /// [`symplex::robotics::fk_chain`].
820    ///
821    /// The position functions from [`generate_fk`](Self::generate_fk) are
822    /// the last column of this matrix; the upper-left 3×3 block is the
823    /// end-effector rotation.
824    pub fn generate_fk_matrix(mut self, name: &str) -> Self {
825        let (thetas, d_vals, a_vals, alpha_vals) = self.build_dh();
826        let dh: Vec<(&Ex, &Ex, &Ex, &Ex)> = thetas
827            .iter()
828            .enumerate()
829            .map(|(i, t)| {
830                (
831                    t as &Ex,
832                    &d_vals[i] as &Ex,
833                    &a_vals[i] as &Ex,
834                    &alpha_vals[i] as &Ex,
835                )
836            })
837            .collect();
838        let owned_names = self.theta_names_owned();
839        let theta_names: Vec<&str> = owned_names.iter().map(|s| s.as_str()).collect();
840        let t = symplex::robotics::fk_chain(&dh);
841        self.codegen = self.codegen.add_matrix_fn(name, &t, &theta_names);
842        self
843    }
844
845    /// Generate the Jacobian matrix function.
846    pub fn generate_jacobian(mut self, name: &str) -> Self {
847        let (thetas, d_vals, a_vals, alpha_vals) = self.build_dh();
848        let dh: Vec<(&Ex, &Ex, &Ex, &Ex)> = thetas
849            .iter()
850            .enumerate()
851            .map(|(i, t)| {
852                (
853                    t as &Ex,
854                    &d_vals[i] as &Ex,
855                    &a_vals[i] as &Ex,
856                    &alpha_vals[i] as &Ex,
857                )
858            })
859            .collect();
860        let owned_names = self.theta_names_owned();
861        let theta_names: Vec<&str> = owned_names.iter().map(|s| s.as_str()).collect();
862        let (x, y, _z) = symplex::robotics::fk_position(&dh);
863        let theta_refs: Vec<&Ex> = thetas.iter().collect();
864        let j = symplex::matrix::jacobian(&[&x, &y], &theta_refs);
865
866        self.codegen = self.codegen.add_matrix_fn(name, &j, &theta_names);
867        self.generated_jacobian = true;
868        self
869    }
870
871    /// Generate all standard functions (FK position + Jacobian).
872    pub fn generate_all(self) -> Self {
873        let s = if !self.generated_fk {
874            self.generate_fk("fk")
875        } else {
876            self
877        };
878        if !s.generated_jacobian {
879            s.generate_jacobian("jacobian")
880        } else {
881            s
882        }
883    }
884
885    /// Enable `no_std`-compatible output.
886    pub fn no_std(mut self) -> Self {
887        self.codegen = self.codegen.no_std(true);
888        self
889    }
890
891    /// Write generated code to `$OUT_DIR/<filename>`.
892    pub fn write_to_out_dir(self, filename: &str) -> Result<(), Box<dyn std::error::Error>> {
893        self.codegen.write_to_out_dir(filename)
894    }
895
896    /// Write generated code to an explicit path.
897    pub fn write_to_path(self, path: impl AsRef<Path>) -> Result<(), Box<dyn std::error::Error>> {
898        self.codegen.write_to_path(path)
899    }
900
901    /// Get the inner [`CodeGen`] builder for further customization.
902    pub fn into_codegen(self) -> CodeGen {
903        self.codegen
904    }
905}
906
907// ═══════════════════════════════════════════════════════════════════════════
908// Tests
909// ═══════════════════════════════════════════════════════════════════════════
910
911#[cfg(test)]
912mod tests {
913    use super::*;
914
915    #[test]
916    fn codegen_new_default() {
917        let cg = CodeGen::new();
918        // Should create without panic; default has no functions registered
919        assert!(cg.functions.is_empty());
920        assert!(!cg.generate_tests);
921    }
922
923    #[test]
924    fn codegen_default_trait() {
925        // CodeGen::default() and CodeGen::new() should behave identically
926        let cg = CodeGen::default();
927        assert!(cg.functions.is_empty());
928    }
929
930    #[test]
931    fn codegen_generate_empty() {
932        let cg = CodeGen::new();
933        let code = cg.generate().unwrap();
934        // Empty codegen should produce at least the file header
935        assert!(
936            code.contains("Auto-generated by symplex-build"),
937            "expected header comment in generated output, got: {code}"
938        );
939        // No functions registered → no function bodies
940        assert!(
941            !code.contains("fn "),
942            "expected no function definitions in empty codegen"
943        );
944    }
945
946    #[test]
947    fn float_to_expr_is_exact_and_reduced() {
948        let ctx = Context::new();
949        assert_eq!(format!("{}", float_to_expr(&ctx, 0.0)), "0");
950        assert_eq!(format!("{}", float_to_expr(&ctx, 2.0)), "2");
951        assert_eq!(format!("{}", float_to_expr(&ctx, -3.0)), "-3");
952        assert_eq!(format!("{}", float_to_expr(&ctx, 0.3)), "3/10");
953        assert_eq!(format!("{}", float_to_expr(&ctx, 0.25)), "1/4");
954        assert_eq!(format!("{}", float_to_expr(&ctx, 0.1 + 0.2)), "3/10");
955        assert_eq!(format!("{}", float_to_expr(&ctx, 1.0 / 3.0)), "1/3");
956        assert_eq!(format!("{}", float_to_expr(&ctx, 0.123456)), "1929/15625");
957    }
958
959    #[test]
960    fn robot_arm_generates_fk_matrix() {
961        let code = robot_arm(&[("q1", 0.0, 0.3, 0.0), ("q2", 0.1, 0.25, 0.0)])
962            .generate_fk_matrix("fk_t")
963            .into_codegen()
964            .generate()
965            .unwrap();
966        assert!(code.contains("fn fk_t("), "{code}");
967        assert!(
968            code.contains("q1: f64") && code.contains("q2: f64"),
969            "{code}"
970        );
971        // 4×4 homogeneous transform → flat array of 16.
972        assert!(code.contains("[f64; 16]"), "expected 4×4 matrix:\n{code}");
973        // Exact rationals survive into the generated constants (no 0.30000000000000004).
974        assert!(!code.contains("0.30000000000000004"), "{code}");
975    }
976
977    #[test]
978    fn fk_matrix_last_column_matches_fk_position() {
979        // The generated position functions must agree with the last column
980        // of the full transform, so both entry points are consistent.
981        let ctx = Context::new();
982        let (q1, q2) = (ctx.symbol("q1"), ctx.symbol("q2"));
983        let zero = ctx.int(0);
984        let (l1, l2) = (float_to_expr(&ctx, 0.3), float_to_expr(&ctx, 0.25));
985        let dh = [(&q1, &zero, &l1, &zero), (&q2, &zero, &l2, &zero)];
986        let t = symplex::robotics::fk_chain(&dh);
987        let (x, y, z) = symplex::robotics::fk_position(&dh);
988        assert_eq!(t.get(0, 3).eval(), x);
989        assert_eq!(t.get(1, 3).eval(), y);
990        assert_eq!(t.get(2, 3).eval(), z);
991    }
992
993    #[test]
994    fn from_toml_accepts_fk_matrix() {
995        let dir = std::env::temp_dir().join(format!("symplex_build_fkm_{}", std::process::id()));
996        fs::create_dir_all(&dir).unwrap();
997        let path = dir.join("robot.toml");
998        fs::write(
999            &path,
1000            r#"
1001[robot]
1002name = "one_link"
1003[[joints]]
1004theta = "q"
1005a = 0.5
1006[generate]
1007functions = ["fk_matrix"]
1008"#,
1009        )
1010        .unwrap();
1011        let code = from_toml(&path).unwrap().generate().unwrap();
1012        assert!(code.contains("fn fk_matrix("), "{code}");
1013        assert!(code.contains("[f64; 16]"), "{code}");
1014        let _ = fs::remove_dir_all(&dir);
1015    }
1016
1017    #[test]
1018    fn robot_arm_generates_fk_and_jacobian() {
1019        let code = robot_arm(&[("theta1", 0.0, 0.3, 0.0), ("theta2", 0.0, 0.25, 0.0)])
1020            .generate_all()
1021            .into_codegen()
1022            .generate()
1023            .unwrap();
1024
1025        for name in ["fk_x", "fk_y", "fk_z", "jacobian"] {
1026            assert!(
1027                code.contains(&format!("fn {name}(")),
1028                "expected `{name}` in generated code:\n{code}"
1029            );
1030        }
1031        assert!(code.contains("theta1: f64") && code.contains("theta2: f64"));
1032        // Planar arm: the Jacobian is 2×2 → flat array of 4.
1033        assert!(code.contains("[f64; 4]"), "expected 2×2 Jacobian:\n{code}");
1034    }
1035
1036    #[test]
1037    fn robot_arm_no_std_emits_single_math_module() {
1038        let code = robot_arm(&[("q", 0.0, 1.0, 0.0)])
1039            .no_std()
1040            .generate_all()
1041            .into_codegen()
1042            .generate()
1043            .unwrap();
1044        // The cfg-gated math module must be emitted exactly once (std + libm variants).
1045        assert_eq!(code.matches("mod math {").count(), 2, "{code}");
1046    }
1047
1048    /// Two functions that each need the special-function runtime.
1049    fn two_special_fns(opts: CodegenOptions) -> CodeGen {
1050        let ctx = Context::new();
1051        let x = ctx.symbol("x");
1052        let y = ctx.symbol("y");
1053        CodeGen::new()
1054            .options(opts)
1055            .add_scalar_fn("g", &(x.gamma() + &y), &["x", "y"])
1056            .add_scalar_fn("e", &(x.erf() * &y), &["x", "y"])
1057    }
1058
1059    #[test]
1060    fn generate_emits_runtime_module_once_for_two_special_functions() {
1061        let code = two_special_fns(CodegenOptions::default())
1062            .generate()
1063            .unwrap();
1064        assert_eq!(code.matches("mod symplex_rt {").count(), 1, "{code}");
1065        assert!(
1066            code.contains("pub fn gamma(") && code.contains("pub fn erf("),
1067            "{code}"
1068        );
1069        assert!(code.contains("fn g(") && code.contains("fn e("), "{code}");
1070        // The runtime precedes the functions that use it.
1071        assert!(code.find("mod symplex_rt {").unwrap() < code.find("fn g(").unwrap());
1072        // Only the helpers the file needs are embedded.
1073        assert!(!code.contains("pub fn bessel_k("), "{code}");
1074    }
1075
1076    #[test]
1077    fn generate_emits_runtime_module_once_in_no_std_mode() {
1078        let code = two_special_fns(CodegenOptions::no_std())
1079            .generate()
1080            .unwrap();
1081        assert_eq!(code.matches("mod symplex_rt {").count(), 1, "{code}");
1082        assert_eq!(code.matches("mod math {").count(), 2, "{code}");
1083        // Order: mod math, mod symplex_rt, functions.
1084        let math_pos = code.find("mod math {").unwrap();
1085        let rt_pos = code.find("mod symplex_rt {").unwrap();
1086        let fn_pos = code.find("fn g(").unwrap();
1087        assert!(math_pos < rt_pos && rt_pos < fn_pos, "{code}");
1088    }
1089
1090    #[test]
1091    fn generate_honours_emit_runtime_false() {
1092        let opts = CodegenOptions {
1093            emit_runtime: false,
1094            ..Default::default()
1095        };
1096        let code = two_special_fns(opts).generate().unwrap();
1097        assert_eq!(code.matches("mod symplex_rt {").count(), 0, "{code}");
1098        assert!(code.contains("symplex_rt::gamma("), "{code}");
1099    }
1100
1101    #[test]
1102    fn generate_omits_runtime_when_unused() {
1103        let ctx = Context::new();
1104        let x = ctx.symbol("x");
1105        let code = CodeGen::new()
1106            .add_scalar_fn("f", &(x.sin() + x.powi(2)), &["x"])
1107            .generate()
1108            .unwrap();
1109        assert!(!code.contains("mod symplex_rt"), "{code}");
1110    }
1111
1112    /// The two-function file must compile as a library (skipped when
1113    /// `rustc` is not on the PATH).
1114    #[test]
1115    fn generated_file_with_two_special_functions_compiles() {
1116        let Ok(out) = std::process::Command::new("rustc")
1117            .arg("--version")
1118            .output()
1119        else {
1120            eprintln!("rustc not available; skipping compile check");
1121            return;
1122        };
1123        if !out.status.success() {
1124            return;
1125        }
1126        let code = two_special_fns(CodegenOptions::default())
1127            .generate()
1128            .unwrap();
1129        let dir = std::env::temp_dir().join(format!("symplex_build_rt_{}", std::process::id()));
1130        fs::create_dir_all(&dir).unwrap();
1131        let src = dir.join("gen.rs");
1132        fs::write(&src, format!("#![allow(dead_code)]\n{code}")).unwrap();
1133        let out = std::process::Command::new("rustc")
1134            .args(["--crate-type", "lib", "--edition", "2024", "-o"])
1135            .arg(dir.join("gen.rlib"))
1136            .arg(&src)
1137            .output()
1138            .unwrap();
1139        let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
1140        let _ = fs::remove_dir_all(&dir);
1141        assert!(
1142            out.status.success(),
1143            "generated file failed to compile:\n{stderr}\n{code}"
1144        );
1145    }
1146
1147    #[test]
1148    fn from_toml_round_trip() {
1149        let dir = std::env::temp_dir().join(format!("symplex_build_{}", std::process::id()));
1150        fs::create_dir_all(&dir).unwrap();
1151        let path = dir.join("robot.toml");
1152        fs::write(
1153            &path,
1154            r#"
1155[robot]
1156name = "two_link"
1157
1158[[joints]]
1159theta = "theta1"
1160a = 0.3
1161
1162[[joints]]
1163theta = "theta2"
1164a = 0.25
1165
1166[generate]
1167functions = ["fk", "jacobian"]
1168"#,
1169        )
1170        .unwrap();
1171
1172        let code = from_toml(&path).unwrap().generate().unwrap();
1173        assert!(code.contains("fn fk_x("), "{code}");
1174        assert!(code.contains("fn jacobian("), "{code}");
1175
1176        let _ = fs::remove_dir_all(&dir);
1177    }
1178
1179    #[test]
1180    fn from_toml_rejects_unknown_function() {
1181        let dir = std::env::temp_dir().join(format!("symplex_build_bad_{}", std::process::id()));
1182        fs::create_dir_all(&dir).unwrap();
1183        let path = dir.join("robot.toml");
1184        fs::write(
1185            &path,
1186            r#"
1187[robot]
1188name = "r"
1189[[joints]]
1190theta = "q"
1191[generate]
1192functions = ["dynamics"]
1193"#,
1194        )
1195        .unwrap();
1196        let err = from_toml(&path)
1197            .err()
1198            .expect("unknown function should error");
1199        assert!(err.to_string().contains("dynamics"), "{err}");
1200        let _ = fs::remove_dir_all(&dir);
1201    }
1202}