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