1use anyhow::Result;
4use kcl_derive_docs::stdlib;
5use kcmc::{
6 each_cmd as mcmd,
7 length_unit::LengthUnit,
8 shared,
9 shared::{Point3d, Point4d},
10 ModelingCmd,
11};
12use kittycad_modeling_cmds as kcmc;
13
14use super::args::TyF64;
15use crate::{
16 errors::{KclError, KclErrorDetails},
17 execution::{types::RuntimeType, ExecState, KclValue, SolidOrSketchOrImportedGeometry},
18 std::Args,
19};
20
21pub async fn scale(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
23 let objects = args.get_unlabeled_kw_arg_typed(
24 "objects",
25 &RuntimeType::Union(vec![
26 RuntimeType::sketches(),
27 RuntimeType::solids(),
28 RuntimeType::imported(),
29 ]),
30 exec_state,
31 )?;
32 let scale_x: Option<TyF64> = args.get_kw_arg_opt_typed("x", &RuntimeType::count(), exec_state)?;
33 let scale_y: Option<TyF64> = args.get_kw_arg_opt_typed("y", &RuntimeType::count(), exec_state)?;
34 let scale_z: Option<TyF64> = args.get_kw_arg_opt_typed("z", &RuntimeType::count(), exec_state)?;
35 let global = args.get_kw_arg_opt("global")?;
36
37 if scale_x.is_none() && scale_y.is_none() && scale_z.is_none() {
39 return Err(KclError::Semantic(KclErrorDetails {
40 message: "Expected `x`, `y`, or `z` to be provided.".to_string(),
41 source_ranges: vec![args.source_range],
42 }));
43 }
44
45 let objects = inner_scale(
46 objects,
47 scale_x.map(|t| t.n),
48 scale_y.map(|t| t.n),
49 scale_z.map(|t| t.n),
50 global,
51 exec_state,
52 args,
53 )
54 .await?;
55 Ok(objects.into())
56}
57
58#[stdlib {
146 name = "scale",
147 feature_tree_operation = false,
148 keywords = true,
149 unlabeled_first = true,
150 args = {
151 objects = {docs = "The solid, sketch, or set of solids or sketches to scale."},
152 x = {docs = "The scale factor for the x axis. Default is 1 if not provided.", include_in_snippet = true},
153 y = {docs = "The scale factor for the y axis. Default is 1 if not provided.", include_in_snippet = true},
154 z = {docs = "The scale factor for the z axis. Default is 1 if not provided.", include_in_snippet = true},
155 global = {docs = "If true, the transform is applied in global space. The origin of the model will move. By default, the transform is applied in local sketch axis, therefore the origin will not move."}
156 },
157 tags = ["transform"]
158}]
159async fn inner_scale(
160 objects: SolidOrSketchOrImportedGeometry,
161 x: Option<f64>,
162 y: Option<f64>,
163 z: Option<f64>,
164 global: Option<bool>,
165 exec_state: &mut ExecState,
166 args: Args,
167) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
168 if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
171 args.flush_batch_for_solids(exec_state, solids).await?;
172 }
173
174 let mut objects = objects.clone();
175 for object_id in objects.ids(&args.ctx).await? {
176 let id = exec_state.next_uuid();
177
178 args.batch_modeling_cmd(
179 id,
180 ModelingCmd::from(mcmd::SetObjectTransform {
181 object_id,
182 transforms: vec![shared::ComponentTransform {
183 scale: Some(shared::TransformBy::<Point3d<f64>> {
184 property: Point3d {
185 x: x.unwrap_or(1.0),
186 y: y.unwrap_or(1.0),
187 z: z.unwrap_or(1.0),
188 },
189 set: false,
190 is_local: !global.unwrap_or(false),
191 }),
192 translate: None,
193 rotate_rpy: None,
194 rotate_angle_axis: None,
195 }],
196 }),
197 )
198 .await?;
199 }
200
201 Ok(objects)
202}
203
204pub async fn translate(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
206 let objects = args.get_unlabeled_kw_arg_typed(
207 "objects",
208 &RuntimeType::Union(vec![
209 RuntimeType::sketches(),
210 RuntimeType::solids(),
211 RuntimeType::imported(),
212 ]),
213 exec_state,
214 )?;
215 let translate_x: Option<TyF64> = args.get_kw_arg_opt_typed("x", &RuntimeType::length(), exec_state)?;
216 let translate_y: Option<TyF64> = args.get_kw_arg_opt_typed("y", &RuntimeType::length(), exec_state)?;
217 let translate_z: Option<TyF64> = args.get_kw_arg_opt_typed("z", &RuntimeType::length(), exec_state)?;
218 let global = args.get_kw_arg_opt("global")?;
219
220 if translate_x.is_none() && translate_y.is_none() && translate_z.is_none() {
222 return Err(KclError::Semantic(KclErrorDetails {
223 message: "Expected `x`, `y`, or `z` to be provided.".to_string(),
224 source_ranges: vec![args.source_range],
225 }));
226 }
227
228 let objects = inner_translate(objects, translate_x, translate_y, translate_z, global, exec_state, args).await?;
229 Ok(objects.into())
230}
231
232#[stdlib {
377 name = "translate",
378 feature_tree_operation = false,
379 keywords = true,
380 unlabeled_first = true,
381 args = {
382 objects = {docs = "The solid, sketch, or set of solids or sketches to move."},
383 x = {docs = "The amount to move the solid or sketch along the x axis. Defaults to 0 if not provided.", include_in_snippet = true},
384 y = {docs = "The amount to move the solid or sketch along the y axis. Defaults to 0 if not provided.", include_in_snippet = true},
385 z = {docs = "The amount to move the solid or sketch along the z axis. Defaults to 0 if not provided.", include_in_snippet = true},
386 global = {docs = "If true, the transform is applied in global space. The origin of the model will move. By default, the transform is applied in local sketch axis, therefore the origin will not move."}
387 },
388 tags = ["transform"]
389}]
390async fn inner_translate(
391 objects: SolidOrSketchOrImportedGeometry,
392 x: Option<TyF64>,
393 y: Option<TyF64>,
394 z: Option<TyF64>,
395 global: Option<bool>,
396 exec_state: &mut ExecState,
397 args: Args,
398) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
399 if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
402 args.flush_batch_for_solids(exec_state, solids).await?;
403 }
404
405 let mut objects = objects.clone();
406 for object_id in objects.ids(&args.ctx).await? {
407 let id = exec_state.next_uuid();
408
409 args.batch_modeling_cmd(
410 id,
411 ModelingCmd::from(mcmd::SetObjectTransform {
412 object_id,
413 transforms: vec![shared::ComponentTransform {
414 translate: Some(shared::TransformBy::<Point3d<LengthUnit>> {
415 property: shared::Point3d {
416 x: LengthUnit(x.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
417 y: LengthUnit(y.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
418 z: LengthUnit(z.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
419 },
420 set: false,
421 is_local: !global.unwrap_or(false),
422 }),
423 scale: None,
424 rotate_rpy: None,
425 rotate_angle_axis: None,
426 }],
427 }),
428 )
429 .await?;
430 }
431
432 Ok(objects)
433}
434
435pub async fn rotate(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
437 let objects = args.get_unlabeled_kw_arg_typed(
438 "objects",
439 &RuntimeType::Union(vec![
440 RuntimeType::sketches(),
441 RuntimeType::solids(),
442 RuntimeType::imported(),
443 ]),
444 exec_state,
445 )?;
446 let roll: Option<TyF64> = args.get_kw_arg_opt_typed("roll", &RuntimeType::degrees(), exec_state)?;
447 let pitch: Option<TyF64> = args.get_kw_arg_opt_typed("pitch", &RuntimeType::degrees(), exec_state)?;
448 let yaw: Option<TyF64> = args.get_kw_arg_opt_typed("yaw", &RuntimeType::degrees(), exec_state)?;
449 let axis: Option<[TyF64; 3]> = args.get_kw_arg_opt_typed("axis", &RuntimeType::point3d(), exec_state)?;
450 let angle: Option<TyF64> = args.get_kw_arg_opt_typed("angle", &RuntimeType::degrees(), exec_state)?;
451 let global = args.get_kw_arg_opt("global")?;
452
453 if roll.is_none() && pitch.is_none() && yaw.is_none() && axis.is_none() && angle.is_none() {
455 return Err(KclError::Semantic(KclErrorDetails {
456 message: "Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided.".to_string(),
457 source_ranges: vec![args.source_range],
458 }));
459 }
460
461 if roll.is_some() || pitch.is_some() || yaw.is_some() {
463 if axis.is_some() || angle.is_some() {
465 return Err(KclError::Semantic(KclErrorDetails {
466 message: "Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."
467 .to_string(),
468 source_ranges: vec![args.source_range],
469 }));
470 }
471 }
472
473 if axis.is_some() || angle.is_some() {
475 if axis.is_none() {
476 return Err(KclError::Semantic(KclErrorDetails {
477 message: "Expected `axis` to be provided when `angle` is provided.".to_string(),
478 source_ranges: vec![args.source_range],
479 }));
480 }
481 if angle.is_none() {
482 return Err(KclError::Semantic(KclErrorDetails {
483 message: "Expected `angle` to be provided when `axis` is provided.".to_string(),
484 source_ranges: vec![args.source_range],
485 }));
486 }
487
488 if roll.is_some() || pitch.is_some() || yaw.is_some() {
490 return Err(KclError::Semantic(KclErrorDetails {
491 message: "Expected `roll`, `pitch`, and `yaw` to not be provided when `axis` and `angle` are provided."
492 .to_string(),
493 source_ranges: vec![args.source_range],
494 }));
495 }
496 }
497
498 if let Some(roll) = &roll {
500 if !(-360.0..=360.0).contains(&roll.n) {
501 return Err(KclError::Semantic(KclErrorDetails {
502 message: format!("Expected roll to be between -360 and 360, found `{}`", roll.n),
503 source_ranges: vec![args.source_range],
504 }));
505 }
506 }
507 if let Some(pitch) = &pitch {
508 if !(-360.0..=360.0).contains(&pitch.n) {
509 return Err(KclError::Semantic(KclErrorDetails {
510 message: format!("Expected pitch to be between -360 and 360, found `{}`", pitch.n),
511 source_ranges: vec![args.source_range],
512 }));
513 }
514 }
515 if let Some(yaw) = &yaw {
516 if !(-360.0..=360.0).contains(&yaw.n) {
517 return Err(KclError::Semantic(KclErrorDetails {
518 message: format!("Expected yaw to be between -360 and 360, found `{}`", yaw.n),
519 source_ranges: vec![args.source_range],
520 }));
521 }
522 }
523
524 if let Some(angle) = &angle {
526 if !(-360.0..=360.0).contains(&angle.n) {
527 return Err(KclError::Semantic(KclErrorDetails {
528 message: format!("Expected angle to be between -360 and 360, found `{}`", angle.n),
529 source_ranges: vec![args.source_range],
530 }));
531 }
532 }
533
534 let objects = inner_rotate(
535 objects,
536 roll.map(|t| t.n),
537 pitch.map(|t| t.n),
538 yaw.map(|t| t.n),
539 axis.map(|a| [a[0].n, a[1].n, a[2].n]),
542 angle.map(|t| t.n),
543 global,
544 exec_state,
545 args,
546 )
547 .await?;
548 Ok(objects.into())
549}
550
551#[stdlib {
741 name = "rotate",
742 feature_tree_operation = false,
743 keywords = true,
744 unlabeled_first = true,
745 args = {
746 objects = {docs = "The solid, sketch, or set of solids or sketches to rotate."},
747 roll = {docs = "The roll angle in degrees. Must be between -360 and 360. Default is 0 if not given.", include_in_snippet = true},
748 pitch = {docs = "The pitch angle in degrees. Must be between -360 and 360. Default is 0 if not given.", include_in_snippet = true},
749 yaw = {docs = "The yaw angle in degrees. Must be between -360 and 360. Default is 0 if not given.", include_in_snippet = true},
750 axis = {docs = "The axis to rotate around. Must be used with `angle`.", include_in_snippet = false},
751 angle = {docs = "The angle to rotate in degrees. Must be used with `axis`. Must be between -360 and 360.", include_in_snippet = false},
752 global = {docs = "If true, the transform is applied in global space. The origin of the model will move. By default, the transform is applied in local sketch axis, therefore the origin will not move."}
753 },
754 tags = ["transform"]
755}]
756#[allow(clippy::too_many_arguments)]
757async fn inner_rotate(
758 objects: SolidOrSketchOrImportedGeometry,
759 roll: Option<f64>,
760 pitch: Option<f64>,
761 yaw: Option<f64>,
762 axis: Option<[f64; 3]>,
763 angle: Option<f64>,
764 global: Option<bool>,
765 exec_state: &mut ExecState,
766 args: Args,
767) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
768 if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
771 args.flush_batch_for_solids(exec_state, solids).await?;
772 }
773
774 let mut objects = objects.clone();
775 for object_id in objects.ids(&args.ctx).await? {
776 let id = exec_state.next_uuid();
777
778 if let (Some(axis), Some(angle)) = (&axis, angle) {
779 args.batch_modeling_cmd(
780 id,
781 ModelingCmd::from(mcmd::SetObjectTransform {
782 object_id,
783 transforms: vec![shared::ComponentTransform {
784 rotate_angle_axis: Some(shared::TransformBy::<Point4d<f64>> {
785 property: shared::Point4d {
786 x: axis[0],
787 y: axis[1],
788 z: axis[2],
789 w: angle,
790 },
791 set: false,
792 is_local: !global.unwrap_or(false),
793 }),
794 scale: None,
795 rotate_rpy: None,
796 translate: None,
797 }],
798 }),
799 )
800 .await?;
801 } else {
802 args.batch_modeling_cmd(
804 id,
805 ModelingCmd::from(mcmd::SetObjectTransform {
806 object_id,
807 transforms: vec![shared::ComponentTransform {
808 rotate_rpy: Some(shared::TransformBy::<Point3d<f64>> {
809 property: shared::Point3d {
810 x: roll.unwrap_or(0.0),
811 y: pitch.unwrap_or(0.0),
812 z: yaw.unwrap_or(0.0),
813 },
814 set: false,
815 is_local: !global.unwrap_or(false),
816 }),
817 scale: None,
818 rotate_angle_axis: None,
819 translate: None,
820 }],
821 }),
822 )
823 .await?;
824 }
825 }
826
827 Ok(objects)
828}
829
830#[cfg(test)]
831mod tests {
832 use pretty_assertions::assert_eq;
833
834 use crate::execution::parse_execute;
835
836 const PIPE: &str = r#"sweepPath = startSketchOn(XZ)
837 |> startProfile(at = [0.05, 0.05])
838 |> line(end = [0, 7])
839 |> tangentialArc(angle = 90, radius = 5)
840 |> line(end = [-3, 0])
841 |> tangentialArc(angle = -90, radius = 5)
842 |> line(end = [0, 7])
843
844// Create a hole for the pipe.
845pipeHole = startSketchOn(XY)
846 |> circle(
847 center = [0, 0],
848 radius = 1.5,
849 )
850sweepSketch = startSketchOn(XY)
851 |> circle(
852 center = [0, 0],
853 radius = 2,
854 )
855 |> subtract2d(tool = pipeHole)
856 |> sweep(
857 path = sweepPath,
858 )"#;
859
860 #[tokio::test(flavor = "multi_thread")]
861 async fn test_rotate_empty() {
862 let ast = PIPE.to_string()
863 + r#"
864 |> rotate()
865"#;
866 let result = parse_execute(&ast).await;
867 assert!(result.is_err());
868 assert_eq!(
869 result.unwrap_err().message(),
870 r#"Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided."#.to_string()
871 );
872 }
873
874 #[tokio::test(flavor = "multi_thread")]
875 async fn test_rotate_axis_no_angle() {
876 let ast = PIPE.to_string()
877 + r#"
878 |> rotate(
879 axis = [0, 0, 1.0],
880 )
881"#;
882 let result = parse_execute(&ast).await;
883 assert!(result.is_err());
884 assert_eq!(
885 result.unwrap_err().message(),
886 r#"Expected `angle` to be provided when `axis` is provided."#.to_string()
887 );
888 }
889
890 #[tokio::test(flavor = "multi_thread")]
891 async fn test_rotate_angle_no_axis() {
892 let ast = PIPE.to_string()
893 + r#"
894 |> rotate(
895 angle = 90,
896 )
897"#;
898 let result = parse_execute(&ast).await;
899 assert!(result.is_err());
900 assert_eq!(
901 result.unwrap_err().message(),
902 r#"Expected `axis` to be provided when `angle` is provided."#.to_string()
903 );
904 }
905
906 #[tokio::test(flavor = "multi_thread")]
907 async fn test_rotate_angle_out_of_range() {
908 let ast = PIPE.to_string()
909 + r#"
910 |> rotate(
911 axis = [0, 0, 1.0],
912 angle = 900,
913 )
914"#;
915 let result = parse_execute(&ast).await;
916 assert!(result.is_err());
917 assert_eq!(
918 result.unwrap_err().message(),
919 r#"Expected angle to be between -360 and 360, found `900`"#.to_string()
920 );
921 }
922
923 #[tokio::test(flavor = "multi_thread")]
924 async fn test_rotate_angle_axis_yaw() {
925 let ast = PIPE.to_string()
926 + r#"
927 |> rotate(
928 axis = [0, 0, 1.0],
929 angle = 90,
930 yaw = 90,
931 )
932"#;
933 let result = parse_execute(&ast).await;
934 assert!(result.is_err());
935 assert_eq!(
936 result.unwrap_err().message(),
937 r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
938 .to_string()
939 );
940 }
941
942 #[tokio::test(flavor = "multi_thread")]
943 async fn test_rotate_yaw_only() {
944 let ast = PIPE.to_string()
945 + r#"
946 |> rotate(
947 yaw = 90,
948 )
949"#;
950 parse_execute(&ast).await.unwrap();
951 }
952
953 #[tokio::test(flavor = "multi_thread")]
954 async fn test_rotate_pitch_only() {
955 let ast = PIPE.to_string()
956 + r#"
957 |> rotate(
958 pitch = 90,
959 )
960"#;
961 parse_execute(&ast).await.unwrap();
962 }
963
964 #[tokio::test(flavor = "multi_thread")]
965 async fn test_rotate_roll_only() {
966 let ast = PIPE.to_string()
967 + r#"
968 |> rotate(
969 pitch = 90,
970 )
971"#;
972 parse_execute(&ast).await.unwrap();
973 }
974
975 #[tokio::test(flavor = "multi_thread")]
976 async fn test_rotate_yaw_out_of_range() {
977 let ast = PIPE.to_string()
978 + r#"
979 |> rotate(
980 yaw = 900,
981 pitch = 90,
982 roll = 90,
983 )
984"#;
985 let result = parse_execute(&ast).await;
986 assert!(result.is_err());
987 assert_eq!(
988 result.unwrap_err().message(),
989 r#"Expected yaw to be between -360 and 360, found `900`"#.to_string()
990 );
991 }
992
993 #[tokio::test(flavor = "multi_thread")]
994 async fn test_rotate_roll_out_of_range() {
995 let ast = PIPE.to_string()
996 + r#"
997 |> rotate(
998 yaw = 90,
999 pitch = 90,
1000 roll = 900,
1001 )
1002"#;
1003 let result = parse_execute(&ast).await;
1004 assert!(result.is_err());
1005 assert_eq!(
1006 result.unwrap_err().message(),
1007 r#"Expected roll to be between -360 and 360, found `900`"#.to_string()
1008 );
1009 }
1010
1011 #[tokio::test(flavor = "multi_thread")]
1012 async fn test_rotate_pitch_out_of_range() {
1013 let ast = PIPE.to_string()
1014 + r#"
1015 |> rotate(
1016 yaw = 90,
1017 pitch = 900,
1018 roll = 90,
1019 )
1020"#;
1021 let result = parse_execute(&ast).await;
1022 assert!(result.is_err());
1023 assert_eq!(
1024 result.unwrap_err().message(),
1025 r#"Expected pitch to be between -360 and 360, found `900`"#.to_string()
1026 );
1027 }
1028
1029 #[tokio::test(flavor = "multi_thread")]
1030 async fn test_rotate_roll_pitch_yaw_with_angle() {
1031 let ast = PIPE.to_string()
1032 + r#"
1033 |> rotate(
1034 yaw = 90,
1035 pitch = 90,
1036 roll = 90,
1037 angle = 90,
1038 )
1039"#;
1040 let result = parse_execute(&ast).await;
1041 assert!(result.is_err());
1042 assert_eq!(
1043 result.unwrap_err().message(),
1044 r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
1045 .to_string()
1046 );
1047 }
1048
1049 #[tokio::test(flavor = "multi_thread")]
1050 async fn test_translate_no_args() {
1051 let ast = PIPE.to_string()
1052 + r#"
1053 |> translate(
1054 )
1055"#;
1056 let result = parse_execute(&ast).await;
1057 assert!(result.is_err());
1058 assert_eq!(
1059 result.unwrap_err().message(),
1060 r#"Expected `x`, `y`, or `z` to be provided."#.to_string()
1061 );
1062 }
1063
1064 #[tokio::test(flavor = "multi_thread")]
1065 async fn test_scale_no_args() {
1066 let ast = PIPE.to_string()
1067 + r#"
1068 |> scale(
1069 )
1070"#;
1071 let result = parse_execute(&ast).await;
1072 assert!(result.is_err());
1073 assert_eq!(
1074 result.unwrap_err().message(),
1075 r#"Expected `x`, `y`, or `z` to be provided."#.to_string()
1076 );
1077 }
1078}