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}