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