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