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