1use symplex::matrix::{CodegenOptions, MathBackend, Matrix, Precision};
45use symplex::prelude::*;
46use symplex::robotics::DhLink;
47
48use std::fs;
49use std::path::{Path, PathBuf};
50
51pub 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 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 pub fn options(mut self, options: CodegenOptions) -> Self {
124 self.options = options;
125 self
126 }
127
128 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 pub fn precision_f32(mut self) -> Self {
140 self.options.precision = Precision::F32;
141 self
142 }
143
144 pub fn inline(mut self, enabled: bool) -> Self {
146 self.options.inline = enabled;
147 self
148 }
149
150 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 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 pub fn with_tests(mut self, enabled: bool) -> Self {
181 self.generate_tests = enabled;
182 self
183 }
184
185 pub fn add_test_point(mut self, point: &[f64]) -> Self {
191 self.test_points.push(point.to_vec());
192 self
193 }
194
195 pub fn generate(&self) -> Result<String, Box<dyn std::error::Error>> {
205 let mut output = String::new();
206
207 output.push_str("// Auto-generated by symplex-build. Do not edit.\n\n");
209
210 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 append_cfg_gated_module(&mut output, self.options.precision);
221 output.push('\n');
222 }
223
224 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, ¶m_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, ¶m_refs, &fn_options)?
251 }
252 };
253
254 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 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 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 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
365fn 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 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 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
491fn 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 if let Some(&next) = lines.peek()
503 && next.starts_with("mod math {")
504 {
505 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 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 let trimmed = result.trim_start_matches('\n');
537 trimmed.to_string()
538}
539
540#[derive(serde::Deserialize)]
546struct RobotConfig {
547 #[allow(dead_code)]
548 robot: RobotInfo,
549 joints: Vec<JointConfig>,
550 generate: GenerateConfig,
551}
552
553#[derive(serde::Deserialize)]
555struct RobotInfo {
556 #[allow(dead_code)]
557 name: String,
558}
559
560#[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#[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
590pub 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 let ctx = Context::new();
623
624 let theta_vars: Vec<Ex> = config.joints.iter().map(|j| ctx.symbol(&j.theta)).collect();
626
627 let d_vals: Vec<Ex> = config
629 .joints
630 .iter()
631 .map(|j| float_to_expr(&ctx, j.d))
632 .collect();
633 let a_vals: Vec<Ex> = config
634 .joints
635 .iter()
636 .map(|j| float_to_expr(&ctx, j.a))
637 .collect();
638 let alpha_vals: Vec<Ex> = config
639 .joints
640 .iter()
641 .map(|j| float_to_expr(&ctx, j.alpha))
642 .collect();
643
644 let dh_params: Vec<DhLink<'_>> = theta_vars
645 .iter()
646 .enumerate()
647 .map(|(i, theta)| DhLink {
648 theta,
649 d: &d_vals[i],
650 a: &a_vals[i],
651 alpha: &alpha_vals[i],
652 })
653 .collect();
654
655 let theta_names: Vec<&str> = config.joints.iter().map(|j| j.theta.as_str()).collect();
656
657 let mut codegen = CodeGen::new();
658
659 for func in &config.generate.functions {
660 match func.as_str() {
661 "fk" => {
662 let (x, y, z) = symplex::robotics::fk_position(&dh_params);
663 codegen = codegen.add_scalar_fn("fk_x", &x, &theta_names);
664 codegen = codegen.add_scalar_fn("fk_y", &y, &theta_names);
665 codegen = codegen.add_scalar_fn("fk_z", &z, &theta_names);
666 }
667 "jacobian" => {
668 let (x, y, _z) = symplex::robotics::fk_position(&dh_params);
669 let theta_refs: Vec<&Ex> = theta_vars.iter().collect();
670 let j = symplex::matrix::jacobian(&[&x, &y], &theta_refs);
671 codegen = codegen.add_matrix_fn("jacobian", &j, &theta_names);
672 }
673 "fk_matrix" => {
674 let t = symplex::robotics::fk_chain(&dh_params);
675 codegen = codegen.add_matrix_fn("fk_matrix", &t, &theta_names);
676 }
677 other => {
678 return Err(format!("unknown generate function: {other}").into());
679 }
680 }
681 }
682
683 Ok(codegen)
684}
685
686fn float_to_expr(ctx: &Context, v: f64) -> Ex {
697 ctx.from_f64_approx(v, 1_000_000)
698 .unwrap_or_else(|e| panic!("symplex-build: invalid DH parameter {v}: {e}"))
699}
700
701pub fn robot_arm(joints: &[(&str, f64, f64, f64)]) -> RobotArmBuilder {
722 let owned: Vec<(String, f64, f64, f64)> = joints
723 .iter()
724 .map(|(name, d, a, alpha)| (name.to_string(), *d, *a, *alpha))
725 .collect();
726 RobotArmBuilder::new(owned)
727}
728
729pub struct RobotArmBuilder {
735 ctx: Context,
736 joints: Vec<(String, f64, f64, f64)>,
737 codegen: CodeGen,
738 generated_fk: bool,
739 generated_jacobian: bool,
740}
741
742impl RobotArmBuilder {
743 fn new(joints: Vec<(String, f64, f64, f64)>) -> Self {
744 Self {
745 ctx: Context::new(),
746 joints,
747 codegen: CodeGen::new(),
748 generated_fk: false,
749 generated_jacobian: false,
750 }
751 }
752
753 fn build_dh(&self) -> (Vec<Ex>, Vec<Ex>, Vec<Ex>, Vec<Ex>) {
755 let ctx = &self.ctx;
756 let thetas: Vec<Ex> = self
757 .joints
758 .iter()
759 .map(|(name, _, _, _)| ctx.symbol(name))
760 .collect();
761 let d_vals: Vec<Ex> = self
762 .joints
763 .iter()
764 .map(|(_, d, _, _)| float_to_expr(ctx, *d))
765 .collect();
766 let a_vals: Vec<Ex> = self
767 .joints
768 .iter()
769 .map(|(_, _, a, _)| float_to_expr(ctx, *a))
770 .collect();
771 let alpha_vals: Vec<Ex> = self
772 .joints
773 .iter()
774 .map(|(_, _, _, alpha)| float_to_expr(ctx, *alpha))
775 .collect();
776 (thetas, d_vals, a_vals, alpha_vals)
777 }
778
779 fn theta_names_owned(&self) -> Vec<String> {
780 self.joints
781 .iter()
782 .map(|(name, _, _, _)| name.clone())
783 .collect()
784 }
785
786 pub fn generate_fk(mut self, name: &str) -> Self {
788 let (thetas, d_vals, a_vals, alpha_vals) = self.build_dh();
789 let dh: Vec<DhLink<'_>> = thetas
790 .iter()
791 .enumerate()
792 .map(|(i, t)| DhLink {
793 theta: t,
794 d: &d_vals[i],
795 a: &a_vals[i],
796 alpha: &alpha_vals[i],
797 })
798 .collect();
799 let owned_names = self.theta_names_owned();
800 let theta_names: Vec<&str> = owned_names.iter().map(|s| s.as_str()).collect();
801 let (x, y, z) = symplex::robotics::fk_position(&dh);
802
803 let name_x = format!("{name}_x");
804 let name_y = format!("{name}_y");
805 let name_z = format!("{name}_z");
806
807 self.codegen = self.codegen.add_scalar_fn(&name_x, &x, &theta_names);
808 self.codegen = self.codegen.add_scalar_fn(&name_y, &y, &theta_names);
809 self.codegen = self.codegen.add_scalar_fn(&name_z, &z, &theta_names);
810 self.generated_fk = true;
811 self
812 }
813
814 pub fn generate_fk_matrix(mut self, name: &str) -> Self {
822 let (thetas, d_vals, a_vals, alpha_vals) = self.build_dh();
823 let dh: Vec<DhLink<'_>> = thetas
824 .iter()
825 .enumerate()
826 .map(|(i, t)| DhLink {
827 theta: t,
828 d: &d_vals[i],
829 a: &a_vals[i],
830 alpha: &alpha_vals[i],
831 })
832 .collect();
833 let owned_names = self.theta_names_owned();
834 let theta_names: Vec<&str> = owned_names.iter().map(|s| s.as_str()).collect();
835 let t = symplex::robotics::fk_chain(&dh);
836 self.codegen = self.codegen.add_matrix_fn(name, &t, &theta_names);
837 self
838 }
839
840 pub fn generate_jacobian(mut self, name: &str) -> Self {
842 let (thetas, d_vals, a_vals, alpha_vals) = self.build_dh();
843 let dh: Vec<DhLink<'_>> = thetas
844 .iter()
845 .enumerate()
846 .map(|(i, t)| DhLink {
847 theta: t,
848 d: &d_vals[i],
849 a: &a_vals[i],
850 alpha: &alpha_vals[i],
851 })
852 .collect();
853 let owned_names = self.theta_names_owned();
854 let theta_names: Vec<&str> = owned_names.iter().map(|s| s.as_str()).collect();
855 let (x, y, _z) = symplex::robotics::fk_position(&dh);
856 let theta_refs: Vec<&Ex> = thetas.iter().collect();
857 let j = symplex::matrix::jacobian(&[&x, &y], &theta_refs);
858
859 self.codegen = self.codegen.add_matrix_fn(name, &j, &theta_names);
860 self.generated_jacobian = true;
861 self
862 }
863
864 pub fn generate_all(self) -> Self {
866 let s = if !self.generated_fk {
867 self.generate_fk("fk")
868 } else {
869 self
870 };
871 if !s.generated_jacobian {
872 s.generate_jacobian("jacobian")
873 } else {
874 s
875 }
876 }
877
878 pub fn no_std(mut self) -> Self {
880 self.codegen = self.codegen.no_std(true);
881 self
882 }
883
884 pub fn write_to_out_dir(self, filename: &str) -> Result<(), Box<dyn std::error::Error>> {
886 self.codegen.write_to_out_dir(filename)
887 }
888
889 pub fn write_to_path(self, path: impl AsRef<Path>) -> Result<(), Box<dyn std::error::Error>> {
891 self.codegen.write_to_path(path)
892 }
893
894 pub fn into_codegen(self) -> CodeGen {
896 self.codegen
897 }
898}
899
900#[cfg(test)]
905mod tests {
906 use super::*;
907
908 #[test]
909 fn codegen_new_default() {
910 let cg = CodeGen::new();
911 assert!(cg.functions.is_empty());
913 assert!(!cg.generate_tests);
914 }
915
916 #[test]
917 fn codegen_default_trait() {
918 let cg = CodeGen::default();
920 assert!(cg.functions.is_empty());
921 }
922
923 #[test]
924 fn codegen_generate_empty() {
925 let cg = CodeGen::new();
926 let code = cg.generate().unwrap();
927 assert!(
929 code.contains("Auto-generated by symplex-build"),
930 "expected header comment in generated output, got: {code}"
931 );
932 assert!(
934 !code.contains("fn "),
935 "expected no function definitions in empty codegen"
936 );
937 }
938
939 #[test]
940 fn float_to_expr_is_exact_and_reduced() {
941 let ctx = Context::new();
942 assert_eq!(format!("{}", float_to_expr(&ctx, 0.0)), "0");
943 assert_eq!(format!("{}", float_to_expr(&ctx, 2.0)), "2");
944 assert_eq!(format!("{}", float_to_expr(&ctx, -3.0)), "-3");
945 assert_eq!(format!("{}", float_to_expr(&ctx, 0.3)), "3/10");
946 assert_eq!(format!("{}", float_to_expr(&ctx, 0.25)), "1/4");
947 assert_eq!(format!("{}", float_to_expr(&ctx, 0.1 + 0.2)), "3/10");
948 assert_eq!(format!("{}", float_to_expr(&ctx, 1.0 / 3.0)), "1/3");
949 assert_eq!(format!("{}", float_to_expr(&ctx, 0.123456)), "1929/15625");
950 }
951
952 #[test]
953 fn robot_arm_generates_fk_matrix() {
954 let code = robot_arm(&[("q1", 0.0, 0.3, 0.0), ("q2", 0.1, 0.25, 0.0)])
955 .generate_fk_matrix("fk_t")
956 .into_codegen()
957 .generate()
958 .unwrap();
959 assert!(code.contains("fn fk_t("), "{code}");
960 assert!(
961 code.contains("q1: f64") && code.contains("q2: f64"),
962 "{code}"
963 );
964 assert!(code.contains("[f64; 16]"), "expected 4×4 matrix:\n{code}");
966 assert!(!code.contains("0.30000000000000004"), "{code}");
968 }
969
970 #[test]
971 fn fk_matrix_last_column_matches_fk_position() {
972 let ctx = Context::new();
975 let (q1, q2) = (ctx.symbol("q1"), ctx.symbol("q2"));
976 let zero = ctx.int(0);
977 let (l1, l2) = (float_to_expr(&ctx, 0.3), float_to_expr(&ctx, 0.25));
978 let dh = [
979 DhLink {
980 theta: &q1,
981 d: &zero,
982 a: &l1,
983 alpha: &zero,
984 },
985 DhLink {
986 theta: &q2,
987 d: &zero,
988 a: &l2,
989 alpha: &zero,
990 },
991 ];
992 let t = symplex::robotics::fk_chain(&dh);
993 let (x, y, z) = symplex::robotics::fk_position(&dh);
994 assert_eq!(t.get(0, 3).eval(), x);
995 assert_eq!(t.get(1, 3).eval(), y);
996 assert_eq!(t.get(2, 3).eval(), z);
997 }
998
999 #[test]
1000 fn from_toml_accepts_fk_matrix() {
1001 let dir = std::env::temp_dir().join(format!("symplex_build_fkm_{}", std::process::id()));
1002 fs::create_dir_all(&dir).unwrap();
1003 let path = dir.join("robot.toml");
1004 fs::write(
1005 &path,
1006 r#"
1007[robot]
1008name = "one_link"
1009[[joints]]
1010theta = "q"
1011a = 0.5
1012[generate]
1013functions = ["fk_matrix"]
1014"#,
1015 )
1016 .unwrap();
1017 let code = from_toml(&path).unwrap().generate().unwrap();
1018 assert!(code.contains("fn fk_matrix("), "{code}");
1019 assert!(code.contains("[f64; 16]"), "{code}");
1020 let _ = fs::remove_dir_all(&dir);
1021 }
1022
1023 #[test]
1024 fn robot_arm_generates_fk_and_jacobian() {
1025 let code = robot_arm(&[("theta1", 0.0, 0.3, 0.0), ("theta2", 0.0, 0.25, 0.0)])
1026 .generate_all()
1027 .into_codegen()
1028 .generate()
1029 .unwrap();
1030
1031 for name in ["fk_x", "fk_y", "fk_z", "jacobian"] {
1032 assert!(
1033 code.contains(&format!("fn {name}(")),
1034 "expected `{name}` in generated code:\n{code}"
1035 );
1036 }
1037 assert!(code.contains("theta1: f64") && code.contains("theta2: f64"));
1038 assert!(code.contains("[f64; 4]"), "expected 2×2 Jacobian:\n{code}");
1040 }
1041
1042 #[test]
1043 fn robot_arm_no_std_emits_single_math_module() {
1044 let code = robot_arm(&[("q", 0.0, 1.0, 0.0)])
1045 .no_std()
1046 .generate_all()
1047 .into_codegen()
1048 .generate()
1049 .unwrap();
1050 assert_eq!(code.matches("mod math {").count(), 2, "{code}");
1052 }
1053
1054 fn two_special_fns(opts: CodegenOptions) -> CodeGen {
1056 let ctx = Context::new();
1057 let x = ctx.symbol("x");
1058 let y = ctx.symbol("y");
1059 CodeGen::new()
1060 .options(opts)
1061 .add_scalar_fn("g", &(x.gamma() + &y), &["x", "y"])
1062 .add_scalar_fn("e", &(x.erf() * &y), &["x", "y"])
1063 }
1064
1065 #[test]
1066 fn generate_emits_runtime_module_once_for_two_special_functions() {
1067 let code = two_special_fns(CodegenOptions::default())
1068 .generate()
1069 .unwrap();
1070 assert_eq!(code.matches("mod symplex_rt {").count(), 1, "{code}");
1071 assert!(
1072 code.contains("pub fn gamma(") && code.contains("pub fn erf("),
1073 "{code}"
1074 );
1075 assert!(code.contains("fn g(") && code.contains("fn e("), "{code}");
1076 assert!(code.find("mod symplex_rt {").unwrap() < code.find("fn g(").unwrap());
1078 assert!(!code.contains("pub fn bessel_k("), "{code}");
1080 }
1081
1082 #[test]
1083 fn generate_emits_runtime_module_once_in_no_std_mode() {
1084 let code = two_special_fns(CodegenOptions::no_std())
1085 .generate()
1086 .unwrap();
1087 assert_eq!(code.matches("mod symplex_rt {").count(), 1, "{code}");
1088 assert_eq!(code.matches("mod math {").count(), 2, "{code}");
1089 let math_pos = code.find("mod math {").unwrap();
1091 let rt_pos = code.find("mod symplex_rt {").unwrap();
1092 let fn_pos = code.find("fn g(").unwrap();
1093 assert!(math_pos < rt_pos && rt_pos < fn_pos, "{code}");
1094 }
1095
1096 #[test]
1097 fn generate_honours_emit_runtime_false() {
1098 let opts = CodegenOptions {
1099 emit_runtime: false,
1100 ..Default::default()
1101 };
1102 let code = two_special_fns(opts).generate().unwrap();
1103 assert_eq!(code.matches("mod symplex_rt {").count(), 0, "{code}");
1104 assert!(code.contains("symplex_rt::gamma("), "{code}");
1105 }
1106
1107 #[test]
1108 fn generate_omits_runtime_when_unused() {
1109 let ctx = Context::new();
1110 let x = ctx.symbol("x");
1111 let code = CodeGen::new()
1112 .add_scalar_fn("f", &(x.sin() + x.powi(2)), &["x"])
1113 .generate()
1114 .unwrap();
1115 assert!(!code.contains("mod symplex_rt"), "{code}");
1116 }
1117
1118 #[test]
1121 fn generated_file_with_two_special_functions_compiles() {
1122 let Ok(out) = std::process::Command::new("rustc")
1123 .arg("--version")
1124 .output()
1125 else {
1126 eprintln!("rustc not available; skipping compile check");
1127 return;
1128 };
1129 if !out.status.success() {
1130 return;
1131 }
1132 let code = two_special_fns(CodegenOptions::default())
1133 .generate()
1134 .unwrap();
1135 let dir = std::env::temp_dir().join(format!("symplex_build_rt_{}", std::process::id()));
1136 fs::create_dir_all(&dir).unwrap();
1137 let src = dir.join("gen.rs");
1138 fs::write(&src, format!("#![allow(dead_code)]\n{code}")).unwrap();
1139 let out = std::process::Command::new("rustc")
1140 .args(["--crate-type", "lib", "--edition", "2024", "-o"])
1141 .arg(dir.join("gen.rlib"))
1142 .arg(&src)
1143 .output()
1144 .unwrap();
1145 let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
1146 let _ = fs::remove_dir_all(&dir);
1147 assert!(
1148 out.status.success(),
1149 "generated file failed to compile:\n{stderr}\n{code}"
1150 );
1151 }
1152
1153 #[test]
1154 fn from_toml_round_trip() {
1155 let dir = std::env::temp_dir().join(format!("symplex_build_{}", std::process::id()));
1156 fs::create_dir_all(&dir).unwrap();
1157 let path = dir.join("robot.toml");
1158 fs::write(
1159 &path,
1160 r#"
1161[robot]
1162name = "two_link"
1163
1164[[joints]]
1165theta = "theta1"
1166a = 0.3
1167
1168[[joints]]
1169theta = "theta2"
1170a = 0.25
1171
1172[generate]
1173functions = ["fk", "jacobian"]
1174"#,
1175 )
1176 .unwrap();
1177
1178 let code = from_toml(&path).unwrap().generate().unwrap();
1179 assert!(code.contains("fn fk_x("), "{code}");
1180 assert!(code.contains("fn jacobian("), "{code}");
1181
1182 let _ = fs::remove_dir_all(&dir);
1183 }
1184
1185 #[test]
1186 fn from_toml_rejects_unknown_function() {
1187 let dir = std::env::temp_dir().join(format!("symplex_build_bad_{}", std::process::id()));
1188 fs::create_dir_all(&dir).unwrap();
1189 let path = dir.join("robot.toml");
1190 fs::write(
1191 &path,
1192 r#"
1193[robot]
1194name = "r"
1195[[joints]]
1196theta = "q"
1197[generate]
1198functions = ["dynamics"]
1199"#,
1200 )
1201 .unwrap();
1202 let err = from_toml(&path)
1203 .err()
1204 .expect("unknown function should error");
1205 assert!(err.to_string().contains("dynamics"), "{err}");
1206 let _ = fs::remove_dir_all(&dir);
1207 }
1208}