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) {
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
386fn 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 if let Some(&next) = lines.peek()
398 && next.starts_with("mod math {")
399 {
400 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 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 let trimmed = result.trim_start_matches('\n');
432 trimmed.to_string()
433}
434
435#[derive(serde::Deserialize)]
441struct RobotConfig {
442 #[allow(dead_code)]
443 robot: RobotInfo,
444 joints: Vec<JointConfig>,
445 generate: GenerateConfig,
446}
447
448#[derive(serde::Deserialize)]
450struct RobotInfo {
451 #[allow(dead_code)]
452 name: String,
453}
454
455#[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#[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
485pub 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 let ctx = Context::new();
518
519 let theta_vars: Vec<Ex> = config
522 .joints
523 .iter()
524 .map(|j| joint_symbol(&ctx, &j.theta))
525 .collect::<Result<_, _>>()?;
526
527 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
586fn 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
602fn 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
624pub 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#[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 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
674pub 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 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 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 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 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 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 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 pub fn no_std(mut self) -> Self {
824 self.codegen = self.codegen.no_std(true);
825 self
826 }
827
828 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 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 pub fn into_codegen(self) -> CodeGen {
856 self.codegen
857 }
858}
859
860#[cfg(test)]
865mod tests {
866 use super::*;
867
868 #[test]
869 fn codegen_new_default() {
870 let cg = CodeGen::new();
871 assert!(cg.functions.is_empty());
873 assert!(!cg.generate_tests);
874 }
875
876 #[test]
877 fn codegen_default_trait() {
878 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 assert!(
889 code.contains("Auto-generated by symplex-build"),
890 "expected header comment in generated output, got: {code}"
891 );
892 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 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 assert!(code.contains("[f64; 16]"), "expected 4×4 matrix:\n{code}");
931 assert!(!code.contains("0.30000000000000004"), "{code}");
933 }
934
935 #[test]
936 fn fk_matrix_last_column_matches_fk_position() {
937 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 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 assert_eq!(code.matches("mod math {").count(), 2, "{code}");
1020 }
1021
1022 #[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 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 assert!(code.find("mod symplex_rt {").unwrap() < code.find("fn g(").unwrap());
1077 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 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 #[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 #[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 #[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}