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 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
625    // theta (an empty name is an error, not a panic).
626    let theta_vars: Vec<Ex> = config
627        .joints
628        .iter()
629        .map(|j| joint_symbol(&ctx, &j.theta))
630        .collect::<Result<_, _>>()?;
631
632    // Hold the numeric constants in vecs so the borrows below stay valid.
633    let d_vals: Vec<Ex> = config
634        .joints
635        .iter()
636        .map(|j| float_to_expr(&ctx, j.d))
637        .collect::<Result<_, _>>()?;
638    let a_vals: Vec<Ex> = config
639        .joints
640        .iter()
641        .map(|j| float_to_expr(&ctx, j.a))
642        .collect::<Result<_, _>>()?;
643    let alpha_vals: Vec<Ex> = config
644        .joints
645        .iter()
646        .map(|j| float_to_expr(&ctx, j.alpha))
647        .collect::<Result<_, _>>()?;
648
649    let dh_params: Vec<DhLink<'_>> = theta_vars
650        .iter()
651        .enumerate()
652        .map(|(i, theta)| DhLink {
653            theta,
654            d: &d_vals[i],
655            a: &a_vals[i],
656            alpha: &alpha_vals[i],
657        })
658        .collect();
659
660    let theta_names: Vec<&str> = config.joints.iter().map(|j| j.theta.as_str()).collect();
661
662    let mut codegen = CodeGen::new();
663
664    for func in &config.generate.functions {
665        match func.as_str() {
666            "fk" => {
667                let (x, y, z) = symplex::robotics::fk_position(&dh_params);
668                codegen = codegen.add_scalar_fn("fk_x", &x, &theta_names);
669                codegen = codegen.add_scalar_fn("fk_y", &y, &theta_names);
670                codegen = codegen.add_scalar_fn("fk_z", &z, &theta_names);
671            }
672            "jacobian" => {
673                let (x, y, _z) = symplex::robotics::fk_position(&dh_params);
674                let theta_refs: Vec<&Ex> = theta_vars.iter().collect();
675                let j = symplex::matrix::jacobian(&[&x, &y], &theta_refs)?;
676                codegen = codegen.add_matrix_fn("jacobian", &j, &theta_names);
677            }
678            "fk_matrix" => {
679                let t = symplex::robotics::fk_chain(&dh_params);
680                codegen = codegen.add_matrix_fn("fk_matrix", &t, &theta_names);
681            }
682            other => {
683                return Err(format!("unknown generate function: {other}").into());
684            }
685        }
686    }
687
688    Ok(codegen)
689}
690
691/// The joint variable named `name`.
692///
693/// # Errors
694///
695/// [`SymplexError::InvalidArgument`](symplex::errors::SymplexError::InvalidArgument)
696/// for an empty name (a configuration error; [`Context::symbol`] would
697/// panic on it).
698fn joint_symbol(ctx: &Context, name: &str) -> Result<Ex, symplex::errors::SymplexError> {
699    ctx.try_symbol(name).map_err(|_| {
700        symplex::errors::SymplexError::invalid_argument(
701            "symplex-build",
702            "a joint's variable name must not be empty",
703        )
704    })
705}
706
707/// Convert an `f64` DH parameter to an exact symplex expression.
708///
709/// Uses [`Context::from_f64_approx`] with a denominator bound of one
710/// million, so the value becomes the reduced rational a human-written robot
711/// spec almost always means (`0.3` → `3/10`, `0.25` → `1/4`, `2.0` → `2`)
712/// rather than the exact binary expansion of the float.
713///
714/// # Errors
715///
716/// [`SymplexError::InvalidArgument`](symplex::errors::SymplexError::InvalidArgument)
717/// for a `NaN` or infinite entry: a configuration error (a `NaN`, which
718/// TOML can spell `nan`, used to panic here).
719fn float_to_expr(ctx: &Context, v: f64) -> Result<Ex, symplex::errors::SymplexError> {
720    if !v.is_finite() {
721        return Err(symplex::errors::SymplexError::invalid_argument(
722            "symplex-build",
723            format!("a DH parameter must be a finite number, got {v}"),
724        ));
725    }
726    ctx.from_f64_approx(v, 1_000_000)
727}
728
729// ═══════════════════════════════════════════════════════════════════════════
730// robot_arm convenience builder
731// ═══════════════════════════════════════════════════════════════════════════
732
733/// Quick builder for a serial robot arm from DH parameters.
734///
735/// Each tuple is `(theta_name, d, a, alpha)`.
736///
737/// # Examples
738///
739/// ```rust,no_run
740/// // build.rs
741/// symplex_build::robot_arm(&[
742///     ("theta1", 0.0, 0.3, 0.0),
743///     ("theta2", 0.0, 0.25, 0.0),
744/// ])
745/// .generate_all()
746/// .write_to_out_dir("arm.rs")
747/// .unwrap();
748/// ```
749pub fn robot_arm(joints: &[(&str, f64, f64, f64)]) -> RobotArmBuilder {
750    let owned: Vec<(String, f64, f64, f64)> = joints
751        .iter()
752        .map(|(name, d, a, alpha)| (name.to_string(), *d, *a, *alpha))
753        .collect();
754    RobotArmBuilder::new(owned)
755}
756
757/// The symbolic DH table of a [`RobotArmBuilder`]: one entry per joint.
758#[derive(Default)]
759struct DhTable {
760    thetas: Vec<Ex>,
761    d: Vec<Ex>,
762    a: Vec<Ex>,
763    alpha: Vec<Ex>,
764}
765
766impl DhTable {
767    /// The links, borrowing the table.
768    fn links(&self) -> Vec<DhLink<'_>> {
769        self.thetas
770            .iter()
771            .zip(&self.d)
772            .zip(&self.a)
773            .zip(&self.alpha)
774            .map(|(((theta, d), a), alpha)| DhLink { theta, d, a, alpha })
775            .collect()
776    }
777}
778
779/// Builder for generating code for a serial robot arm.
780///
781/// Created by [`robot_arm()`]. Accumulates requested functions and then
782/// delegates to [`CodeGen`] for final output.  All symbolic work happens
783/// in a private [`Context`] owned by the builder.
784///
785/// A function that cannot be generated (a Jacobian for an arm without
786/// joints; any function of an arm with an empty joint name or a `NaN` or
787/// infinite DH parameter) is reported by
788/// [`write_to_out_dir`](Self::write_to_out_dir) /
789/// [`write_to_path`](Self::write_to_path);
790/// [`into_codegen`](Self::into_codegen) returns the functions that could
791/// be generated.
792pub struct RobotArmBuilder {
793    ctx: Context,
794    joints: Vec<(String, f64, f64, f64)>,
795    codegen: CodeGen,
796    generated_fk: bool,
797    generated_jacobian: bool,
798    error: Option<symplex::errors::SymplexError>,
799}
800
801impl RobotArmBuilder {
802    fn new(joints: Vec<(String, f64, f64, f64)>) -> Self {
803        Self {
804            ctx: Context::new(),
805            joints,
806            codegen: CodeGen::new(),
807            generated_fk: false,
808            generated_jacobian: false,
809            error: None,
810        }
811    }
812
813    /// Build the symbolic DH parameter tuples and theta variable list.
814    ///
815    /// # Errors
816    ///
817    /// An empty joint name or a non-finite DH parameter (see
818    /// [`joint_symbol`], [`float_to_expr`]); the `generate_*` methods keep
819    /// it for [`write_to_out_dir`](Self::write_to_out_dir) /
820    /// [`write_to_path`](Self::write_to_path) to report.  (An empty name
821    /// panicked in `Context::symbol`.)
822    fn build_dh(&self) -> Result<DhTable, symplex::errors::SymplexError> {
823        let ctx = &self.ctx;
824        let mut table = DhTable::default();
825        for (name, d, a, alpha) in &self.joints {
826            table.thetas.push(joint_symbol(ctx, name)?);
827            table.d.push(float_to_expr(ctx, *d)?);
828            table.a.push(float_to_expr(ctx, *a)?);
829            table.alpha.push(float_to_expr(ctx, *alpha)?);
830        }
831        Ok(table)
832    }
833
834    /// [`build_dh`](Self::build_dh), recording its error (the first one
835    /// wins) and returning `None` so the caller generates nothing.
836    fn dh_or_record(&mut self) -> Option<DhTable> {
837        match self.build_dh() {
838            Ok(table) => Some(table),
839            Err(e) => {
840                self.error = self.error.take().or(Some(e));
841                None
842            }
843        }
844    }
845
846    fn theta_names_owned(&self) -> Vec<String> {
847        self.joints
848            .iter()
849            .map(|(name, _, _, _)| name.clone())
850            .collect()
851    }
852
853    /// Generate forward kinematics position functions (`fk_x`, `fk_y`, `fk_z`).
854    pub fn generate_fk(mut self, name: &str) -> Self {
855        let Some(table) = self.dh_or_record() else {
856            self.generated_fk = true;
857            return self;
858        };
859        let dh = table.links();
860        let owned_names = self.theta_names_owned();
861        let theta_names: Vec<&str> = owned_names.iter().map(|s| s.as_str()).collect();
862        let (x, y, z) = symplex::robotics::fk_position(&dh);
863
864        let name_x = format!("{name}_x");
865        let name_y = format!("{name}_y");
866        let name_z = format!("{name}_z");
867
868        self.codegen = self.codegen.add_scalar_fn(&name_x, &x, &theta_names);
869        self.codegen = self.codegen.add_scalar_fn(&name_y, &y, &theta_names);
870        self.codegen = self.codegen.add_scalar_fn(&name_z, &z, &theta_names);
871        self.generated_fk = true;
872        self
873    }
874
875    /// Generate the full 4×4 homogeneous forward-kinematics transform as a
876    /// matrix function `name(theta…) -> [f64; 16]` (row-major), via
877    /// [`symplex::robotics::fk_chain`].
878    ///
879    /// The position functions from [`generate_fk`](Self::generate_fk) are
880    /// the last column of this matrix; the upper-left 3×3 block is the
881    /// end-effector rotation.
882    pub fn generate_fk_matrix(mut self, name: &str) -> Self {
883        let Some(table) = self.dh_or_record() else {
884            return self;
885        };
886        let dh = table.links();
887        let owned_names = self.theta_names_owned();
888        let theta_names: Vec<&str> = owned_names.iter().map(|s| s.as_str()).collect();
889        let t = symplex::robotics::fk_chain(&dh);
890        self.codegen = self.codegen.add_matrix_fn(name, &t, &theta_names);
891        self
892    }
893
894    /// Generate the Jacobian matrix function.
895    pub fn generate_jacobian(mut self, name: &str) -> Self {
896        let Some(table) = self.dh_or_record() else {
897            self.generated_jacobian = true;
898            return self;
899        };
900        let dh = table.links();
901        let owned_names = self.theta_names_owned();
902        let theta_names: Vec<&str> = owned_names.iter().map(|s| s.as_str()).collect();
903        let (x, y, _z) = symplex::robotics::fk_position(&dh);
904        let theta_refs: Vec<&Ex> = table.thetas.iter().collect();
905        match symplex::matrix::jacobian(&[&x, &y], &theta_refs) {
906            Ok(j) => self.codegen = self.codegen.add_matrix_fn(name, &j, &theta_names),
907            Err(e) => self.error = self.error.or(Some(e)),
908        }
909        self.generated_jacobian = true;
910        self
911    }
912
913    /// Generate all standard functions (FK position + Jacobian).
914    pub fn generate_all(self) -> Self {
915        let s = if !self.generated_fk {
916            self.generate_fk("fk")
917        } else {
918            self
919        };
920        if !s.generated_jacobian {
921            s.generate_jacobian("jacobian")
922        } else {
923            s
924        }
925    }
926
927    /// Enable `no_std`-compatible output.
928    pub fn no_std(mut self) -> Self {
929        self.codegen = self.codegen.no_std(true);
930        self
931    }
932
933    /// Write generated code to `$OUT_DIR/<filename>`.
934    ///
935    /// # Errors
936    ///
937    /// A function that could not be generated (see [`RobotArmBuilder`]), or
938    /// the error of [`CodeGen::write_to_out_dir`].
939    pub fn write_to_out_dir(self, filename: &str) -> Result<(), Box<dyn std::error::Error>> {
940        if let Some(e) = self.error {
941            return Err(e.into());
942        }
943        self.codegen.write_to_out_dir(filename)
944    }
945
946    /// Write generated code to an explicit path.
947    ///
948    /// # Errors
949    ///
950    /// A function that could not be generated (see [`RobotArmBuilder`]), or
951    /// the error of [`CodeGen::write_to_path`].
952    pub fn write_to_path(self, path: impl AsRef<Path>) -> Result<(), Box<dyn std::error::Error>> {
953        if let Some(e) = self.error {
954            return Err(e.into());
955        }
956        self.codegen.write_to_path(path)
957    }
958
959    /// Get the inner [`CodeGen`] builder for further customization.
960    pub fn into_codegen(self) -> CodeGen {
961        self.codegen
962    }
963}
964
965// ═══════════════════════════════════════════════════════════════════════════
966// Tests
967// ═══════════════════════════════════════════════════════════════════════════
968
969#[cfg(test)]
970mod tests {
971    use super::*;
972
973    #[test]
974    fn codegen_new_default() {
975        let cg = CodeGen::new();
976        // Should create without panic; default has no functions registered
977        assert!(cg.functions.is_empty());
978        assert!(!cg.generate_tests);
979    }
980
981    #[test]
982    fn codegen_default_trait() {
983        // CodeGen::default() and CodeGen::new() should behave identically
984        let cg = CodeGen::default();
985        assert!(cg.functions.is_empty());
986    }
987
988    #[test]
989    fn codegen_generate_empty() {
990        let cg = CodeGen::new();
991        let code = cg.generate().unwrap();
992        // Empty codegen should produce at least the file header
993        assert!(
994            code.contains("Auto-generated by symplex-build"),
995            "expected header comment in generated output, got: {code}"
996        );
997        // No functions registered → no function bodies
998        assert!(
999            !code.contains("fn "),
1000            "expected no function definitions in empty codegen"
1001        );
1002    }
1003
1004    #[test]
1005    fn float_to_expr_is_exact_and_reduced() {
1006        let ctx = Context::new();
1007        let show = |v: f64| format!("{}", float_to_expr(&ctx, v).unwrap());
1008        assert_eq!(show(0.0), "0");
1009        assert_eq!(show(2.0), "2");
1010        assert_eq!(show(-3.0), "-3");
1011        assert_eq!(show(0.3), "3/10");
1012        assert_eq!(show(0.25), "1/4");
1013        assert_eq!(show(0.1 + 0.2), "3/10");
1014        assert_eq!(show(1.0 / 3.0), "1/3");
1015        assert_eq!(show(0.123456), "1929/15625");
1016        // A NaN or infinite entry is an error (a NaN panicked).
1017        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1018            assert!(float_to_expr(&ctx, bad).is_err(), "{bad}");
1019        }
1020    }
1021
1022    #[test]
1023    fn robot_arm_generates_fk_matrix() {
1024        let code = robot_arm(&[("q1", 0.0, 0.3, 0.0), ("q2", 0.1, 0.25, 0.0)])
1025            .generate_fk_matrix("fk_t")
1026            .into_codegen()
1027            .generate()
1028            .unwrap();
1029        assert!(code.contains("fn fk_t("), "{code}");
1030        assert!(
1031            code.contains("q1: f64") && code.contains("q2: f64"),
1032            "{code}"
1033        );
1034        // 4×4 homogeneous transform → flat array of 16.
1035        assert!(code.contains("[f64; 16]"), "expected 4×4 matrix:\n{code}");
1036        // Exact rationals survive into the generated constants (no 0.30000000000000004).
1037        assert!(!code.contains("0.30000000000000004"), "{code}");
1038    }
1039
1040    #[test]
1041    fn fk_matrix_last_column_matches_fk_position() {
1042        // The generated position functions must agree with the last column
1043        // of the full transform, so both entry points are consistent.
1044        let ctx = Context::new();
1045        let (q1, q2) = (ctx.symbol("q1"), ctx.symbol("q2"));
1046        let zero = ctx.int(0);
1047        let (l1, l2) = (
1048            float_to_expr(&ctx, 0.3).unwrap(),
1049            float_to_expr(&ctx, 0.25).unwrap(),
1050        );
1051        let dh = [
1052            DhLink {
1053                theta: &q1,
1054                d: &zero,
1055                a: &l1,
1056                alpha: &zero,
1057            },
1058            DhLink {
1059                theta: &q2,
1060                d: &zero,
1061                a: &l2,
1062                alpha: &zero,
1063            },
1064        ];
1065        let t = symplex::robotics::fk_chain(&dh);
1066        let (x, y, z) = symplex::robotics::fk_position(&dh);
1067        assert_eq!(t.get(0, 3).eval(), x);
1068        assert_eq!(t.get(1, 3).eval(), y);
1069        assert_eq!(t.get(2, 3).eval(), z);
1070    }
1071
1072    #[test]
1073    fn from_toml_accepts_fk_matrix() {
1074        let dir = std::env::temp_dir().join(format!("symplex_build_fkm_{}", std::process::id()));
1075        fs::create_dir_all(&dir).unwrap();
1076        let path = dir.join("robot.toml");
1077        fs::write(
1078            &path,
1079            r#"
1080[robot]
1081name = "one_link"
1082[[joints]]
1083theta = "q"
1084a = 0.5
1085[generate]
1086functions = ["fk_matrix"]
1087"#,
1088        )
1089        .unwrap();
1090        let code = from_toml(&path).unwrap().generate().unwrap();
1091        assert!(code.contains("fn fk_matrix("), "{code}");
1092        assert!(code.contains("[f64; 16]"), "{code}");
1093        let _ = fs::remove_dir_all(&dir);
1094    }
1095
1096    #[test]
1097    fn robot_arm_generates_fk_and_jacobian() {
1098        let code = robot_arm(&[("theta1", 0.0, 0.3, 0.0), ("theta2", 0.0, 0.25, 0.0)])
1099            .generate_all()
1100            .into_codegen()
1101            .generate()
1102            .unwrap();
1103
1104        for name in ["fk_x", "fk_y", "fk_z", "jacobian"] {
1105            assert!(
1106                code.contains(&format!("fn {name}(")),
1107                "expected `{name}` in generated code:\n{code}"
1108            );
1109        }
1110        assert!(code.contains("theta1: f64") && code.contains("theta2: f64"));
1111        // Planar arm: the Jacobian is 2×2 → flat array of 4.
1112        assert!(code.contains("[f64; 4]"), "expected 2×2 Jacobian:\n{code}");
1113    }
1114
1115    #[test]
1116    fn robot_arm_no_std_emits_single_math_module() {
1117        let code = robot_arm(&[("q", 0.0, 1.0, 0.0)])
1118            .no_std()
1119            .generate_all()
1120            .into_codegen()
1121            .generate()
1122            .unwrap();
1123        // The cfg-gated math module must be emitted exactly once (std + libm variants).
1124        assert_eq!(code.matches("mod math {").count(), 2, "{code}");
1125    }
1126
1127    /// Two functions that each need the special-function runtime.
1128    fn two_special_fns(opts: CodegenOptions) -> CodeGen {
1129        let ctx = Context::new();
1130        let x = ctx.symbol("x");
1131        let y = ctx.symbol("y");
1132        CodeGen::new()
1133            .options(opts)
1134            .add_scalar_fn("g", &(x.gamma() + &y), &["x", "y"])
1135            .add_scalar_fn("e", &(x.erf() * &y), &["x", "y"])
1136    }
1137
1138    #[test]
1139    fn generate_emits_runtime_module_once_for_two_special_functions() {
1140        let code = two_special_fns(CodegenOptions::default())
1141            .generate()
1142            .unwrap();
1143        assert_eq!(code.matches("mod symplex_rt {").count(), 1, "{code}");
1144        assert!(
1145            code.contains("pub fn gamma(") && code.contains("pub fn erf("),
1146            "{code}"
1147        );
1148        assert!(code.contains("fn g(") && code.contains("fn e("), "{code}");
1149        // The runtime precedes the functions that use it.
1150        assert!(code.find("mod symplex_rt {").unwrap() < code.find("fn g(").unwrap());
1151        // Only the helpers the file needs are embedded.
1152        assert!(!code.contains("pub fn bessel_k("), "{code}");
1153    }
1154
1155    #[test]
1156    fn generate_emits_runtime_module_once_in_no_std_mode() {
1157        let code = two_special_fns(CodegenOptions::no_std())
1158            .generate()
1159            .unwrap();
1160        assert_eq!(code.matches("mod symplex_rt {").count(), 1, "{code}");
1161        assert_eq!(code.matches("mod math {").count(), 2, "{code}");
1162        // Order: mod math, mod symplex_rt, functions.
1163        let math_pos = code.find("mod math {").unwrap();
1164        let rt_pos = code.find("mod symplex_rt {").unwrap();
1165        let fn_pos = code.find("fn g(").unwrap();
1166        assert!(math_pos < rt_pos && rt_pos < fn_pos, "{code}");
1167    }
1168
1169    #[test]
1170    fn generate_honours_emit_runtime_false() {
1171        let opts = CodegenOptions {
1172            emit_runtime: false,
1173            ..Default::default()
1174        };
1175        let code = two_special_fns(opts).generate().unwrap();
1176        assert_eq!(code.matches("mod symplex_rt {").count(), 0, "{code}");
1177        assert!(code.contains("symplex_rt::gamma("), "{code}");
1178    }
1179
1180    #[test]
1181    fn generate_omits_runtime_when_unused() {
1182        let ctx = Context::new();
1183        let x = ctx.symbol("x");
1184        let code = CodeGen::new()
1185            .add_scalar_fn("f", &(x.sin() + x.powi(2)), &["x"])
1186            .generate()
1187            .unwrap();
1188        assert!(!code.contains("mod symplex_rt"), "{code}");
1189    }
1190
1191    /// The two-function file must compile as a library (skipped when
1192    /// `rustc` is not on the PATH).
1193    #[test]
1194    fn generated_file_with_two_special_functions_compiles() {
1195        let Ok(out) = std::process::Command::new("rustc")
1196            .arg("--version")
1197            .output()
1198        else {
1199            eprintln!("rustc not available; skipping compile check");
1200            return;
1201        };
1202        if !out.status.success() {
1203            return;
1204        }
1205        let code = two_special_fns(CodegenOptions::default())
1206            .generate()
1207            .unwrap();
1208        let dir = std::env::temp_dir().join(format!("symplex_build_rt_{}", std::process::id()));
1209        fs::create_dir_all(&dir).unwrap();
1210        let src = dir.join("gen.rs");
1211        fs::write(&src, format!("#![allow(dead_code)]\n{code}")).unwrap();
1212        let out = std::process::Command::new("rustc")
1213            .args(["--crate-type", "lib", "--edition", "2024", "-o"])
1214            .arg(dir.join("gen.rlib"))
1215            .arg(&src)
1216            .output()
1217            .unwrap();
1218        let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
1219        let _ = fs::remove_dir_all(&dir);
1220        assert!(
1221            out.status.success(),
1222            "generated file failed to compile:\n{stderr}\n{code}"
1223        );
1224    }
1225
1226    #[test]
1227    fn from_toml_round_trip() {
1228        let dir = std::env::temp_dir().join(format!("symplex_build_{}", std::process::id()));
1229        fs::create_dir_all(&dir).unwrap();
1230        let path = dir.join("robot.toml");
1231        fs::write(
1232            &path,
1233            r#"
1234[robot]
1235name = "two_link"
1236
1237[[joints]]
1238theta = "theta1"
1239a = 0.3
1240
1241[[joints]]
1242theta = "theta2"
1243a = 0.25
1244
1245[generate]
1246functions = ["fk", "jacobian"]
1247"#,
1248        )
1249        .unwrap();
1250
1251        let code = from_toml(&path).unwrap().generate().unwrap();
1252        assert!(code.contains("fn fk_x("), "{code}");
1253        assert!(code.contains("fn jacobian("), "{code}");
1254
1255        let _ = fs::remove_dir_all(&dir);
1256    }
1257
1258    #[test]
1259    fn from_toml_rejects_unknown_function() {
1260        let dir = std::env::temp_dir().join(format!("symplex_build_bad_{}", std::process::id()));
1261        fs::create_dir_all(&dir).unwrap();
1262        let path = dir.join("robot.toml");
1263        fs::write(
1264            &path,
1265            r#"
1266[robot]
1267name = "r"
1268[[joints]]
1269theta = "q"
1270[generate]
1271functions = ["dynamics"]
1272"#,
1273        )
1274        .unwrap();
1275        let err = from_toml(&path)
1276            .err()
1277            .expect("unknown function should error");
1278        assert!(err.to_string().contains("dynamics"), "{err}");
1279        let _ = fs::remove_dir_all(&dir);
1280    }
1281
1282    /// An empty joint name panicked in `build_dh` (`Context::symbol("")`)
1283    /// from every `generate_*`; it is now the builder's error, reported by
1284    /// `write_to_path`, and nothing is generated for the arm.  A `NaN` DH
1285    /// parameter (which panicked in `float_to_expr`) likewise.
1286    #[test]
1287    fn robot_arm_reports_an_empty_joint_name_instead_of_panicking() {
1288        let dir = std::env::temp_dir().join(format!("symplex_build_empty_{}", std::process::id()));
1289        let path = dir.join("arm.rs");
1290        let err = robot_arm(&[("q1", 0.0, 0.3, 0.0), ("", 0.0, 0.25, 0.0)])
1291            .generate_all()
1292            .generate_fk_matrix("fk_t")
1293            .write_to_path(&path)
1294            .expect_err("an empty joint name is an error");
1295        assert!(err.to_string().contains("name must not be empty"), "{err}");
1296        assert!(!path.exists(), "nothing is written");
1297        let generated = robot_arm(&[("", 0.0, 0.3, 0.0)])
1298            .generate_fk("fk")
1299            .into_codegen();
1300        assert!(generated.functions.is_empty());
1301        let err = robot_arm(&[("q1", f64::NAN, 0.3, 0.0)])
1302            .generate_jacobian("j")
1303            .write_to_path(&path)
1304            .expect_err("a NaN DH parameter is an error");
1305        assert!(err.to_string().contains("finite"), "{err}");
1306        assert!(!path.exists(), "nothing is written");
1307    }
1308
1309    /// `from_toml` with `theta = ""` (or `d = nan`) panicked the same way;
1310    /// it is now its error.
1311    #[test]
1312    fn from_toml_rejects_an_empty_joint_name_and_a_nan_parameter() {
1313        let dir =
1314            std::env::temp_dir().join(format!("symplex_build_empty_toml_{}", std::process::id()));
1315        fs::create_dir_all(&dir).unwrap();
1316        let path = dir.join("robot.toml");
1317        for (joint, needle) in [
1318            ("theta = \"\"", "name must not be empty"),
1319            ("theta = \"q\"\nd = nan", "finite"),
1320        ] {
1321            fs::write(
1322                &path,
1323                format!(
1324                    "[robot]\nname = \"r\"\n[[joints]]\n{joint}\n[generate]\nfunctions = [\"fk\"]\n"
1325                ),
1326            )
1327            .unwrap();
1328            let err = from_toml(&path).err().expect("a bad joint is an error");
1329            assert!(err.to_string().contains(needle), "{err}");
1330        }
1331        let _ = fs::remove_dir_all(&dir);
1332    }
1333}