kcl_lib/std/
transform.rs

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