1use symplex::matrix::{CodegenOptions, MathBackend, Matrix, Precision};
45use symplex::prelude::*;
46
47use std::fs;
48use std::path::{Path, PathBuf};
49
50pub struct CodeGen {
59 functions: Vec<GeneratedFn>,
60 options: CodegenOptions,
61 preamble: Vec<String>,
62 test_points: Vec<Vec<f64>>,
63 generate_tests: bool,
64}
65
66enum GeneratedFn {
67 Scalar {
68 name: String,
69 expr: Ex,
70 params: Vec<String>,
71 },
72 Matrix {
73 name: String,
74 matrix: Matrix,
75 params: Vec<String>,
76 },
77}
78
79impl CodeGen {
80 pub fn new() -> Self {
82 Self {
83 functions: Vec::new(),
84 options: CodegenOptions::default(),
85 preamble: Vec::new(),
86 test_points: Vec::new(),
87 generate_tests: false,
88 }
89 }
90
91 pub fn options(mut self, options: CodegenOptions) -> Self {
123 self.options = options;
124 self
125 }
126
127 pub fn no_std(mut self, enabled: bool) -> Self {
129 if enabled {
130 self.options.math_backend = MathBackend::CfgGated;
131 } else {
132 self.options.math_backend = MathBackend::Std;
133 }
134 self
135 }
136
137 pub fn precision_f32(mut self) -> Self {
139 self.options.precision = Precision::F32;
140 self
141 }
142
143 pub fn inline(mut self, enabled: bool) -> Self {
145 self.options.inline = enabled;
146 self
147 }
148
149 pub fn add_scalar_fn(mut self, name: &str, expr: &Ex, params: &[&str]) -> Self {
154 self.functions.push(GeneratedFn::Scalar {
155 name: name.to_string(),
156 expr: expr.clone(),
157 params: params.iter().map(|s| s.to_string()).collect(),
158 });
159 self
160 }
161
162 pub fn add_matrix_fn(mut self, name: &str, matrix: &Matrix, params: &[&str]) -> Self {
167 self.functions.push(GeneratedFn::Matrix {
168 name: name.to_string(),
169 matrix: matrix.clone(),
170 params: params.iter().map(|s| s.to_string()).collect(),
171 });
172 self
173 }
174
175 pub fn with_tests(mut self, enabled: bool) -> Self {
180 self.generate_tests = enabled;
181 self
182 }
183
184 pub fn add_test_point(mut self, point: &[f64]) -> Self {
190 self.test_points.push(point.to_vec());
191 self
192 }
193
194 pub fn generate(&self) -> Result<String, Box<dyn std::error::Error>> {
204 let mut output = String::new();
205
206 output.push_str("// Auto-generated by symplex-build. Do not edit.\n\n");
208
209 for line in &self.preamble {
211 output.push_str(line);
212 output.push('\n');
213 }
214
215 let emit_cfg_module = self.options.math_backend == MathBackend::CfgGated;
216
217 if emit_cfg_module {
218 append_cfg_gated_module(&mut output, self.options.precision);
220 output.push('\n');
221 }
222
223 let fn_options = CodegenOptions {
226 emit_runtime: false,
227 ..self.options.clone()
228 };
229
230 let mut functions = String::new();
231 let mut first_fn = true;
232 for gfn in &self.functions {
233 if !first_fn {
234 functions.push('\n');
235 }
236 first_fn = false;
237
238 let code = match gfn {
239 GeneratedFn::Scalar { name, expr, params } => {
240 let param_refs: Vec<&str> = params.iter().map(|s| s.as_str()).collect();
241 expr.to_rust_fn_with_options(name, ¶m_refs, &fn_options)?
242 }
243 GeneratedFn::Matrix {
244 name,
245 matrix,
246 params,
247 } => {
248 let param_refs: Vec<&str> = params.iter().map(|s| s.as_str()).collect();
249 matrix.to_rust_fn_with_options(name, ¶m_refs, &fn_options)?
250 }
251 };
252
253 if emit_cfg_module {
256 let stripped = strip_cfg_gated_module(&code);
257 functions.push_str(&stripped);
258 } else {
259 functions.push_str(&code);
260 }
261 functions.push('\n');
262 }
263
264 if self.options.emit_runtime
265 && let Some(runtime) = self.options.runtime_module_for(&functions)
266 {
267 output.push_str(&runtime);
268 output.push_str("\n\n");
269 }
270 output.push_str(&functions);
271
272 if self.generate_tests && !self.test_points.is_empty() {
274 output.push('\n');
275 output.push_str("#[cfg(test)]\n");
276 output.push_str("mod generated_tests {\n");
277 output.push_str(" use super::*;\n\n");
278
279 for (fn_idx, gfn) in self.functions.iter().enumerate() {
280 let (fn_name, param_count) = match gfn {
281 GeneratedFn::Scalar { name, params, .. } => (name.as_str(), params.len()),
282 GeneratedFn::Matrix { name, params, .. } => (name.as_str(), params.len()),
283 };
284
285 for (pt_idx, point) in self.test_points.iter().enumerate() {
286 if point.len() != param_count {
287 continue;
288 }
289 output.push_str(&format!(
290 " #[test]\n fn test_{fn_name}_point_{pt_idx}() {{\n"
291 ));
292
293 let args: Vec<String> = point
294 .iter()
295 .map(|v| {
296 let float_ty = match self.options.precision {
297 Precision::F64 => "f64",
298 Precision::F32 => "f32",
299 };
300 format!("{v}_{float_ty}")
301 })
302 .collect();
303 let args_str = args.join(", ");
304
305 match &self.functions[fn_idx] {
306 GeneratedFn::Scalar { .. } => {
307 output.push_str(&format!(
308 " let result = {fn_name}({args_str});\n"
309 ));
310 output.push_str(" assert!(result.is_finite(), \"expected finite result, got {}\", result);\n");
311 }
312 GeneratedFn::Matrix { matrix, .. } => {
313 let total = matrix.nrows() * matrix.ncols();
314 output.push_str(&format!(
315 " let result = {fn_name}({args_str});\n"
316 ));
317 output.push_str(&format!(" for i in 0..{total} {{\n"));
318 output.push_str(" assert!(result[i].is_finite(), \"entry {} is not finite: {}\", i, result[i]);\n");
319 output.push_str(" }\n");
320 }
321 }
322
323 output.push_str(" }\n\n");
324 }
325 }
326
327 output.push_str("}\n");
328 }
329
330 Ok(output)
331 }
332
333 pub fn write_to_out_dir(&self, filename: &str) -> Result<(), Box<dyn std::error::Error>> {
338 let out_dir = std::env::var("OUT_DIR")
339 .map_err(|_| "OUT_DIR not set — this function must be called from a build script")?;
340 let path = PathBuf::from(out_dir).join(filename);
341 let code = self.generate()?;
342 fs::write(&path, code)?;
343 println!("cargo:rerun-if-changed=build.rs");
344 Ok(())
345 }
346
347 pub fn write_to_path(&self, path: impl AsRef<Path>) -> Result<(), Box<dyn std::error::Error>> {
349 let code = self.generate()?;
350 if let Some(parent) = path.as_ref().parent() {
351 fs::create_dir_all(parent)?;
352 }
353 fs::write(path, code)?;
354 Ok(())
355 }
356}
357
358impl Default for CodeGen {
359 fn default() -> Self {
360 Self::new()
361 }
362}
363
364fn append_cfg_gated_module(out: &mut String, precision: Precision) {
378 let ft = match precision {
379 Precision::F64 => "f64",
380 Precision::F32 => "f32",
381 };
382
383 let funcs = [
384 "sin", "cos", "tan", "exp", "ln", "abs", "sqrt", "cbrt", "asin", "acos", "atan", "sinh",
385 "cosh", "tanh", "asinh", "acosh", "atanh", "floor", "ceil", "signum",
386 ];
387
388 out.push_str("#[cfg(feature = \"std\")]\n");
390 out.push_str("mod math {\n");
391 for func in &funcs {
392 out.push_str(&format!(
393 " #[inline] pub fn {func}(x: {ft}) -> {ft} {{ x.{func}() }}\n"
394 ));
395 }
396 out.push_str(&format!(
397 " #[inline] pub fn atan2(y: {ft}, x: {ft}) -> {ft} {{ y.atan2(x) }}\n"
398 ));
399 out.push_str(&format!(
400 " #[inline] pub fn powf(base: {ft}, exp: {ft}) -> {ft} {{ base.powf(exp) }}\n"
401 ));
402 out.push_str(&format!(
403 " #[inline] pub fn powi(base: {ft}, exp: i32) -> {ft} {{ base.powi(exp) }}\n"
404 ));
405 out.push_str(&format!(
406 " #[inline] pub fn min(a: {ft}, b: {ft}) -> {ft} {{ a.min(b) }}\n"
407 ));
408 out.push_str(&format!(
409 " #[inline] pub fn max(a: {ft}, b: {ft}) -> {ft} {{ a.max(b) }}\n"
410 ));
411 out.push_str(&format!(
412 " #[inline] pub fn expm1(x: {ft}) -> {ft} {{ x.exp_m1() }}\n"
413 ));
414 out.push_str(&format!(
415 " #[inline] pub fn log1p(x: {ft}) -> {ft} {{ x.ln_1p() }}\n"
416 ));
417 out.push_str(&format!(
418 " #[inline] pub fn log2(x: {ft}) -> {ft} {{ x.log2() }}\n"
419 ));
420 out.push_str(&format!(
421 " #[inline] pub fn exp2(x: {ft}) -> {ft} {{ x.exp2() }}\n"
422 ));
423 out.push_str(&format!(
424 " #[inline] pub fn fma(a: {ft}, b: {ft}, c: {ft}) -> {ft} {{ a.mul_add(b, c) }}\n"
425 ));
426 out.push_str(&format!(
427 " #[inline] pub fn sin_cos(x: {ft}) -> ({ft}, {ft}) {{ x.sin_cos() }}\n"
428 ));
429 out.push_str("}\n\n");
430
431 out.push_str("#[cfg(not(feature = \"std\"))]\n");
435 out.push_str("mod math {\n");
436 let libm_funcs = [
437 "sin", "cos", "tan", "exp", "sqrt", "cbrt", "asin", "acos", "atan", "sinh", "cosh", "tanh",
438 "asinh", "acosh", "atanh", "floor", "ceil",
439 ];
440 for func in &libm_funcs {
441 out.push_str(&format!(
442 " #[inline] pub fn {func}(x: {ft}) -> {ft} {{ libm::{func}(x as f64) as {ft} }}\n"
443 ));
444 }
445 out.push_str(&format!(
446 " #[inline] pub fn abs(x: {ft}) -> {ft} {{ libm::fabs(x as f64) as {ft} }}\n"
447 ));
448 out.push_str(&format!(
449 " #[inline] pub fn ln(x: {ft}) -> {ft} {{ libm::log(x as f64) as {ft} }}\n"
450 ));
451 out.push_str(&format!(
452 " #[inline] pub fn signum(x: {ft}) -> {ft} {{ if x > 0.0 {{ 1.0 }} else if x < 0.0 {{ -1.0 }} else {{ 0.0 }} }}\n"
453 ));
454 out.push_str(&format!(
455 " #[inline] pub fn atan2(y: {ft}, x: {ft}) -> {ft} {{ libm::atan2(y as f64, x as f64) as {ft} }}\n"
456 ));
457 out.push_str(&format!(
458 " #[inline] pub fn powf(base: {ft}, exp: {ft}) -> {ft} {{ libm::pow(base as f64, exp as f64) as {ft} }}\n"
459 ));
460 out.push_str(&format!(
461 " #[inline] pub fn powi(base: {ft}, exp: i32) -> {ft} {{ libm::pow(base as f64, exp as f64) as {ft} }}\n"
462 ));
463 out.push_str(&format!(
464 " #[inline] pub fn min(a: {ft}, b: {ft}) -> {ft} {{ libm::fmin(a as f64, b as f64) as {ft} }}\n"
465 ));
466 out.push_str(&format!(
467 " #[inline] pub fn max(a: {ft}, b: {ft}) -> {ft} {{ libm::fmax(a as f64, b as f64) as {ft} }}\n"
468 ));
469 out.push_str(&format!(
470 " #[inline] pub fn expm1(x: {ft}) -> {ft} {{ libm::expm1(x as f64) as {ft} }}\n"
471 ));
472 out.push_str(&format!(
473 " #[inline] pub fn log1p(x: {ft}) -> {ft} {{ libm::log1p(x as f64) as {ft} }}\n"
474 ));
475 out.push_str(&format!(
476 " #[inline] pub fn log2(x: {ft}) -> {ft} {{ libm::log2(x as f64) as {ft} }}\n"
477 ));
478 out.push_str(&format!(
479 " #[inline] pub fn exp2(x: {ft}) -> {ft} {{ libm::exp2(x as f64) as {ft} }}\n"
480 ));
481 out.push_str(&format!(
482 " #[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"
483 ));
484 out.push_str(&format!(
485 " #[inline] pub fn sin_cos(x: {ft}) -> ({ft}, {ft}) {{ (libm::sin(x as f64) as {ft}, libm::cos(x as f64) as {ft}) }}\n"
486 ));
487 out.push_str("}\n");
488}
489
490fn strip_cfg_gated_module(code: &str) -> String {
495 let mut result = String::new();
496 let mut lines = code.lines().peekable();
497
498 while let Some(line) = lines.next() {
499 if line.starts_with("#[cfg(") && line.contains("feature") {
500 if let Some(&next) = lines.peek()
502 && next.starts_with("mod math {")
503 {
504 lines.next();
506 let mut brace_depth = 1;
507 while brace_depth > 0 {
508 if let Some(inner) = lines.next() {
509 for ch in inner.chars() {
510 if ch == '{' {
511 brace_depth += 1;
512 } else if ch == '}' {
513 brace_depth -= 1;
514 }
515 }
516 } else {
517 break;
518 }
519 }
520 if let Some(&next_after) = lines.peek()
522 && next_after.trim().is_empty()
523 {
524 lines.next();
525 }
526 continue;
527 }
528 }
529
530 result.push_str(line);
531 result.push('\n');
532 }
533
534 let trimmed = result.trim_start_matches('\n');
536 trimmed.to_string()
537}
538
539#[derive(serde::Deserialize)]
545struct RobotConfig {
546 #[allow(dead_code)]
547 robot: RobotInfo,
548 joints: Vec<JointConfig>,
549 generate: GenerateConfig,
550}
551
552#[derive(serde::Deserialize)]
554struct RobotInfo {
555 #[allow(dead_code)]
556 name: String,
557}
558
559#[derive(serde::Deserialize)]
561struct JointConfig {
562 theta: String,
563 #[serde(default)]
564 d: f64,
565 #[serde(default)]
566 a: f64,
567 #[serde(default)]
568 alpha: f64,
569}
570
571fn default_functions() -> Vec<String> {
572 vec!["fk".to_string(), "jacobian".to_string()]
573}
574
575fn default_output() -> String {
576 "robot_math.rs".to_string()
577}
578
579#[derive(serde::Deserialize)]
581struct GenerateConfig {
582 #[serde(default = "default_functions")]
583 functions: Vec<String>,
584 #[serde(default = "default_output")]
585 #[allow(dead_code)]
586 output: String,
587}
588
589pub fn from_toml(path: impl AsRef<Path>) -> Result<CodeGen, Box<dyn std::error::Error>> {
617 let content = fs::read_to_string(path.as_ref())?;
618 let config: RobotConfig = toml::from_str(&content)?;
619
620 let ctx = Context::new();
622
623 let theta_vars: Vec<Ex> = config.joints.iter().map(|j| ctx.symbol(&j.theta)).collect();
625
626 let d_vals: Vec<Ex> = config
628 .joints
629 .iter()
630 .map(|j| float_to_expr(&ctx, j.d))
631 .collect();
632 let a_vals: Vec<Ex> = config
633 .joints
634 .iter()
635 .map(|j| float_to_expr(&ctx, j.a))
636 .collect();
637 let alpha_vals: Vec<Ex> = config
638 .joints
639 .iter()
640 .map(|j| float_to_expr(&ctx, j.alpha))
641 .collect();
642
643 let dh_params: Vec<(&Ex, &Ex, &Ex, &Ex)> = theta_vars
644 .iter()
645 .enumerate()
646 .map(|(i, theta)| {
647 (
648 theta as &Ex,
649 &d_vals[i] as &Ex,
650 &a_vals[i] as &Ex,
651 &alpha_vals[i] as &Ex,
652 )
653 })
654 .collect();
655
656 let theta_names: Vec<&str> = config.joints.iter().map(|j| j.theta.as_str()).collect();
657
658 let mut codegen = CodeGen::new();
659
660 for func in &config.generate.functions {
661 match func.as_str() {
662 "fk" => {
663 let (x, y, z) = symplex::robotics::fk_position(&dh_params);
664 codegen = codegen.add_scalar_fn("fk_x", &x, &theta_names);
665 codegen = codegen.add_scalar_fn("fk_y", &y, &theta_names);
666 codegen = codegen.add_scalar_fn("fk_z", &z, &theta_names);
667 }
668 "jacobian" => {
669 let (x, y, _z) = symplex::robotics::fk_position(&dh_params);
670 let theta_refs: Vec<&Ex> = theta_vars.iter().collect();
671 let j = symplex::matrix::jacobian(&[&x, &y], &theta_refs);
672 codegen = codegen.add_matrix_fn("jacobian", &j, &theta_names);
673 }
674 "fk_matrix" => {
675 let t = symplex::robotics::fk_chain(&dh_params);
676 codegen = codegen.add_matrix_fn("fk_matrix", &t, &theta_names);
677 }
678 other => {
679 return Err(format!("unknown generate function: {other}").into());
680 }
681 }
682 }
683
684 Ok(codegen)
685}
686
687fn float_to_expr(ctx: &Context, v: f64) -> Ex {
698 ctx.from_f64_approx(v, 1_000_000)
699 .unwrap_or_else(|e| panic!("symplex-build: invalid DH parameter {v}: {e}"))
700}
701
702pub fn robot_arm(joints: &[(&str, f64, f64, f64)]) -> RobotArmBuilder {
723 let owned: Vec<(String, f64, f64, f64)> = joints
724 .iter()
725 .map(|(name, d, a, alpha)| (name.to_string(), *d, *a, *alpha))
726 .collect();
727 RobotArmBuilder::new(owned)
728}
729
730pub struct RobotArmBuilder {
736 ctx: Context,
737 joints: Vec<(String, f64, f64, f64)>,
738 codegen: CodeGen,
739 generated_fk: bool,
740 generated_jacobian: bool,
741}
742
743impl RobotArmBuilder {
744 fn new(joints: Vec<(String, f64, f64, f64)>) -> Self {
745 Self {
746 ctx: Context::new(),
747 joints,
748 codegen: CodeGen::new(),
749 generated_fk: false,
750 generated_jacobian: false,
751 }
752 }
753
754 fn build_dh(&self) -> (Vec<Ex>, Vec<Ex>, Vec<Ex>, Vec<Ex>) {
756 let ctx = &self.ctx;
757 let thetas: Vec<Ex> = self
758 .joints
759 .iter()
760 .map(|(name, _, _, _)| ctx.symbol(name))
761 .collect();
762 let d_vals: Vec<Ex> = self
763 .joints
764 .iter()
765 .map(|(_, d, _, _)| float_to_expr(ctx, *d))
766 .collect();
767 let a_vals: Vec<Ex> = self
768 .joints
769 .iter()
770 .map(|(_, _, a, _)| float_to_expr(ctx, *a))
771 .collect();
772 let alpha_vals: Vec<Ex> = self
773 .joints
774 .iter()
775 .map(|(_, _, _, alpha)| float_to_expr(ctx, *alpha))
776 .collect();
777 (thetas, d_vals, a_vals, alpha_vals)
778 }
779
780 fn theta_names_owned(&self) -> Vec<String> {
781 self.joints
782 .iter()
783 .map(|(name, _, _, _)| name.clone())
784 .collect()
785 }
786
787 pub fn generate_fk(mut self, name: &str) -> Self {
789 let (thetas, d_vals, a_vals, alpha_vals) = self.build_dh();
790 let dh: Vec<(&Ex, &Ex, &Ex, &Ex)> = thetas
791 .iter()
792 .enumerate()
793 .map(|(i, t)| {
794 (
795 t as &Ex,
796 &d_vals[i] as &Ex,
797 &a_vals[i] as &Ex,
798 &alpha_vals[i] as &Ex,
799 )
800 })
801 .collect();
802 let owned_names = self.theta_names_owned();
803 let theta_names: Vec<&str> = owned_names.iter().map(|s| s.as_str()).collect();
804 let (x, y, z) = symplex::robotics::fk_position(&dh);
805
806 let name_x = format!("{name}_x");
807 let name_y = format!("{name}_y");
808 let name_z = format!("{name}_z");
809
810 self.codegen = self.codegen.add_scalar_fn(&name_x, &x, &theta_names);
811 self.codegen = self.codegen.add_scalar_fn(&name_y, &y, &theta_names);
812 self.codegen = self.codegen.add_scalar_fn(&name_z, &z, &theta_names);
813 self.generated_fk = true;
814 self
815 }
816
817 pub fn generate_fk_matrix(mut self, name: &str) -> Self {
825 let (thetas, d_vals, a_vals, alpha_vals) = self.build_dh();
826 let dh: Vec<(&Ex, &Ex, &Ex, &Ex)> = thetas
827 .iter()
828 .enumerate()
829 .map(|(i, t)| {
830 (
831 t as &Ex,
832 &d_vals[i] as &Ex,
833 &a_vals[i] as &Ex,
834 &alpha_vals[i] as &Ex,
835 )
836 })
837 .collect();
838 let owned_names = self.theta_names_owned();
839 let theta_names: Vec<&str> = owned_names.iter().map(|s| s.as_str()).collect();
840 let t = symplex::robotics::fk_chain(&dh);
841 self.codegen = self.codegen.add_matrix_fn(name, &t, &theta_names);
842 self
843 }
844
845 pub fn generate_jacobian(mut self, name: &str) -> Self {
847 let (thetas, d_vals, a_vals, alpha_vals) = self.build_dh();
848 let dh: Vec<(&Ex, &Ex, &Ex, &Ex)> = thetas
849 .iter()
850 .enumerate()
851 .map(|(i, t)| {
852 (
853 t as &Ex,
854 &d_vals[i] as &Ex,
855 &a_vals[i] as &Ex,
856 &alpha_vals[i] as &Ex,
857 )
858 })
859 .collect();
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 let theta_refs: Vec<&Ex> = thetas.iter().collect();
864 let j = symplex::matrix::jacobian(&[&x, &y], &theta_refs);
865
866 self.codegen = self.codegen.add_matrix_fn(name, &j, &theta_names);
867 self.generated_jacobian = true;
868 self
869 }
870
871 pub fn generate_all(self) -> Self {
873 let s = if !self.generated_fk {
874 self.generate_fk("fk")
875 } else {
876 self
877 };
878 if !s.generated_jacobian {
879 s.generate_jacobian("jacobian")
880 } else {
881 s
882 }
883 }
884
885 pub fn no_std(mut self) -> Self {
887 self.codegen = self.codegen.no_std(true);
888 self
889 }
890
891 pub fn write_to_out_dir(self, filename: &str) -> Result<(), Box<dyn std::error::Error>> {
893 self.codegen.write_to_out_dir(filename)
894 }
895
896 pub fn write_to_path(self, path: impl AsRef<Path>) -> Result<(), Box<dyn std::error::Error>> {
898 self.codegen.write_to_path(path)
899 }
900
901 pub fn into_codegen(self) -> CodeGen {
903 self.codegen
904 }
905}
906
907#[cfg(test)]
912mod tests {
913 use super::*;
914
915 #[test]
916 fn codegen_new_default() {
917 let cg = CodeGen::new();
918 assert!(cg.functions.is_empty());
920 assert!(!cg.generate_tests);
921 }
922
923 #[test]
924 fn codegen_default_trait() {
925 let cg = CodeGen::default();
927 assert!(cg.functions.is_empty());
928 }
929
930 #[test]
931 fn codegen_generate_empty() {
932 let cg = CodeGen::new();
933 let code = cg.generate().unwrap();
934 assert!(
936 code.contains("Auto-generated by symplex-build"),
937 "expected header comment in generated output, got: {code}"
938 );
939 assert!(
941 !code.contains("fn "),
942 "expected no function definitions in empty codegen"
943 );
944 }
945
946 #[test]
947 fn float_to_expr_is_exact_and_reduced() {
948 let ctx = Context::new();
949 assert_eq!(format!("{}", float_to_expr(&ctx, 0.0)), "0");
950 assert_eq!(format!("{}", float_to_expr(&ctx, 2.0)), "2");
951 assert_eq!(format!("{}", float_to_expr(&ctx, -3.0)), "-3");
952 assert_eq!(format!("{}", float_to_expr(&ctx, 0.3)), "3/10");
953 assert_eq!(format!("{}", float_to_expr(&ctx, 0.25)), "1/4");
954 assert_eq!(format!("{}", float_to_expr(&ctx, 0.1 + 0.2)), "3/10");
955 assert_eq!(format!("{}", float_to_expr(&ctx, 1.0 / 3.0)), "1/3");
956 assert_eq!(format!("{}", float_to_expr(&ctx, 0.123456)), "1929/15625");
957 }
958
959 #[test]
960 fn robot_arm_generates_fk_matrix() {
961 let code = robot_arm(&[("q1", 0.0, 0.3, 0.0), ("q2", 0.1, 0.25, 0.0)])
962 .generate_fk_matrix("fk_t")
963 .into_codegen()
964 .generate()
965 .unwrap();
966 assert!(code.contains("fn fk_t("), "{code}");
967 assert!(
968 code.contains("q1: f64") && code.contains("q2: f64"),
969 "{code}"
970 );
971 assert!(code.contains("[f64; 16]"), "expected 4×4 matrix:\n{code}");
973 assert!(!code.contains("0.30000000000000004"), "{code}");
975 }
976
977 #[test]
978 fn fk_matrix_last_column_matches_fk_position() {
979 let ctx = Context::new();
982 let (q1, q2) = (ctx.symbol("q1"), ctx.symbol("q2"));
983 let zero = ctx.int(0);
984 let (l1, l2) = (float_to_expr(&ctx, 0.3), float_to_expr(&ctx, 0.25));
985 let dh = [(&q1, &zero, &l1, &zero), (&q2, &zero, &l2, &zero)];
986 let t = symplex::robotics::fk_chain(&dh);
987 let (x, y, z) = symplex::robotics::fk_position(&dh);
988 assert_eq!(t.get(0, 3).eval(), x);
989 assert_eq!(t.get(1, 3).eval(), y);
990 assert_eq!(t.get(2, 3).eval(), z);
991 }
992
993 #[test]
994 fn from_toml_accepts_fk_matrix() {
995 let dir = std::env::temp_dir().join(format!("symplex_build_fkm_{}", std::process::id()));
996 fs::create_dir_all(&dir).unwrap();
997 let path = dir.join("robot.toml");
998 fs::write(
999 &path,
1000 r#"
1001[robot]
1002name = "one_link"
1003[[joints]]
1004theta = "q"
1005a = 0.5
1006[generate]
1007functions = ["fk_matrix"]
1008"#,
1009 )
1010 .unwrap();
1011 let code = from_toml(&path).unwrap().generate().unwrap();
1012 assert!(code.contains("fn fk_matrix("), "{code}");
1013 assert!(code.contains("[f64; 16]"), "{code}");
1014 let _ = fs::remove_dir_all(&dir);
1015 }
1016
1017 #[test]
1018 fn robot_arm_generates_fk_and_jacobian() {
1019 let code = robot_arm(&[("theta1", 0.0, 0.3, 0.0), ("theta2", 0.0, 0.25, 0.0)])
1020 .generate_all()
1021 .into_codegen()
1022 .generate()
1023 .unwrap();
1024
1025 for name in ["fk_x", "fk_y", "fk_z", "jacobian"] {
1026 assert!(
1027 code.contains(&format!("fn {name}(")),
1028 "expected `{name}` in generated code:\n{code}"
1029 );
1030 }
1031 assert!(code.contains("theta1: f64") && code.contains("theta2: f64"));
1032 assert!(code.contains("[f64; 4]"), "expected 2×2 Jacobian:\n{code}");
1034 }
1035
1036 #[test]
1037 fn robot_arm_no_std_emits_single_math_module() {
1038 let code = robot_arm(&[("q", 0.0, 1.0, 0.0)])
1039 .no_std()
1040 .generate_all()
1041 .into_codegen()
1042 .generate()
1043 .unwrap();
1044 assert_eq!(code.matches("mod math {").count(), 2, "{code}");
1046 }
1047
1048 fn two_special_fns(opts: CodegenOptions) -> CodeGen {
1050 let ctx = Context::new();
1051 let x = ctx.symbol("x");
1052 let y = ctx.symbol("y");
1053 CodeGen::new()
1054 .options(opts)
1055 .add_scalar_fn("g", &(x.gamma() + &y), &["x", "y"])
1056 .add_scalar_fn("e", &(x.erf() * &y), &["x", "y"])
1057 }
1058
1059 #[test]
1060 fn generate_emits_runtime_module_once_for_two_special_functions() {
1061 let code = two_special_fns(CodegenOptions::default())
1062 .generate()
1063 .unwrap();
1064 assert_eq!(code.matches("mod symplex_rt {").count(), 1, "{code}");
1065 assert!(
1066 code.contains("pub fn gamma(") && code.contains("pub fn erf("),
1067 "{code}"
1068 );
1069 assert!(code.contains("fn g(") && code.contains("fn e("), "{code}");
1070 assert!(code.find("mod symplex_rt {").unwrap() < code.find("fn g(").unwrap());
1072 assert!(!code.contains("pub fn bessel_k("), "{code}");
1074 }
1075
1076 #[test]
1077 fn generate_emits_runtime_module_once_in_no_std_mode() {
1078 let code = two_special_fns(CodegenOptions::no_std())
1079 .generate()
1080 .unwrap();
1081 assert_eq!(code.matches("mod symplex_rt {").count(), 1, "{code}");
1082 assert_eq!(code.matches("mod math {").count(), 2, "{code}");
1083 let math_pos = code.find("mod math {").unwrap();
1085 let rt_pos = code.find("mod symplex_rt {").unwrap();
1086 let fn_pos = code.find("fn g(").unwrap();
1087 assert!(math_pos < rt_pos && rt_pos < fn_pos, "{code}");
1088 }
1089
1090 #[test]
1091 fn generate_honours_emit_runtime_false() {
1092 let opts = CodegenOptions {
1093 emit_runtime: false,
1094 ..Default::default()
1095 };
1096 let code = two_special_fns(opts).generate().unwrap();
1097 assert_eq!(code.matches("mod symplex_rt {").count(), 0, "{code}");
1098 assert!(code.contains("symplex_rt::gamma("), "{code}");
1099 }
1100
1101 #[test]
1102 fn generate_omits_runtime_when_unused() {
1103 let ctx = Context::new();
1104 let x = ctx.symbol("x");
1105 let code = CodeGen::new()
1106 .add_scalar_fn("f", &(x.sin() + x.powi(2)), &["x"])
1107 .generate()
1108 .unwrap();
1109 assert!(!code.contains("mod symplex_rt"), "{code}");
1110 }
1111
1112 #[test]
1115 fn generated_file_with_two_special_functions_compiles() {
1116 let Ok(out) = std::process::Command::new("rustc")
1117 .arg("--version")
1118 .output()
1119 else {
1120 eprintln!("rustc not available; skipping compile check");
1121 return;
1122 };
1123 if !out.status.success() {
1124 return;
1125 }
1126 let code = two_special_fns(CodegenOptions::default())
1127 .generate()
1128 .unwrap();
1129 let dir = std::env::temp_dir().join(format!("symplex_build_rt_{}", std::process::id()));
1130 fs::create_dir_all(&dir).unwrap();
1131 let src = dir.join("gen.rs");
1132 fs::write(&src, format!("#![allow(dead_code)]\n{code}")).unwrap();
1133 let out = std::process::Command::new("rustc")
1134 .args(["--crate-type", "lib", "--edition", "2024", "-o"])
1135 .arg(dir.join("gen.rlib"))
1136 .arg(&src)
1137 .output()
1138 .unwrap();
1139 let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
1140 let _ = fs::remove_dir_all(&dir);
1141 assert!(
1142 out.status.success(),
1143 "generated file failed to compile:\n{stderr}\n{code}"
1144 );
1145 }
1146
1147 #[test]
1148 fn from_toml_round_trip() {
1149 let dir = std::env::temp_dir().join(format!("symplex_build_{}", std::process::id()));
1150 fs::create_dir_all(&dir).unwrap();
1151 let path = dir.join("robot.toml");
1152 fs::write(
1153 &path,
1154 r#"
1155[robot]
1156name = "two_link"
1157
1158[[joints]]
1159theta = "theta1"
1160a = 0.3
1161
1162[[joints]]
1163theta = "theta2"
1164a = 0.25
1165
1166[generate]
1167functions = ["fk", "jacobian"]
1168"#,
1169 )
1170 .unwrap();
1171
1172 let code = from_toml(&path).unwrap().generate().unwrap();
1173 assert!(code.contains("fn fk_x("), "{code}");
1174 assert!(code.contains("fn jacobian("), "{code}");
1175
1176 let _ = fs::remove_dir_all(&dir);
1177 }
1178
1179 #[test]
1180 fn from_toml_rejects_unknown_function() {
1181 let dir = std::env::temp_dir().join(format!("symplex_build_bad_{}", std::process::id()));
1182 fs::create_dir_all(&dir).unwrap();
1183 let path = dir.join("robot.toml");
1184 fs::write(
1185 &path,
1186 r#"
1187[robot]
1188name = "r"
1189[[joints]]
1190theta = "q"
1191[generate]
1192functions = ["dynamics"]
1193"#,
1194 )
1195 .unwrap();
1196 let err = from_toml(&path)
1197 .err()
1198 .expect("unknown function should error");
1199 assert!(err.to_string().contains("dynamics"), "{err}");
1200 let _ = fs::remove_dir_all(&dir);
1201 }
1202}