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]).unwrap();
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 is symplex's own ([`CodegenOptions::cfg_gated_math_module`]),
372/// the one every function generated with [`MathBackend::CfgGated`] embeds
373/// and calls (`math::sin`, `math::min`, …).  This crate kept a copy of it
374/// that drifted: its `min`/`max` were `f64::min` / `libm::fmin`, which return
375/// the other operand of a NaN, so `Min(x, √y)` at y < 0 was `x` here and NaN
376/// in `compile()` and in the per-function output.
377fn append_cfg_gated_module(out: &mut String, precision: Precision) {
378    let options = CodegenOptions {
379        precision,
380        ..CodegenOptions::default()
381    };
382    out.push_str(&options.cfg_gated_math_module());
383    out.push('\n');
384}
385
386/// Strip the cfg-gated module block from per-function generated code.
387///
388/// When the module has already been emitted at the file level, we need to
389/// remove duplicates from individual codegen output that also contains it.
390fn strip_cfg_gated_module(code: &str) -> String {
391    let mut result = String::new();
392    let mut lines = code.lines().peekable();
393
394    while let Some(line) = lines.next() {
395        if line.starts_with("#[cfg(") && line.contains("feature") {
396            // Check if next line is "mod math {"
397            if let Some(&next) = lines.peek()
398                && next.starts_with("mod math {")
399            {
400                // Consume the "mod math {" line and skip the whole block
401                lines.next();
402                let mut brace_depth = 1;
403                while brace_depth > 0 {
404                    if let Some(inner) = lines.next() {
405                        for ch in inner.chars() {
406                            if ch == '{' {
407                                brace_depth += 1;
408                            } else if ch == '}' {
409                                brace_depth -= 1;
410                            }
411                        }
412                    } else {
413                        break;
414                    }
415                }
416                // After closing brace, skip any blank line
417                if let Some(&next_after) = lines.peek()
418                    && next_after.trim().is_empty()
419                {
420                    lines.next();
421                }
422                continue;
423            }
424        }
425
426        result.push_str(line);
427        result.push('\n');
428    }
429
430    // Remove leading blank lines
431    let trimmed = result.trim_start_matches('\n');
432    trimmed.to_string()
433}
434
435// ═══════════════════════════════════════════════════════════════════════════
436// TOML robot config reader
437// ═══════════════════════════════════════════════════════════════════════════
438
439/// Robot configuration loaded from a TOML file.
440#[derive(serde::Deserialize)]
441struct RobotConfig {
442    #[allow(dead_code)]
443    robot: RobotInfo,
444    joints: Vec<JointConfig>,
445    generate: GenerateConfig,
446}
447
448/// Basic robot metadata.
449#[derive(serde::Deserialize)]
450struct RobotInfo {
451    #[allow(dead_code)]
452    name: String,
453}
454
455/// Configuration for a single joint using DH parameters.
456#[derive(serde::Deserialize)]
457struct JointConfig {
458    theta: String,
459    #[serde(default)]
460    d: f64,
461    #[serde(default)]
462    a: f64,
463    #[serde(default)]
464    alpha: f64,
465}
466
467fn default_functions() -> Vec<String> {
468    vec!["fk".to_string(), "jacobian".to_string()]
469}
470
471fn default_output() -> String {
472    "robot_math.rs".to_string()
473}
474
475/// What to generate from the robot definition.
476#[derive(serde::Deserialize)]
477struct GenerateConfig {
478    #[serde(default = "default_functions")]
479    functions: Vec<String>,
480    #[serde(default = "default_output")]
481    #[allow(dead_code)]
482    output: String,
483}
484
485/// Load a robot configuration from a TOML file and generate code.
486///
487/// Returns a `CodeGen` builder pre-populated with the functions requested
488/// in the TOML config. Call `.write_to_out_dir()` or `.generate()` on the
489/// result to produce the final source file.
490///
491/// # Example TOML
492///
493/// ```toml
494/// [robot]
495/// name = "two_link"
496///
497/// [[joints]]
498/// theta = "theta1"
499/// a = 0.3
500///
501/// [[joints]]
502/// theta = "theta2"
503/// a = 0.25
504///
505/// [generate]
506/// functions = ["fk", "jacobian"]   # also: "fk_matrix" (full 4×4 transform)
507/// output = "robot_math.rs"
508/// ```
509///
510/// Numeric DH parameters are converted to exact rationals with
511/// [`Context::from_f64_approx`] (`0.3` → `3/10`).
512pub fn from_toml(path: impl AsRef<Path>) -> Result<CodeGen, Box<dyn std::error::Error>> {
513    let content = fs::read_to_string(path.as_ref())?;
514    let config: RobotConfig = toml::from_str(&content)?;
515
516    // All symbolic work for this robot lives in a single private context.
517    let ctx = Context::new();
518
519    // Build DH parameters from config — create symbolic variables for each
520    // theta (an empty name is an error, not a panic).
521    let theta_vars: Vec<Ex> = config
522        .joints
523        .iter()
524        .map(|j| joint_symbol(&ctx, &j.theta))
525        .collect::<Result<_, _>>()?;
526
527    // Hold the numeric constants in vecs so the borrows below stay valid.
528    let d_vals: Vec<Ex> = config
529        .joints
530        .iter()
531        .map(|j| float_to_expr(&ctx, j.d))
532        .collect::<Result<_, _>>()?;
533    let a_vals: Vec<Ex> = config
534        .joints
535        .iter()
536        .map(|j| float_to_expr(&ctx, j.a))
537        .collect::<Result<_, _>>()?;
538    let alpha_vals: Vec<Ex> = config
539        .joints
540        .iter()
541        .map(|j| float_to_expr(&ctx, j.alpha))
542        .collect::<Result<_, _>>()?;
543
544    let dh_params: Vec<DhLink<'_>> = theta_vars
545        .iter()
546        .enumerate()
547        .map(|(i, theta)| DhLink {
548            theta,
549            d: &d_vals[i],
550            a: &a_vals[i],
551            alpha: &alpha_vals[i],
552        })
553        .collect();
554
555    let theta_names: Vec<&str> = config.joints.iter().map(|j| j.theta.as_str()).collect();
556
557    let mut codegen = CodeGen::new();
558
559    for func in &config.generate.functions {
560        match func.as_str() {
561            "fk" => {
562                let (x, y, z) = symplex::robotics::fk_position(&dh_params);
563                codegen = codegen.add_scalar_fn("fk_x", &x, &theta_names);
564                codegen = codegen.add_scalar_fn("fk_y", &y, &theta_names);
565                codegen = codegen.add_scalar_fn("fk_z", &z, &theta_names);
566            }
567            "jacobian" => {
568                let (x, y, _z) = symplex::robotics::fk_position(&dh_params);
569                let theta_refs: Vec<&Ex> = theta_vars.iter().collect();
570                let j = symplex::matrix::jacobian(&[&x, &y], &theta_refs)?;
571                codegen = codegen.add_matrix_fn("jacobian", &j, &theta_names);
572            }
573            "fk_matrix" => {
574                let t = symplex::robotics::fk_chain(&dh_params);
575                codegen = codegen.add_matrix_fn("fk_matrix", &t, &theta_names);
576            }
577            other => {
578                return Err(format!("unknown generate function: {other}").into());
579            }
580        }
581    }
582
583    Ok(codegen)
584}
585
586/// The joint variable named `name`.
587///
588/// # Errors
589///
590/// [`SymplexError::InvalidArgument`](symplex::errors::SymplexError::InvalidArgument)
591/// for an empty name (a configuration error; [`Context::symbol`] would
592/// panic on it).
593fn joint_symbol(ctx: &Context, name: &str) -> Result<Ex, symplex::errors::SymplexError> {
594    ctx.try_symbol(name).map_err(|_| {
595        symplex::errors::SymplexError::invalid_argument(
596            "symplex-build",
597            "a joint's variable name must not be empty",
598        )
599    })
600}
601
602/// Convert an `f64` DH parameter to an exact symplex expression.
603///
604/// Uses [`Context::from_f64_approx`] with a denominator bound of one
605/// million, so the value becomes the reduced rational a human-written robot
606/// spec almost always means (`0.3` → `3/10`, `0.25` → `1/4`, `2.0` → `2`)
607/// rather than the exact binary expansion of the float.
608///
609/// # Errors
610///
611/// [`SymplexError::InvalidArgument`](symplex::errors::SymplexError::InvalidArgument)
612/// for a `NaN` or infinite entry: a configuration error (a `NaN`, which
613/// TOML can spell `nan`, used to panic here).
614fn float_to_expr(ctx: &Context, v: f64) -> Result<Ex, symplex::errors::SymplexError> {
615    if !v.is_finite() {
616        return Err(symplex::errors::SymplexError::invalid_argument(
617            "symplex-build",
618            format!("a DH parameter must be a finite number, got {v}"),
619        ));
620    }
621    ctx.from_f64_approx(v, 1_000_000)
622}
623
624// ═══════════════════════════════════════════════════════════════════════════
625// robot_arm convenience builder
626// ═══════════════════════════════════════════════════════════════════════════
627
628/// Quick builder for a serial robot arm from DH parameters.
629///
630/// Each tuple is `(theta_name, d, a, alpha)`.
631///
632/// # Examples
633///
634/// ```rust,no_run
635/// // build.rs
636/// symplex_build::robot_arm(&[
637///     ("theta1", 0.0, 0.3, 0.0),
638///     ("theta2", 0.0, 0.25, 0.0),
639/// ])
640/// .generate_all()
641/// .write_to_out_dir("arm.rs")
642/// .unwrap();
643/// ```
644pub fn robot_arm(joints: &[(&str, f64, f64, f64)]) -> RobotArmBuilder {
645    let owned: Vec<(String, f64, f64, f64)> = joints
646        .iter()
647        .map(|(name, d, a, alpha)| (name.to_string(), *d, *a, *alpha))
648        .collect();
649    RobotArmBuilder::new(owned)
650}
651
652/// The symbolic DH table of a [`RobotArmBuilder`]: one entry per joint.
653#[derive(Default)]
654struct DhTable {
655    thetas: Vec<Ex>,
656    d: Vec<Ex>,
657    a: Vec<Ex>,
658    alpha: Vec<Ex>,
659}
660
661impl DhTable {
662    /// The links, borrowing the table.
663    fn links(&self) -> Vec<DhLink<'_>> {
664        self.thetas
665            .iter()
666            .zip(&self.d)
667            .zip(&self.a)
668            .zip(&self.alpha)
669            .map(|(((theta, d), a), alpha)| DhLink { theta, d, a, alpha })
670            .collect()
671    }
672}
673
674/// Builder for generating code for a serial robot arm.
675///
676/// Created by [`robot_arm()`]. Accumulates requested functions and then
677/// delegates to [`CodeGen`] for final output.  All symbolic work happens
678/// in a private [`Context`] owned by the builder.
679///
680/// A function that cannot be generated (a Jacobian for an arm without
681/// joints; any function of an arm with an empty joint name or a `NaN` or
682/// infinite DH parameter) is reported by
683/// [`write_to_out_dir`](Self::write_to_out_dir) /
684/// [`write_to_path`](Self::write_to_path);
685/// [`into_codegen`](Self::into_codegen) returns the functions that could
686/// be generated.
687pub struct RobotArmBuilder {
688    ctx: Context,
689    joints: Vec<(String, f64, f64, f64)>,
690    codegen: CodeGen,
691    generated_fk: bool,
692    generated_jacobian: bool,
693    error: Option<symplex::errors::SymplexError>,
694}
695
696impl RobotArmBuilder {
697    fn new(joints: Vec<(String, f64, f64, f64)>) -> Self {
698        Self {
699            ctx: Context::new(),
700            joints,
701            codegen: CodeGen::new(),
702            generated_fk: false,
703            generated_jacobian: false,
704            error: None,
705        }
706    }
707
708    /// Build the symbolic DH parameter tuples and theta variable list.
709    ///
710    /// # Errors
711    ///
712    /// An empty joint name or a non-finite DH parameter (see
713    /// [`joint_symbol`], [`float_to_expr`]); the `generate_*` methods keep
714    /// it for [`write_to_out_dir`](Self::write_to_out_dir) /
715    /// [`write_to_path`](Self::write_to_path) to report.  (An empty name
716    /// panicked in `Context::symbol`.)
717    fn build_dh(&self) -> Result<DhTable, symplex::errors::SymplexError> {
718        let ctx = &self.ctx;
719        let mut table = DhTable::default();
720        for (name, d, a, alpha) in &self.joints {
721            table.thetas.push(joint_symbol(ctx, name)?);
722            table.d.push(float_to_expr(ctx, *d)?);
723            table.a.push(float_to_expr(ctx, *a)?);
724            table.alpha.push(float_to_expr(ctx, *alpha)?);
725        }
726        Ok(table)
727    }
728
729    /// [`build_dh`](Self::build_dh), recording its error (the first one
730    /// wins) and returning `None` so the caller generates nothing.
731    fn dh_or_record(&mut self) -> Option<DhTable> {
732        match self.build_dh() {
733            Ok(table) => Some(table),
734            Err(e) => {
735                self.error = self.error.take().or(Some(e));
736                None
737            }
738        }
739    }
740
741    fn theta_names_owned(&self) -> Vec<String> {
742        self.joints
743            .iter()
744            .map(|(name, _, _, _)| name.clone())
745            .collect()
746    }
747
748    /// Generate forward kinematics position functions (`fk_x`, `fk_y`, `fk_z`).
749    pub fn generate_fk(mut self, name: &str) -> Self {
750        let Some(table) = self.dh_or_record() else {
751            self.generated_fk = true;
752            return self;
753        };
754        let dh = table.links();
755        let owned_names = self.theta_names_owned();
756        let theta_names: Vec<&str> = owned_names.iter().map(|s| s.as_str()).collect();
757        let (x, y, z) = symplex::robotics::fk_position(&dh);
758
759        let name_x = format!("{name}_x");
760        let name_y = format!("{name}_y");
761        let name_z = format!("{name}_z");
762
763        self.codegen = self.codegen.add_scalar_fn(&name_x, &x, &theta_names);
764        self.codegen = self.codegen.add_scalar_fn(&name_y, &y, &theta_names);
765        self.codegen = self.codegen.add_scalar_fn(&name_z, &z, &theta_names);
766        self.generated_fk = true;
767        self
768    }
769
770    /// Generate the full 4×4 homogeneous forward-kinematics transform as a
771    /// matrix function `name(theta…) -> [f64; 16]` (row-major), via
772    /// [`symplex::robotics::fk_chain`].
773    ///
774    /// The position functions from [`generate_fk`](Self::generate_fk) are
775    /// the last column of this matrix; the upper-left 3×3 block is the
776    /// end-effector rotation.
777    pub fn generate_fk_matrix(mut self, name: &str) -> Self {
778        let Some(table) = self.dh_or_record() else {
779            return self;
780        };
781        let dh = table.links();
782        let owned_names = self.theta_names_owned();
783        let theta_names: Vec<&str> = owned_names.iter().map(|s| s.as_str()).collect();
784        let t = symplex::robotics::fk_chain(&dh);
785        self.codegen = self.codegen.add_matrix_fn(name, &t, &theta_names);
786        self
787    }
788
789    /// Generate the Jacobian matrix function.
790    pub fn generate_jacobian(mut self, name: &str) -> Self {
791        let Some(table) = self.dh_or_record() else {
792            self.generated_jacobian = true;
793            return self;
794        };
795        let dh = table.links();
796        let owned_names = self.theta_names_owned();
797        let theta_names: Vec<&str> = owned_names.iter().map(|s| s.as_str()).collect();
798        let (x, y, _z) = symplex::robotics::fk_position(&dh);
799        let theta_refs: Vec<&Ex> = table.thetas.iter().collect();
800        match symplex::matrix::jacobian(&[&x, &y], &theta_refs) {
801            Ok(j) => self.codegen = self.codegen.add_matrix_fn(name, &j, &theta_names),
802            Err(e) => self.error = self.error.or(Some(e)),
803        }
804        self.generated_jacobian = true;
805        self
806    }
807
808    /// Generate all standard functions (FK position + Jacobian).
809    pub fn generate_all(self) -> Self {
810        let s = if !self.generated_fk {
811            self.generate_fk("fk")
812        } else {
813            self
814        };
815        if !s.generated_jacobian {
816            s.generate_jacobian("jacobian")
817        } else {
818            s
819        }
820    }
821
822    /// Enable `no_std`-compatible output.
823    pub fn no_std(mut self) -> Self {
824        self.codegen = self.codegen.no_std(true);
825        self
826    }
827
828    /// Write generated code to `$OUT_DIR/<filename>`.
829    ///
830    /// # Errors
831    ///
832    /// A function that could not be generated (see [`RobotArmBuilder`]), or
833    /// the error of [`CodeGen::write_to_out_dir`].
834    pub fn write_to_out_dir(self, filename: &str) -> Result<(), Box<dyn std::error::Error>> {
835        if let Some(e) = self.error {
836            return Err(e.into());
837        }
838        self.codegen.write_to_out_dir(filename)
839    }
840
841    /// Write generated code to an explicit path.
842    ///
843    /// # Errors
844    ///
845    /// A function that could not be generated (see [`RobotArmBuilder`]), or
846    /// the error of [`CodeGen::write_to_path`].
847    pub fn write_to_path(self, path: impl AsRef<Path>) -> Result<(), Box<dyn std::error::Error>> {
848        if let Some(e) = self.error {
849            return Err(e.into());
850        }
851        self.codegen.write_to_path(path)
852    }
853
854    /// Get the inner [`CodeGen`] builder for further customization.
855    pub fn into_codegen(self) -> CodeGen {
856        self.codegen
857    }
858}
859
860// ═══════════════════════════════════════════════════════════════════════════
861// Tests
862// ═══════════════════════════════════════════════════════════════════════════
863
864#[cfg(test)]
865mod tests {
866    use super::*;
867
868    #[test]
869    fn codegen_new_default() {
870        let cg = CodeGen::new();
871        // Should create without panic; default has no functions registered
872        assert!(cg.functions.is_empty());
873        assert!(!cg.generate_tests);
874    }
875
876    #[test]
877    fn codegen_default_trait() {
878        // CodeGen::default() and CodeGen::new() should behave identically
879        let cg = CodeGen::default();
880        assert!(cg.functions.is_empty());
881    }
882
883    #[test]
884    fn codegen_generate_empty() {
885        let cg = CodeGen::new();
886        let code = cg.generate().unwrap();
887        // Empty codegen should produce at least the file header
888        assert!(
889            code.contains("Auto-generated by symplex-build"),
890            "expected header comment in generated output, got: {code}"
891        );
892        // No functions registered → no function bodies
893        assert!(
894            !code.contains("fn "),
895            "expected no function definitions in empty codegen"
896        );
897    }
898
899    #[test]
900    fn float_to_expr_is_exact_and_reduced() {
901        let ctx = Context::new();
902        let show = |v: f64| format!("{}", float_to_expr(&ctx, v).unwrap());
903        assert_eq!(show(0.0), "0");
904        assert_eq!(show(2.0), "2");
905        assert_eq!(show(-3.0), "-3");
906        assert_eq!(show(0.3), "3/10");
907        assert_eq!(show(0.25), "1/4");
908        assert_eq!(show(0.1 + 0.2), "3/10");
909        assert_eq!(show(1.0 / 3.0), "1/3");
910        assert_eq!(show(0.123456), "1929/15625");
911        // A NaN or infinite entry is an error (a NaN panicked).
912        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
913            assert!(float_to_expr(&ctx, bad).is_err(), "{bad}");
914        }
915    }
916
917    #[test]
918    fn robot_arm_generates_fk_matrix() {
919        let code = robot_arm(&[("q1", 0.0, 0.3, 0.0), ("q2", 0.1, 0.25, 0.0)])
920            .generate_fk_matrix("fk_t")
921            .into_codegen()
922            .generate()
923            .unwrap();
924        assert!(code.contains("fn fk_t("), "{code}");
925        assert!(
926            code.contains("q1: f64") && code.contains("q2: f64"),
927            "{code}"
928        );
929        // 4×4 homogeneous transform → flat array of 16.
930        assert!(code.contains("[f64; 16]"), "expected 4×4 matrix:\n{code}");
931        // Exact rationals survive into the generated constants (no 0.30000000000000004).
932        assert!(!code.contains("0.30000000000000004"), "{code}");
933    }
934
935    #[test]
936    fn fk_matrix_last_column_matches_fk_position() {
937        // The generated position functions must agree with the last column
938        // of the full transform, so both entry points are consistent.
939        let ctx = Context::new();
940        let (q1, q2) = (ctx.symbol("q1"), ctx.symbol("q2"));
941        let zero = ctx.int(0);
942        let (l1, l2) = (
943            float_to_expr(&ctx, 0.3).unwrap(),
944            float_to_expr(&ctx, 0.25).unwrap(),
945        );
946        let dh = [
947            DhLink {
948                theta: &q1,
949                d: &zero,
950                a: &l1,
951                alpha: &zero,
952            },
953            DhLink {
954                theta: &q2,
955                d: &zero,
956                a: &l2,
957                alpha: &zero,
958            },
959        ];
960        let t = symplex::robotics::fk_chain(&dh);
961        let (x, y, z) = symplex::robotics::fk_position(&dh);
962        assert_eq!(t.get(0, 3).eval(), x);
963        assert_eq!(t.get(1, 3).eval(), y);
964        assert_eq!(t.get(2, 3).eval(), z);
965    }
966
967    #[test]
968    fn from_toml_accepts_fk_matrix() {
969        let dir = std::env::temp_dir().join(format!("symplex_build_fkm_{}", std::process::id()));
970        fs::create_dir_all(&dir).unwrap();
971        let path = dir.join("robot.toml");
972        fs::write(
973            &path,
974            r#"
975[robot]
976name = "one_link"
977[[joints]]
978theta = "q"
979a = 0.5
980[generate]
981functions = ["fk_matrix"]
982"#,
983        )
984        .unwrap();
985        let code = from_toml(&path).unwrap().generate().unwrap();
986        assert!(code.contains("fn fk_matrix("), "{code}");
987        assert!(code.contains("[f64; 16]"), "{code}");
988        let _ = fs::remove_dir_all(&dir);
989    }
990
991    #[test]
992    fn robot_arm_generates_fk_and_jacobian() {
993        let code = robot_arm(&[("theta1", 0.0, 0.3, 0.0), ("theta2", 0.0, 0.25, 0.0)])
994            .generate_all()
995            .into_codegen()
996            .generate()
997            .unwrap();
998
999        for name in ["fk_x", "fk_y", "fk_z", "jacobian"] {
1000            assert!(
1001                code.contains(&format!("fn {name}(")),
1002                "expected `{name}` in generated code:\n{code}"
1003            );
1004        }
1005        assert!(code.contains("theta1: f64") && code.contains("theta2: f64"));
1006        // Planar arm: the Jacobian is 2×2 → flat array of 4.
1007        assert!(code.contains("[f64; 4]"), "expected 2×2 Jacobian:\n{code}");
1008    }
1009
1010    #[test]
1011    fn robot_arm_no_std_emits_single_math_module() {
1012        let code = robot_arm(&[("q", 0.0, 1.0, 0.0)])
1013            .no_std()
1014            .generate_all()
1015            .into_codegen()
1016            .generate()
1017            .unwrap();
1018        // The cfg-gated math module must be emitted exactly once (std + libm variants).
1019        assert_eq!(code.matches("mod math {").count(), 2, "{code}");
1020    }
1021
1022    /// The file-level `mod math` is symplex's own: its `min`/`max`
1023    /// propagate NaN like `compile()`.  This crate's copy used `f64::min`
1024    /// and `libm::fmin`, which return the other operand of a NaN, so
1025    /// `min(x, √y)` at y < 0 was `x` (`compile()`: NaN).
1026    #[test]
1027    fn no_std_min_max_propagate_nan() {
1028        let ctx = Context::new();
1029        let (x, y) = (ctx.symbol("x"), ctx.symbol("y"));
1030        let e = x.min_with(&y.sqrt());
1031        assert!(e.compile(&["x", "y"]).unwrap().call(&[1.0, -1.0]).is_nan());
1032        let code = CodeGen::new()
1033            .no_std(true)
1034            .add_scalar_fn("m", &e, &["x", "y"])
1035            .generate()
1036            .unwrap();
1037        assert_eq!(code.matches("mod math {").count(), 2, "{code}");
1038        assert!(
1039            !code.contains("a.min(b)") && !code.contains("libm::fmin"),
1040            "{code}"
1041        );
1042        assert_eq!(
1043            code.matches(
1044                "pub fn min(a: f64, b: f64) -> f64 { if a <= b || a.is_nan() { a } else { b } }"
1045            )
1046            .count(),
1047            2,
1048            "{code}"
1049        );
1050        assert!(code.contains("math::min("), "{code}");
1051    }
1052
1053    /// Two functions that each need the special-function runtime.
1054    fn two_special_fns(opts: CodegenOptions) -> CodeGen {
1055        let ctx = Context::new();
1056        let x = ctx.symbol("x");
1057        let y = ctx.symbol("y");
1058        CodeGen::new()
1059            .options(opts)
1060            .add_scalar_fn("g", &(x.gamma() + &y), &["x", "y"])
1061            .add_scalar_fn("e", &(x.erf() * &y), &["x", "y"])
1062    }
1063
1064    #[test]
1065    fn generate_emits_runtime_module_once_for_two_special_functions() {
1066        let code = two_special_fns(CodegenOptions::default())
1067            .generate()
1068            .unwrap();
1069        assert_eq!(code.matches("mod symplex_rt {").count(), 1, "{code}");
1070        assert!(
1071            code.contains("pub fn gamma(") && code.contains("pub fn erf("),
1072            "{code}"
1073        );
1074        assert!(code.contains("fn g(") && code.contains("fn e("), "{code}");
1075        // The runtime precedes the functions that use it.
1076        assert!(code.find("mod symplex_rt {").unwrap() < code.find("fn g(").unwrap());
1077        // Only the helpers the file needs are embedded.
1078        assert!(!code.contains("pub fn bessel_k("), "{code}");
1079    }
1080
1081    #[test]
1082    fn generate_emits_runtime_module_once_in_no_std_mode() {
1083        let code = two_special_fns(CodegenOptions::no_std())
1084            .generate()
1085            .unwrap();
1086        assert_eq!(code.matches("mod symplex_rt {").count(), 1, "{code}");
1087        assert_eq!(code.matches("mod math {").count(), 2, "{code}");
1088        // Order: mod math, mod symplex_rt, functions.
1089        let math_pos = code.find("mod math {").unwrap();
1090        let rt_pos = code.find("mod symplex_rt {").unwrap();
1091        let fn_pos = code.find("fn g(").unwrap();
1092        assert!(math_pos < rt_pos && rt_pos < fn_pos, "{code}");
1093    }
1094
1095    #[test]
1096    fn generate_honours_emit_runtime_false() {
1097        let opts = CodegenOptions {
1098            emit_runtime: false,
1099            ..Default::default()
1100        };
1101        let code = two_special_fns(opts).generate().unwrap();
1102        assert_eq!(code.matches("mod symplex_rt {").count(), 0, "{code}");
1103        assert!(code.contains("symplex_rt::gamma("), "{code}");
1104    }
1105
1106    #[test]
1107    fn generate_omits_runtime_when_unused() {
1108        let ctx = Context::new();
1109        let x = ctx.symbol("x");
1110        let code = CodeGen::new()
1111            .add_scalar_fn("f", &(x.sin() + x.powi(2)), &["x"])
1112            .generate()
1113            .unwrap();
1114        assert!(!code.contains("mod symplex_rt"), "{code}");
1115    }
1116
1117    /// The two-function file must compile as a library (skipped when
1118    /// `rustc` is not on the PATH).
1119    #[test]
1120    fn generated_file_with_two_special_functions_compiles() {
1121        let Ok(out) = std::process::Command::new("rustc")
1122            .arg("--version")
1123            .output()
1124        else {
1125            eprintln!("rustc not available; skipping compile check");
1126            return;
1127        };
1128        if !out.status.success() {
1129            return;
1130        }
1131        let code = two_special_fns(CodegenOptions::default())
1132            .generate()
1133            .unwrap();
1134        let dir = std::env::temp_dir().join(format!("symplex_build_rt_{}", std::process::id()));
1135        fs::create_dir_all(&dir).unwrap();
1136        let src = dir.join("gen.rs");
1137        fs::write(&src, format!("#![allow(dead_code)]\n{code}")).unwrap();
1138        let out = std::process::Command::new("rustc")
1139            .args(["--crate-type", "lib", "--edition", "2024", "-o"])
1140            .arg(dir.join("gen.rlib"))
1141            .arg(&src)
1142            .output()
1143            .unwrap();
1144        let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
1145        let _ = fs::remove_dir_all(&dir);
1146        assert!(
1147            out.status.success(),
1148            "generated file failed to compile:\n{stderr}\n{code}"
1149        );
1150    }
1151
1152    #[test]
1153    fn from_toml_round_trip() {
1154        let dir = std::env::temp_dir().join(format!("symplex_build_{}", std::process::id()));
1155        fs::create_dir_all(&dir).unwrap();
1156        let path = dir.join("robot.toml");
1157        fs::write(
1158            &path,
1159            r#"
1160[robot]
1161name = "two_link"
1162
1163[[joints]]
1164theta = "theta1"
1165a = 0.3
1166
1167[[joints]]
1168theta = "theta2"
1169a = 0.25
1170
1171[generate]
1172functions = ["fk", "jacobian"]
1173"#,
1174        )
1175        .unwrap();
1176
1177        let code = from_toml(&path).unwrap().generate().unwrap();
1178        assert!(code.contains("fn fk_x("), "{code}");
1179        assert!(code.contains("fn jacobian("), "{code}");
1180
1181        let _ = fs::remove_dir_all(&dir);
1182    }
1183
1184    #[test]
1185    fn from_toml_rejects_unknown_function() {
1186        let dir = std::env::temp_dir().join(format!("symplex_build_bad_{}", std::process::id()));
1187        fs::create_dir_all(&dir).unwrap();
1188        let path = dir.join("robot.toml");
1189        fs::write(
1190            &path,
1191            r#"
1192[robot]
1193name = "r"
1194[[joints]]
1195theta = "q"
1196[generate]
1197functions = ["dynamics"]
1198"#,
1199        )
1200        .unwrap();
1201        let err = from_toml(&path)
1202            .err()
1203            .expect("unknown function should error");
1204        assert!(err.to_string().contains("dynamics"), "{err}");
1205        let _ = fs::remove_dir_all(&dir);
1206    }
1207
1208    /// An empty joint name panicked in `build_dh` (`Context::symbol("")`)
1209    /// from every `generate_*`; it is now the builder's error, reported by
1210    /// `write_to_path`, and nothing is generated for the arm.  A `NaN` DH
1211    /// parameter (which panicked in `float_to_expr`) likewise.
1212    #[test]
1213    fn robot_arm_reports_an_empty_joint_name_instead_of_panicking() {
1214        let dir = std::env::temp_dir().join(format!("symplex_build_empty_{}", std::process::id()));
1215        let path = dir.join("arm.rs");
1216        let err = robot_arm(&[("q1", 0.0, 0.3, 0.0), ("", 0.0, 0.25, 0.0)])
1217            .generate_all()
1218            .generate_fk_matrix("fk_t")
1219            .write_to_path(&path)
1220            .expect_err("an empty joint name is an error");
1221        assert!(err.to_string().contains("name must not be empty"), "{err}");
1222        assert!(!path.exists(), "nothing is written");
1223        let generated = robot_arm(&[("", 0.0, 0.3, 0.0)])
1224            .generate_fk("fk")
1225            .into_codegen();
1226        assert!(generated.functions.is_empty());
1227        let err = robot_arm(&[("q1", f64::NAN, 0.3, 0.0)])
1228            .generate_jacobian("j")
1229            .write_to_path(&path)
1230            .expect_err("a NaN DH parameter is an error");
1231        assert!(err.to_string().contains("finite"), "{err}");
1232        assert!(!path.exists(), "nothing is written");
1233    }
1234
1235    /// `from_toml` with `theta = ""` (or `d = nan`) panicked the same way;
1236    /// it is now its error.
1237    #[test]
1238    fn from_toml_rejects_an_empty_joint_name_and_a_nan_parameter() {
1239        let dir =
1240            std::env::temp_dir().join(format!("symplex_build_empty_toml_{}", std::process::id()));
1241        fs::create_dir_all(&dir).unwrap();
1242        let path = dir.join("robot.toml");
1243        for (joint, needle) in [
1244            ("theta = \"\"", "name must not be empty"),
1245            ("theta = \"q\"\nd = nan", "finite"),
1246        ] {
1247            fs::write(
1248                &path,
1249                format!(
1250                    "[robot]\nname = \"r\"\n[[joints]]\n{joint}\n[generate]\nfunctions = [\"fk\"]\n"
1251                ),
1252            )
1253            .unwrap();
1254            let err = from_toml(&path).err().expect("a bad joint is an error");
1255            assert!(err.to_string().contains(needle), "{err}");
1256        }
1257        let _ = fs::remove_dir_all(&dir);
1258    }
1259}