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(
108                    mcmd::SetObjectTransform::builder()
109                        .object_id(object_id)
110                        .transforms(vec![shared::ComponentTransform {
111                            scale: Some(transform_by(
112                                Point3d {
113                                    x: x.unwrap_or(1.0),
114                                    y: y.unwrap_or(1.0),
115                                    z: z.unwrap_or(1.0),
116                                },
117                                false,
118                                !is_global,
119                                origin,
120                            )),
121                            translate: None,
122                            rotate_rpy: None,
123                            rotate_angle_axis: None,
124                        }])
125                        .build(),
126                ),
127            )
128            .await?;
129    }
130
131    Ok(objects)
132}
133
134/// Move a solid or a sketch.
135pub async fn translate(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
136    let objects = args.get_unlabeled_kw_arg(
137        "objects",
138        &RuntimeType::Union(vec![
139            RuntimeType::sketches(),
140            RuntimeType::solids(),
141            RuntimeType::imported(),
142        ]),
143        exec_state,
144    )?;
145    let translate_x: Option<TyF64> = args.get_kw_arg_opt("x", &RuntimeType::length(), exec_state)?;
146    let translate_y: Option<TyF64> = args.get_kw_arg_opt("y", &RuntimeType::length(), exec_state)?;
147    let translate_z: Option<TyF64> = args.get_kw_arg_opt("z", &RuntimeType::length(), exec_state)?;
148    let xyz: Option<[TyF64; 3]> = args.get_kw_arg_opt("xyz", &RuntimeType::point3d(), exec_state)?;
149    let global = args.get_kw_arg_opt("global", &RuntimeType::bool(), exec_state)?;
150
151    let objects = inner_translate(
152        objects,
153        xyz,
154        translate_x,
155        translate_y,
156        translate_z,
157        global,
158        exec_state,
159        args,
160    )
161    .await?;
162    Ok(objects.into())
163}
164
165#[allow(clippy::too_many_arguments)]
166async fn inner_translate(
167    objects: SolidOrSketchOrImportedGeometry,
168    xyz: Option<[TyF64; 3]>,
169    x: Option<TyF64>,
170    y: Option<TyF64>,
171    z: Option<TyF64>,
172    global: Option<bool>,
173    exec_state: &mut ExecState,
174    args: Args,
175) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
176    let (x, y, z) = match (xyz, x, y, z) {
177        (None, None, None, None) => {
178            return Err(KclError::new_semantic(KclErrorDetails::new(
179                "Expected `x`, `y`, or `z` to be provided.".to_string(),
180                vec![args.source_range],
181            )));
182        }
183        (Some(xyz), None, None, None) => {
184            let [x, y, z] = xyz;
185            (Some(x), Some(y), Some(z))
186        }
187        (None, x, y, z) => (x, y, z),
188        (Some(_), _, _, _) => {
189            return Err(KclError::new_semantic(KclErrorDetails::new(
190                "If you provide all 3 distances via the `xyz` arg, you cannot provide them separately via the `x`, `y` or `z` args."
191                    .to_string(),
192                vec![args.source_range],
193            )));
194        }
195    };
196    // If we have a solid, flush the fillets and chamfers.
197    // Only transforms needs this, it is very odd, see: https://github.com/KittyCAD/modeling-app/issues/5880
198    if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
199        exec_state
200            .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), solids)
201            .await?;
202    }
203
204    let is_global = global.unwrap_or(false);
205    let origin = if is_global {
206        Some(OriginType::Global)
207    } else {
208        Some(OriginType::Local)
209    };
210
211    let translation = shared::Point3d {
212        x: LengthUnit(x.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
213        y: LengthUnit(y.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
214        z: LengthUnit(z.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
215    };
216    let mut objects = objects.clone();
217    for object_id in objects.ids(&args.ctx).await? {
218        exec_state
219            .batch_modeling_cmd(
220                ModelingCmdMeta::from_args(exec_state, &args),
221                ModelingCmd::from(
222                    mcmd::SetObjectTransform::builder()
223                        .object_id(object_id)
224                        .transforms(vec![shared::ComponentTransform {
225                            translate: Some(transform_by(translation, false, !is_global, origin)),
226                            scale: None,
227                            rotate_rpy: None,
228                            rotate_angle_axis: None,
229                        }])
230                        .build(),
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(
407                        mcmd::SetObjectTransform::builder()
408                            .object_id(object_id)
409                            .transforms(vec![shared::ComponentTransform {
410                                rotate_angle_axis: Some(transform_by(
411                                    shared::Point4d {
412                                        x: axis[0],
413                                        y: axis[1],
414                                        z: axis[2],
415                                        w: angle,
416                                    },
417                                    false,
418                                    !global.unwrap_or(false),
419                                    origin,
420                                )),
421                                scale: None,
422                                rotate_rpy: None,
423                                translate: None,
424                            }])
425                            .build(),
426                    ),
427                )
428                .await?;
429        } else {
430            // Do roll, pitch, and yaw.
431            exec_state
432                .batch_modeling_cmd(
433                    ModelingCmdMeta::from_args(exec_state, &args),
434                    ModelingCmd::from(
435                        mcmd::SetObjectTransform::builder()
436                            .object_id(object_id)
437                            .transforms(vec![shared::ComponentTransform {
438                                rotate_rpy: Some(transform_by(
439                                    shared::Point3d {
440                                        x: roll.unwrap_or(0.0),
441                                        y: pitch.unwrap_or(0.0),
442                                        z: yaw.unwrap_or(0.0),
443                                    },
444                                    false,
445                                    !global.unwrap_or(false),
446                                    origin,
447                                )),
448                                scale: None,
449                                rotate_angle_axis: None,
450                                translate: None,
451                            }])
452                            .build(),
453                    ),
454                )
455                .await?;
456        }
457    }
458
459    Ok(objects)
460}
461
462#[cfg(test)]
463mod tests {
464    use pretty_assertions::assert_eq;
465
466    use crate::execution::parse_execute;
467
468    const PIPE: &str = r#"sweepPath = startSketchOn(XZ)
469    |> startProfile(at = [0.05, 0.05])
470    |> line(end = [0, 7])
471    |> tangentialArc(angle = 90, radius = 5)
472    |> line(end = [-3, 0])
473    |> tangentialArc(angle = -90, radius = 5)
474    |> line(end = [0, 7])
475
476// Create a hole for the pipe.
477pipeHole = startSketchOn(XY)
478    |> circle(
479        center = [0, 0],
480        radius = 1.5,
481    )
482sweepSketch = startSketchOn(XY)
483    |> circle(
484        center = [0, 0],
485        radius = 2,
486        )              
487    |> subtract2d(tool = pipeHole)
488    |> sweep(
489        path = sweepPath,
490    )"#;
491
492    #[tokio::test(flavor = "multi_thread")]
493    async fn test_rotate_empty() {
494        let ast = PIPE.to_string()
495            + r#"
496    |> rotate()
497"#;
498        let result = parse_execute(&ast).await;
499        assert!(result.is_err());
500        assert_eq!(
501            result.unwrap_err().message(),
502            r#"Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided."#.to_string()
503        );
504    }
505
506    #[tokio::test(flavor = "multi_thread")]
507    async fn test_rotate_axis_no_angle() {
508        let ast = PIPE.to_string()
509            + r#"
510    |> rotate(
511    axis =  [0, 0, 1.0],
512    )
513"#;
514        let result = parse_execute(&ast).await;
515        assert!(result.is_err());
516        assert_eq!(
517            result.unwrap_err().message(),
518            r#"Expected `angle` to be provided when `axis` is provided."#.to_string()
519        );
520    }
521
522    #[tokio::test(flavor = "multi_thread")]
523    async fn test_rotate_angle_no_axis() {
524        let ast = PIPE.to_string()
525            + r#"
526    |> rotate(
527    angle = 90,
528    )
529"#;
530        let result = parse_execute(&ast).await;
531        assert!(result.is_err());
532        assert_eq!(
533            result.unwrap_err().message(),
534            r#"Expected `axis` to be provided when `angle` is provided."#.to_string()
535        );
536    }
537
538    #[tokio::test(flavor = "multi_thread")]
539    async fn test_rotate_angle_out_of_range() {
540        let ast = PIPE.to_string()
541            + r#"
542    |> rotate(
543    axis =  [0, 0, 1.0],
544    angle = 900,
545    )
546"#;
547        let result = parse_execute(&ast).await;
548        assert!(result.is_err());
549        assert_eq!(
550            result.unwrap_err().message(),
551            r#"Expected angle to be between -360 and 360, found `900`"#.to_string()
552        );
553    }
554
555    #[tokio::test(flavor = "multi_thread")]
556    async fn test_rotate_angle_axis_yaw() {
557        let ast = PIPE.to_string()
558            + r#"
559    |> rotate(
560    axis =  [0, 0, 1.0],
561    angle = 90,
562    yaw = 90,
563   ) 
564"#;
565        let result = parse_execute(&ast).await;
566        assert!(result.is_err());
567        assert_eq!(
568            result.unwrap_err().message(),
569            r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
570                .to_string()
571        );
572    }
573
574    #[tokio::test(flavor = "multi_thread")]
575    async fn test_rotate_yaw_only() {
576        let ast = PIPE.to_string()
577            + r#"
578    |> rotate(
579    yaw = 90,
580    )
581"#;
582        parse_execute(&ast).await.unwrap();
583    }
584
585    #[tokio::test(flavor = "multi_thread")]
586    async fn test_rotate_pitch_only() {
587        let ast = PIPE.to_string()
588            + r#"
589    |> rotate(
590    pitch = 90,
591    )
592"#;
593        parse_execute(&ast).await.unwrap();
594    }
595
596    #[tokio::test(flavor = "multi_thread")]
597    async fn test_rotate_roll_only() {
598        let ast = PIPE.to_string()
599            + r#"
600    |> rotate(
601    pitch = 90,
602    )
603"#;
604        parse_execute(&ast).await.unwrap();
605    }
606
607    #[tokio::test(flavor = "multi_thread")]
608    async fn test_rotate_yaw_out_of_range() {
609        let ast = PIPE.to_string()
610            + r#"
611    |> rotate(
612    yaw = 900,
613    pitch = 90,
614    roll = 90,
615    )
616"#;
617        let result = parse_execute(&ast).await;
618        assert!(result.is_err());
619        assert_eq!(
620            result.unwrap_err().message(),
621            r#"Expected yaw to be between -360 and 360, found `900`"#.to_string()
622        );
623    }
624
625    #[tokio::test(flavor = "multi_thread")]
626    async fn test_rotate_roll_out_of_range() {
627        let ast = PIPE.to_string()
628            + r#"
629    |> rotate(
630    yaw = 90,
631    pitch = 90,
632    roll = 900,
633    )
634"#;
635        let result = parse_execute(&ast).await;
636        assert!(result.is_err());
637        assert_eq!(
638            result.unwrap_err().message(),
639            r#"Expected roll to be between -360 and 360, found `900`"#.to_string()
640        );
641    }
642
643    #[tokio::test(flavor = "multi_thread")]
644    async fn test_rotate_pitch_out_of_range() {
645        let ast = PIPE.to_string()
646            + r#"
647    |> rotate(
648    yaw = 90,
649    pitch = 900,
650    roll = 90,
651    )
652"#;
653        let result = parse_execute(&ast).await;
654        assert!(result.is_err());
655        assert_eq!(
656            result.unwrap_err().message(),
657            r#"Expected pitch to be between -360 and 360, found `900`"#.to_string()
658        );
659    }
660
661    #[tokio::test(flavor = "multi_thread")]
662    async fn test_rotate_roll_pitch_yaw_with_angle() {
663        let ast = PIPE.to_string()
664            + r#"
665    |> rotate(
666    yaw = 90,
667    pitch = 90,
668    roll = 90,
669    angle = 90,
670    )
671"#;
672        let result = parse_execute(&ast).await;
673        assert!(result.is_err());
674        assert_eq!(
675            result.unwrap_err().message(),
676            r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
677                .to_string()
678        );
679    }
680
681    #[tokio::test(flavor = "multi_thread")]
682    async fn test_translate_no_args() {
683        let ast = PIPE.to_string()
684            + r#"
685    |> translate(
686    )
687"#;
688        let result = parse_execute(&ast).await;
689        assert!(result.is_err());
690        assert_eq!(
691            result.unwrap_err().message(),
692            r#"Expected `x`, `y`, or `z` to be provided."#.to_string()
693        );
694    }
695
696    #[tokio::test(flavor = "multi_thread")]
697    async fn test_scale_no_args() {
698        let ast = PIPE.to_string()
699            + r#"
700    |> scale(
701    )
702"#;
703        let result = parse_execute(&ast).await;
704        assert!(result.is_err());
705        assert_eq!(
706            result.unwrap_err().message(),
707            r#"Expected `x`, `y`, `z` or `factor` to be provided."#.to_string()
708        );
709    }
710}