kcl_lib/std/
transform.rs

1//! Standard library transforms.
2
3use anyhow::Result;
4use kcmc::{
5    ModelingCmd, each_cmd as mcmd,
6    length_unit::LengthUnit,
7    shared,
8    shared::{OriginType, Point3d},
9};
10use kittycad_modeling_cmds as kcmc;
11
12use crate::{
13    errors::{KclError, KclErrorDetails},
14    execution::{
15        ExecState, KclValue, ModelingCmdMeta, SolidOrSketchOrImportedGeometry,
16        types::{PrimitiveType, RuntimeType},
17    },
18    std::{Args, args::TyF64, axis_or_reference::Axis3dOrPoint3d},
19};
20
21fn transform_by<T>(property: T, set: bool, is_local: bool, origin: Option<OriginType>) -> shared::TransformBy<T> {
22    shared::TransformBy {
23        property,
24        set,
25        #[expect(deprecated)]
26        is_local,
27        origin,
28    }
29}
30
31/// Scale a solid or a sketch.
32pub async fn scale(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
33    let objects = args.get_unlabeled_kw_arg(
34        "objects",
35        &RuntimeType::Union(vec![
36            RuntimeType::sketches(),
37            RuntimeType::solids(),
38            RuntimeType::imported(),
39        ]),
40        exec_state,
41    )?;
42    let scale_x: Option<TyF64> = args.get_kw_arg_opt("x", &RuntimeType::count(), exec_state)?;
43    let scale_y: Option<TyF64> = args.get_kw_arg_opt("y", &RuntimeType::count(), exec_state)?;
44    let scale_z: Option<TyF64> = args.get_kw_arg_opt("z", &RuntimeType::count(), exec_state)?;
45    let factor: Option<TyF64> = args.get_kw_arg_opt("factor", &RuntimeType::count(), exec_state)?;
46    let (scale_x, scale_y, scale_z) = match (scale_x, scale_y, scale_z, factor) {
47        (None, None, None, Some(factor)) => (Some(factor.clone()), Some(factor.clone()), Some(factor)),
48        // Ensure at least one scale value is provided.
49        (None, None, None, None) => {
50            return Err(KclError::new_semantic(KclErrorDetails::new(
51                "Expected `x`, `y`, `z` or `factor` to be provided.".to_string(),
52                vec![args.source_range],
53            )));
54        }
55        (x, y, z, None) => (x, y, z),
56        _ => {
57            return Err(KclError::new_semantic(KclErrorDetails::new(
58                "If you give `factor` then you cannot use  `x`, `y`, or `z`".to_string(),
59                vec![args.source_range],
60            )));
61        }
62    };
63    let global = args.get_kw_arg_opt("global", &RuntimeType::bool(), exec_state)?;
64
65    let objects = inner_scale(
66        objects,
67        scale_x.map(|t| t.n),
68        scale_y.map(|t| t.n),
69        scale_z.map(|t| t.n),
70        global,
71        exec_state,
72        args,
73    )
74    .await?;
75    Ok(objects.into())
76}
77
78async fn inner_scale(
79    objects: SolidOrSketchOrImportedGeometry,
80    x: Option<f64>,
81    y: Option<f64>,
82    z: Option<f64>,
83    global: Option<bool>,
84    exec_state: &mut ExecState,
85    args: Args,
86) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
87    // If we have a solid, flush the fillets and chamfers.
88    // Only transforms needs this, it is very odd, see: https://github.com/KittyCAD/modeling-app/issues/5880
89    if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
90        exec_state
91            .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), solids)
92            .await?;
93    }
94
95    let is_global = global.unwrap_or(false);
96    let origin = if is_global {
97        Some(OriginType::Global)
98    } else {
99        Some(OriginType::Local)
100    };
101
102    let mut objects = objects.clone();
103    for object_id in objects.ids(&args.ctx).await? {
104        exec_state
105            .batch_modeling_cmd(
106                ModelingCmdMeta::from_args(exec_state, &args),
107                ModelingCmd::from(mcmd::SetObjectTransform {
108                    object_id,
109                    transforms: vec![shared::ComponentTransform {
110                        scale: Some(transform_by(
111                            Point3d {
112                                x: x.unwrap_or(1.0),
113                                y: y.unwrap_or(1.0),
114                                z: z.unwrap_or(1.0),
115                            },
116                            false,
117                            !is_global,
118                            origin,
119                        )),
120                        translate: None,
121                        rotate_rpy: None,
122                        rotate_angle_axis: None,
123                    }],
124                }),
125            )
126            .await?;
127    }
128
129    Ok(objects)
130}
131
132/// Move a solid or a sketch.
133pub async fn translate(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
134    let objects = args.get_unlabeled_kw_arg(
135        "objects",
136        &RuntimeType::Union(vec![
137            RuntimeType::sketches(),
138            RuntimeType::solids(),
139            RuntimeType::imported(),
140        ]),
141        exec_state,
142    )?;
143    let translate_x: Option<TyF64> = args.get_kw_arg_opt("x", &RuntimeType::length(), exec_state)?;
144    let translate_y: Option<TyF64> = args.get_kw_arg_opt("y", &RuntimeType::length(), exec_state)?;
145    let translate_z: Option<TyF64> = args.get_kw_arg_opt("z", &RuntimeType::length(), exec_state)?;
146    let xyz: Option<[TyF64; 3]> = args.get_kw_arg_opt("xyz", &RuntimeType::point3d(), exec_state)?;
147    let global = args.get_kw_arg_opt("global", &RuntimeType::bool(), exec_state)?;
148
149    let objects = inner_translate(
150        objects,
151        xyz,
152        translate_x,
153        translate_y,
154        translate_z,
155        global,
156        exec_state,
157        args,
158    )
159    .await?;
160    Ok(objects.into())
161}
162
163#[allow(clippy::too_many_arguments)]
164async fn inner_translate(
165    objects: SolidOrSketchOrImportedGeometry,
166    xyz: Option<[TyF64; 3]>,
167    x: Option<TyF64>,
168    y: Option<TyF64>,
169    z: Option<TyF64>,
170    global: Option<bool>,
171    exec_state: &mut ExecState,
172    args: Args,
173) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
174    let (x, y, z) = match (xyz, x, y, z) {
175        (None, None, None, None) => {
176            return Err(KclError::new_semantic(KclErrorDetails::new(
177                "Expected `x`, `y`, or `z` to be provided.".to_string(),
178                vec![args.source_range],
179            )));
180        }
181        (Some(xyz), None, None, None) => {
182            let [x, y, z] = xyz;
183            (Some(x), Some(y), Some(z))
184        }
185        (None, x, y, z) => (x, y, z),
186        (Some(_), _, _, _) => {
187            return Err(KclError::new_semantic(KclErrorDetails::new(
188                "If you provide all 3 distances via the `xyz` arg, you cannot provide them separately via the `x`, `y` or `z` args."
189                    .to_string(),
190                vec![args.source_range],
191            )));
192        }
193    };
194    // If we have a solid, flush the fillets and chamfers.
195    // Only transforms needs this, it is very odd, see: https://github.com/KittyCAD/modeling-app/issues/5880
196    if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
197        exec_state
198            .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), solids)
199            .await?;
200    }
201
202    let is_global = global.unwrap_or(false);
203    let origin = if is_global {
204        Some(OriginType::Global)
205    } else {
206        Some(OriginType::Local)
207    };
208
209    let translation = shared::Point3d {
210        x: LengthUnit(x.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
211        y: LengthUnit(y.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
212        z: LengthUnit(z.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
213    };
214    let mut objects = objects.clone();
215    for object_id in objects.ids(&args.ctx).await? {
216        exec_state
217            .batch_modeling_cmd(
218                ModelingCmdMeta::from_args(exec_state, &args),
219                ModelingCmd::from(mcmd::SetObjectTransform {
220                    object_id,
221                    transforms: vec![shared::ComponentTransform {
222                        translate: Some(transform_by(translation, false, !is_global, origin)),
223                        scale: None,
224                        rotate_rpy: None,
225                        rotate_angle_axis: None,
226                    }],
227                }),
228            )
229            .await?;
230    }
231
232    Ok(objects)
233}
234
235/// Rotate a solid or a sketch.
236pub async fn rotate(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
237    let objects = args.get_unlabeled_kw_arg(
238        "objects",
239        &RuntimeType::Union(vec![
240            RuntimeType::sketches(),
241            RuntimeType::solids(),
242            RuntimeType::imported(),
243        ]),
244        exec_state,
245    )?;
246    let roll: Option<TyF64> = args.get_kw_arg_opt("roll", &RuntimeType::degrees(), exec_state)?;
247    let pitch: Option<TyF64> = args.get_kw_arg_opt("pitch", &RuntimeType::degrees(), exec_state)?;
248    let yaw: Option<TyF64> = args.get_kw_arg_opt("yaw", &RuntimeType::degrees(), exec_state)?;
249    let axis: Option<Axis3dOrPoint3d> = args.get_kw_arg_opt(
250        "axis",
251        &RuntimeType::Union(vec![
252            RuntimeType::Primitive(PrimitiveType::Axis3d),
253            RuntimeType::point3d(),
254        ]),
255        exec_state,
256    )?;
257    let origin = axis.clone().map(|a| a.axis_origin()).unwrap_or_default();
258    let axis = axis.map(|a| a.to_point3d());
259    let angle: Option<TyF64> = args.get_kw_arg_opt("angle", &RuntimeType::degrees(), exec_state)?;
260    let global = args.get_kw_arg_opt("global", &RuntimeType::bool(), exec_state)?;
261
262    // Check if no rotation values are provided.
263    if roll.is_none() && pitch.is_none() && yaw.is_none() && axis.is_none() && angle.is_none() {
264        return Err(KclError::new_semantic(KclErrorDetails::new(
265            "Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided.".to_string(),
266            vec![args.source_range],
267        )));
268    }
269
270    // If they give us a roll, pitch, or yaw, they must give us at least one of them.
271    if roll.is_some() || pitch.is_some() || yaw.is_some() {
272        // Ensure they didn't also provide an axis or angle.
273        if axis.is_some() || angle.is_some() {
274            return Err(KclError::new_semantic(KclErrorDetails::new(
275                "Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."
276                    .to_owned(),
277                vec![args.source_range],
278            )));
279        }
280    }
281
282    // If they give us an axis or angle, they must give us both.
283    if axis.is_some() || angle.is_some() {
284        if axis.is_none() {
285            return Err(KclError::new_semantic(KclErrorDetails::new(
286                "Expected `axis` to be provided when `angle` is provided.".to_string(),
287                vec![args.source_range],
288            )));
289        }
290        if angle.is_none() {
291            return Err(KclError::new_semantic(KclErrorDetails::new(
292                "Expected `angle` to be provided when `axis` is provided.".to_string(),
293                vec![args.source_range],
294            )));
295        }
296
297        // Ensure they didn't also provide a roll, pitch, or yaw.
298        if roll.is_some() || pitch.is_some() || yaw.is_some() {
299            return Err(KclError::new_semantic(KclErrorDetails::new(
300                "Expected `roll`, `pitch`, and `yaw` to not be provided when `axis` and `angle` are provided."
301                    .to_owned(),
302                vec![args.source_range],
303            )));
304        }
305    }
306
307    // Validate the roll, pitch, and yaw values.
308    if let Some(roll) = &roll
309        && !(-360.0..=360.0).contains(&roll.n)
310    {
311        return Err(KclError::new_semantic(KclErrorDetails::new(
312            format!("Expected roll to be between -360 and 360, found `{}`", roll.n),
313            vec![args.source_range],
314        )));
315    }
316    if let Some(pitch) = &pitch
317        && !(-360.0..=360.0).contains(&pitch.n)
318    {
319        return Err(KclError::new_semantic(KclErrorDetails::new(
320            format!("Expected pitch to be between -360 and 360, found `{}`", pitch.n),
321            vec![args.source_range],
322        )));
323    }
324    if let Some(yaw) = &yaw
325        && !(-360.0..=360.0).contains(&yaw.n)
326    {
327        return Err(KclError::new_semantic(KclErrorDetails::new(
328            format!("Expected yaw to be between -360 and 360, found `{}`", yaw.n),
329            vec![args.source_range],
330        )));
331    }
332
333    // Validate the axis and angle values.
334    if let Some(angle) = &angle
335        && !(-360.0..=360.0).contains(&angle.n)
336    {
337        return Err(KclError::new_semantic(KclErrorDetails::new(
338            format!("Expected angle to be between -360 and 360, found `{}`", angle.n),
339            vec![args.source_range],
340        )));
341    }
342
343    let objects = inner_rotate(
344        objects,
345        roll.map(|t| t.n),
346        pitch.map(|t| t.n),
347        yaw.map(|t| t.n),
348        // Don't adjust axis units since the axis must be normalized and only the direction
349        // should be significant, not the magnitude.
350        axis.map(|a| [a[0].n, a[1].n, a[2].n]),
351        origin.map(|a| [a[0].n, a[1].n, a[2].n]),
352        angle.map(|t| t.n),
353        global,
354        exec_state,
355        args,
356    )
357    .await?;
358    Ok(objects.into())
359}
360
361#[allow(clippy::too_many_arguments)]
362async fn inner_rotate(
363    objects: SolidOrSketchOrImportedGeometry,
364    roll: Option<f64>,
365    pitch: Option<f64>,
366    yaw: Option<f64>,
367    axis: Option<[f64; 3]>,
368    origin: Option<[f64; 3]>,
369    angle: Option<f64>,
370    global: Option<bool>,
371    exec_state: &mut ExecState,
372    args: Args,
373) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
374    // If we have a solid, flush the fillets and chamfers.
375    // Only transforms needs this, it is very odd, see: https://github.com/KittyCAD/modeling-app/issues/5880
376    if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
377        exec_state
378            .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), solids)
379            .await?;
380    }
381
382    let origin = if let Some(origin) = origin {
383        Some(OriginType::Custom {
384            origin: shared::Point3d {
385                x: origin[0],
386                y: origin[1],
387                z: origin[2],
388            },
389        })
390    } else if global.unwrap_or(false) {
391        Some(OriginType::Global)
392    } else {
393        Some(OriginType::Local)
394    };
395
396    let mut objects = objects.clone();
397    for object_id in objects.ids(&args.ctx).await? {
398        if let (Some(axis), Some(angle)) = (&axis, angle) {
399            exec_state
400                .batch_modeling_cmd(
401                    ModelingCmdMeta::from_args(exec_state, &args),
402                    ModelingCmd::from(mcmd::SetObjectTransform {
403                        object_id,
404                        transforms: vec![shared::ComponentTransform {
405                            rotate_angle_axis: Some(transform_by(
406                                shared::Point4d {
407                                    x: axis[0],
408                                    y: axis[1],
409                                    z: axis[2],
410                                    w: angle,
411                                },
412                                false,
413                                !global.unwrap_or(false),
414                                origin,
415                            )),
416                            scale: None,
417                            rotate_rpy: None,
418                            translate: None,
419                        }],
420                    }),
421                )
422                .await?;
423        } else {
424            // Do roll, pitch, and yaw.
425            exec_state
426                .batch_modeling_cmd(
427                    ModelingCmdMeta::from_args(exec_state, &args),
428                    ModelingCmd::from(mcmd::SetObjectTransform {
429                        object_id,
430                        transforms: vec![shared::ComponentTransform {
431                            rotate_rpy: Some(transform_by(
432                                shared::Point3d {
433                                    x: roll.unwrap_or(0.0),
434                                    y: pitch.unwrap_or(0.0),
435                                    z: yaw.unwrap_or(0.0),
436                                },
437                                false,
438                                !global.unwrap_or(false),
439                                origin,
440                            )),
441                            scale: None,
442                            rotate_angle_axis: None,
443                            translate: None,
444                        }],
445                    }),
446                )
447                .await?;
448        }
449    }
450
451    Ok(objects)
452}
453
454#[cfg(test)]
455mod tests {
456    use pretty_assertions::assert_eq;
457
458    use crate::execution::parse_execute;
459
460    const PIPE: &str = r#"sweepPath = startSketchOn(XZ)
461    |> startProfile(at = [0.05, 0.05])
462    |> line(end = [0, 7])
463    |> tangentialArc(angle = 90, radius = 5)
464    |> line(end = [-3, 0])
465    |> tangentialArc(angle = -90, radius = 5)
466    |> line(end = [0, 7])
467
468// Create a hole for the pipe.
469pipeHole = startSketchOn(XY)
470    |> circle(
471        center = [0, 0],
472        radius = 1.5,
473    )
474sweepSketch = startSketchOn(XY)
475    |> circle(
476        center = [0, 0],
477        radius = 2,
478        )              
479    |> subtract2d(tool = pipeHole)
480    |> sweep(
481        path = sweepPath,
482    )"#;
483
484    #[tokio::test(flavor = "multi_thread")]
485    async fn test_rotate_empty() {
486        let ast = PIPE.to_string()
487            + r#"
488    |> rotate()
489"#;
490        let result = parse_execute(&ast).await;
491        assert!(result.is_err());
492        assert_eq!(
493            result.unwrap_err().message(),
494            r#"Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided."#.to_string()
495        );
496    }
497
498    #[tokio::test(flavor = "multi_thread")]
499    async fn test_rotate_axis_no_angle() {
500        let ast = PIPE.to_string()
501            + r#"
502    |> rotate(
503    axis =  [0, 0, 1.0],
504    )
505"#;
506        let result = parse_execute(&ast).await;
507        assert!(result.is_err());
508        assert_eq!(
509            result.unwrap_err().message(),
510            r#"Expected `angle` to be provided when `axis` is provided."#.to_string()
511        );
512    }
513
514    #[tokio::test(flavor = "multi_thread")]
515    async fn test_rotate_angle_no_axis() {
516        let ast = PIPE.to_string()
517            + r#"
518    |> rotate(
519    angle = 90,
520    )
521"#;
522        let result = parse_execute(&ast).await;
523        assert!(result.is_err());
524        assert_eq!(
525            result.unwrap_err().message(),
526            r#"Expected `axis` to be provided when `angle` is provided."#.to_string()
527        );
528    }
529
530    #[tokio::test(flavor = "multi_thread")]
531    async fn test_rotate_angle_out_of_range() {
532        let ast = PIPE.to_string()
533            + r#"
534    |> rotate(
535    axis =  [0, 0, 1.0],
536    angle = 900,
537    )
538"#;
539        let result = parse_execute(&ast).await;
540        assert!(result.is_err());
541        assert_eq!(
542            result.unwrap_err().message(),
543            r#"Expected angle to be between -360 and 360, found `900`"#.to_string()
544        );
545    }
546
547    #[tokio::test(flavor = "multi_thread")]
548    async fn test_rotate_angle_axis_yaw() {
549        let ast = PIPE.to_string()
550            + r#"
551    |> rotate(
552    axis =  [0, 0, 1.0],
553    angle = 90,
554    yaw = 90,
555   ) 
556"#;
557        let result = parse_execute(&ast).await;
558        assert!(result.is_err());
559        assert_eq!(
560            result.unwrap_err().message(),
561            r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
562                .to_string()
563        );
564    }
565
566    #[tokio::test(flavor = "multi_thread")]
567    async fn test_rotate_yaw_only() {
568        let ast = PIPE.to_string()
569            + r#"
570    |> rotate(
571    yaw = 90,
572    )
573"#;
574        parse_execute(&ast).await.unwrap();
575    }
576
577    #[tokio::test(flavor = "multi_thread")]
578    async fn test_rotate_pitch_only() {
579        let ast = PIPE.to_string()
580            + r#"
581    |> rotate(
582    pitch = 90,
583    )
584"#;
585        parse_execute(&ast).await.unwrap();
586    }
587
588    #[tokio::test(flavor = "multi_thread")]
589    async fn test_rotate_roll_only() {
590        let ast = PIPE.to_string()
591            + r#"
592    |> rotate(
593    pitch = 90,
594    )
595"#;
596        parse_execute(&ast).await.unwrap();
597    }
598
599    #[tokio::test(flavor = "multi_thread")]
600    async fn test_rotate_yaw_out_of_range() {
601        let ast = PIPE.to_string()
602            + r#"
603    |> rotate(
604    yaw = 900,
605    pitch = 90,
606    roll = 90,
607    )
608"#;
609        let result = parse_execute(&ast).await;
610        assert!(result.is_err());
611        assert_eq!(
612            result.unwrap_err().message(),
613            r#"Expected yaw to be between -360 and 360, found `900`"#.to_string()
614        );
615    }
616
617    #[tokio::test(flavor = "multi_thread")]
618    async fn test_rotate_roll_out_of_range() {
619        let ast = PIPE.to_string()
620            + r#"
621    |> rotate(
622    yaw = 90,
623    pitch = 90,
624    roll = 900,
625    )
626"#;
627        let result = parse_execute(&ast).await;
628        assert!(result.is_err());
629        assert_eq!(
630            result.unwrap_err().message(),
631            r#"Expected roll to be between -360 and 360, found `900`"#.to_string()
632        );
633    }
634
635    #[tokio::test(flavor = "multi_thread")]
636    async fn test_rotate_pitch_out_of_range() {
637        let ast = PIPE.to_string()
638            + r#"
639    |> rotate(
640    yaw = 90,
641    pitch = 900,
642    roll = 90,
643    )
644"#;
645        let result = parse_execute(&ast).await;
646        assert!(result.is_err());
647        assert_eq!(
648            result.unwrap_err().message(),
649            r#"Expected pitch to be between -360 and 360, found `900`"#.to_string()
650        );
651    }
652
653    #[tokio::test(flavor = "multi_thread")]
654    async fn test_rotate_roll_pitch_yaw_with_angle() {
655        let ast = PIPE.to_string()
656            + r#"
657    |> rotate(
658    yaw = 90,
659    pitch = 90,
660    roll = 90,
661    angle = 90,
662    )
663"#;
664        let result = parse_execute(&ast).await;
665        assert!(result.is_err());
666        assert_eq!(
667            result.unwrap_err().message(),
668            r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
669                .to_string()
670        );
671    }
672
673    #[tokio::test(flavor = "multi_thread")]
674    async fn test_translate_no_args() {
675        let ast = PIPE.to_string()
676            + r#"
677    |> translate(
678    )
679"#;
680        let result = parse_execute(&ast).await;
681        assert!(result.is_err());
682        assert_eq!(
683            result.unwrap_err().message(),
684            r#"Expected `x`, `y`, or `z` to be provided."#.to_string()
685        );
686    }
687
688    #[tokio::test(flavor = "multi_thread")]
689    async fn test_scale_no_args() {
690        let ast = PIPE.to_string()
691            + r#"
692    |> scale(
693    )
694"#;
695        let result = parse_execute(&ast).await;
696        assert!(result.is_err());
697        assert_eq!(
698            result.unwrap_err().message(),
699            r#"Expected `x`, `y`, `z` or `factor` to be provided."#.to_string()
700        );
701    }
702}