kcl_lib/std/
transform.rs

1//! Standard library transforms.
2
3use anyhow::Result;
4use kcl_derive_docs::stdlib;
5use kcmc::{
6    each_cmd as mcmd,
7    length_unit::LengthUnit,
8    shared,
9    shared::{Point3d, Point4d},
10    ModelingCmd,
11};
12use kittycad_modeling_cmds as kcmc;
13
14use crate::{
15    errors::{KclError, KclErrorDetails},
16    execution::{ExecState, KclValue, Solid},
17    std::Args,
18};
19
20/// Scale a solid.
21pub async fn scale(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
22    let solid = args.get_unlabeled_kw_arg("solid")?;
23    let scale = args.get_kw_arg("scale")?;
24    let global = args.get_kw_arg_opt("global")?;
25
26    let solid = inner_scale(solid, scale, global, exec_state, args).await?;
27    Ok(KclValue::Solid { value: solid })
28}
29
30/// Scale a solid.
31///
32/// By default the transform is applied in local sketch axis, therefore the origin will not move.
33///
34/// If you want to apply the transform in global space, set `global` to `true`. The origin of the
35/// model will move. If the model is not centered on origin and you scale globally it will
36/// look like the model moves and gets bigger at the same time. Say you have a square
37/// `(1,1) - (1,2) - (2,2) - (2,1)` and you scale by 2 globally it will become
38/// `(2,2) - (2,4)`...etc so the origin has moved from `(1.5, 1.5)` to `(2,2)`.
39///
40/// ```no_run
41/// // Scale a pipe.
42///
43/// // Create a path for the sweep.
44/// sweepPath = startSketchOn('XZ')
45///     |> startProfileAt([0.05, 0.05], %)
46///     |> line(end = [0, 7])
47///     |> tangentialArc({
48///         offset: 90,
49///         radius: 5
50///     }, %)
51///     |> line(end = [-3, 0])
52///     |> tangentialArc({
53///         offset: -90,
54///         radius: 5
55///     }, %)
56///     |> line(end = [0, 7])
57///
58/// // Create a hole for the pipe.
59/// pipeHole = startSketchOn('XY')
60///     |> circle(
61///         center = [0, 0],
62///         radius = 1.5,
63///     )
64///
65/// sweepSketch = startSketchOn('XY')
66///     |> circle(
67///         center = [0, 0],
68///         radius = 2,
69///         )              
70///     |> hole(pipeHole, %)
71///     |> sweep(path = sweepPath)   
72///     |> scale(
73///     scale = [1.0, 1.0, 2.5],
74///     )
75/// ```
76#[stdlib {
77    name = "scale",
78    feature_tree_operation = false,
79    keywords = true,
80    unlabeled_first = true,
81    args = {
82        solid = {docs = "The solid to scale."},
83        scale = {docs = "The scale factor for the x, y, and z axes."},
84        global = {docs = "If true, the transform is applied in global space. The origin of the model will move. By default, the transform is applied in local sketch axis, therefore the origin will not move."}
85    }
86}]
87async fn inner_scale(
88    solid: Box<Solid>,
89    scale: [f64; 3],
90    global: Option<bool>,
91    exec_state: &mut ExecState,
92    args: Args,
93) -> Result<Box<Solid>, KclError> {
94    let id = exec_state.next_uuid();
95
96    args.batch_modeling_cmd(
97        id,
98        ModelingCmd::from(mcmd::SetObjectTransform {
99            object_id: solid.id,
100            transforms: vec![shared::ComponentTransform {
101                scale: Some(shared::TransformBy::<Point3d<f64>> {
102                    property: Point3d {
103                        x: scale[0],
104                        y: scale[1],
105                        z: scale[2],
106                    },
107                    set: false,
108                    is_local: !global.unwrap_or(false),
109                }),
110                translate: None,
111                rotate_rpy: None,
112                rotate_angle_axis: None,
113            }],
114        }),
115    )
116    .await?;
117
118    Ok(solid)
119}
120
121/// Move a solid.
122pub async fn translate(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
123    let solid = args.get_unlabeled_kw_arg("solid")?;
124    let translate = args.get_kw_arg("translate")?;
125    let global = args.get_kw_arg_opt("global")?;
126
127    let solid = inner_translate(solid, translate, global, exec_state, args).await?;
128    Ok(KclValue::Solid { value: solid })
129}
130
131/// Move a solid.
132///
133/// ```no_run
134/// // Move a pipe.
135///
136/// // Create a path for the sweep.
137/// sweepPath = startSketchOn('XZ')
138///     |> startProfileAt([0.05, 0.05], %)
139///     |> line(end = [0, 7])
140///     |> tangentialArc({
141///         offset: 90,
142///         radius: 5
143///     }, %)
144///     |> line(end = [-3, 0])
145///     |> tangentialArc({
146///         offset: -90,
147///         radius: 5
148///     }, %)
149///     |> line(end = [0, 7])
150///
151/// // Create a hole for the pipe.
152/// pipeHole = startSketchOn('XY')
153///     |> circle(
154///         center = [0, 0],
155///         radius = 1.5,
156///     )
157///
158/// sweepSketch = startSketchOn('XY')
159///     |> circle(
160///         center = [0, 0],
161///         radius = 2,
162///         )              
163///     |> hole(pipeHole, %)
164///     |> sweep(path = sweepPath)   
165///     |> translate(
166///     translate = [1.0, 1.0, 2.5],
167///     )
168/// ```
169#[stdlib {
170    name = "translate",
171    feature_tree_operation = false,
172    keywords = true,
173    unlabeled_first = true,
174    args = {
175        solid = {docs = "The solid to move."},
176        translate = {docs = "The amount to move the solid in all three axes."},
177        global = {docs = "If true, the transform is applied in global space. The origin of the model will move. By default, the transform is applied in local sketch axis, therefore the origin will not move."}
178    }
179}]
180async fn inner_translate(
181    solid: Box<Solid>,
182    translate: [f64; 3],
183    global: Option<bool>,
184    exec_state: &mut ExecState,
185    args: Args,
186) -> Result<Box<Solid>, KclError> {
187    let id = exec_state.next_uuid();
188
189    args.batch_modeling_cmd(
190        id,
191        ModelingCmd::from(mcmd::SetObjectTransform {
192            object_id: solid.id,
193            transforms: vec![shared::ComponentTransform {
194                translate: Some(shared::TransformBy::<Point3d<LengthUnit>> {
195                    property: shared::Point3d {
196                        x: LengthUnit(translate[0]),
197                        y: LengthUnit(translate[1]),
198                        z: LengthUnit(translate[2]),
199                    },
200                    set: false,
201                    is_local: !global.unwrap_or(false),
202                }),
203                scale: None,
204                rotate_rpy: None,
205                rotate_angle_axis: None,
206            }],
207        }),
208    )
209    .await?;
210
211    Ok(solid)
212}
213
214/// Rotate a solid.
215pub async fn rotate(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
216    let solid = args.get_unlabeled_kw_arg("solid")?;
217    let roll = args.get_kw_arg_opt("roll")?;
218    let pitch = args.get_kw_arg_opt("pitch")?;
219    let yaw = args.get_kw_arg_opt("yaw")?;
220    let axis = args.get_kw_arg_opt("axis")?;
221    let angle = args.get_kw_arg_opt("angle")?;
222    let global = args.get_kw_arg_opt("global")?;
223
224    // Check if no rotation values are provided.
225    if roll.is_none() && pitch.is_none() && yaw.is_none() && axis.is_none() && angle.is_none() {
226        return Err(KclError::Semantic(KclErrorDetails {
227            message: "Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided.".to_string(),
228            source_ranges: vec![args.source_range],
229        }));
230    }
231
232    // If they give us a roll, pitch, or yaw, they must give us all three.
233    if roll.is_some() || pitch.is_some() || yaw.is_some() {
234        if roll.is_none() {
235            return Err(KclError::Semantic(KclErrorDetails {
236                message: "Expected `roll` to be provided when `pitch` or `yaw` is provided.".to_string(),
237                source_ranges: vec![args.source_range],
238            }));
239        }
240        if pitch.is_none() {
241            return Err(KclError::Semantic(KclErrorDetails {
242                message: "Expected `pitch` to be provided when `roll` or `yaw` is provided.".to_string(),
243                source_ranges: vec![args.source_range],
244            }));
245        }
246        if yaw.is_none() {
247            return Err(KclError::Semantic(KclErrorDetails {
248                message: "Expected `yaw` to be provided when `roll` or `pitch` is provided.".to_string(),
249                source_ranges: vec![args.source_range],
250            }));
251        }
252
253        // Ensure they didn't also provide an axis or angle.
254        if axis.is_some() || angle.is_some() {
255            return Err(KclError::Semantic(KclErrorDetails {
256                message: "Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."
257                    .to_string(),
258                source_ranges: vec![args.source_range],
259            }));
260        }
261    }
262
263    // If they give us an axis or angle, they must give us both.
264    if axis.is_some() || angle.is_some() {
265        if axis.is_none() {
266            return Err(KclError::Semantic(KclErrorDetails {
267                message: "Expected `axis` to be provided when `angle` is provided.".to_string(),
268                source_ranges: vec![args.source_range],
269            }));
270        }
271        if angle.is_none() {
272            return Err(KclError::Semantic(KclErrorDetails {
273                message: "Expected `angle` to be provided when `axis` is provided.".to_string(),
274                source_ranges: vec![args.source_range],
275            }));
276        }
277
278        // Ensure they didn't also provide a roll, pitch, or yaw.
279        if roll.is_some() || pitch.is_some() || yaw.is_some() {
280            return Err(KclError::Semantic(KclErrorDetails {
281                message: "Expected `roll`, `pitch`, and `yaw` to not be provided when `axis` and `angle` are provided."
282                    .to_string(),
283                source_ranges: vec![args.source_range],
284            }));
285        }
286    }
287
288    // Validate the roll, pitch, and yaw values.
289    if let Some(roll) = roll {
290        if !(-360.0..=360.0).contains(&roll) {
291            return Err(KclError::Semantic(KclErrorDetails {
292                message: format!("Expected roll to be between -360 and 360, found `{}`", roll),
293                source_ranges: vec![args.source_range],
294            }));
295        }
296    }
297    if let Some(pitch) = pitch {
298        if !(-360.0..=360.0).contains(&pitch) {
299            return Err(KclError::Semantic(KclErrorDetails {
300                message: format!("Expected pitch to be between -360 and 360, found `{}`", pitch),
301                source_ranges: vec![args.source_range],
302            }));
303        }
304    }
305    if let Some(yaw) = yaw {
306        if !(-360.0..=360.0).contains(&yaw) {
307            return Err(KclError::Semantic(KclErrorDetails {
308                message: format!("Expected yaw to be between -360 and 360, found `{}`", yaw),
309                source_ranges: vec![args.source_range],
310            }));
311        }
312    }
313
314    // Validate the axis and angle values.
315    if let Some(angle) = angle {
316        if !(-360.0..=360.0).contains(&angle) {
317            return Err(KclError::Semantic(KclErrorDetails {
318                message: format!("Expected angle to be between -360 and 360, found `{}`", angle),
319                source_ranges: vec![args.source_range],
320            }));
321        }
322    }
323
324    let solid = inner_rotate(solid, roll, pitch, yaw, axis, angle, global, exec_state, args).await?;
325    Ok(KclValue::Solid { value: solid })
326}
327
328/// Rotate a solid.
329///
330/// ### Using Roll, Pitch, and Yaw
331///
332/// When rotating a part in 3D space, "roll," "pitch," and "yaw" refer to the
333/// three rotational axes used to describe its orientation: roll is rotation
334/// around the longitudinal axis (front-to-back), pitch is rotation around the
335/// lateral axis (wing-to-wing), and yaw is rotation around the vertical axis
336/// (up-down); essentially, it's like tilting the part on its side (roll),
337/// tipping the nose up or down (pitch), and turning it left or right (yaw).
338///
339/// So, in the context of a 3D model:
340///
341/// - **Roll**: Imagine spinning a pencil on its tip - that's a roll movement.
342///
343/// - **Pitch**: Think of a seesaw motion, where the object tilts up or down along its side axis.
344///
345/// - **Yaw**: Like turning your head left or right, this is a rotation around the vertical axis
346///
347/// ### Using an Axis and Angle
348///
349/// When rotating a part around an axis, you specify the axis of rotation and the angle of
350/// rotation.
351///
352/// ```no_run
353/// // Rotate a pipe with roll, pitch, and yaw.
354///
355/// // Create a path for the sweep.
356/// sweepPath = startSketchOn('XZ')
357///     |> startProfileAt([0.05, 0.05], %)
358///     |> line(end = [0, 7])
359///     |> tangentialArc({
360///         offset: 90,
361///         radius: 5
362///     }, %)
363///     |> line(end = [-3, 0])
364///     |> tangentialArc({
365///         offset: -90,
366///         radius: 5
367///     }, %)
368///     |> line(end = [0, 7])
369///
370/// // Create a hole for the pipe.
371/// pipeHole = startSketchOn('XY')
372///     |> circle(
373///         center = [0, 0],
374///         radius = 1.5,
375///     )
376///
377/// sweepSketch = startSketchOn('XY')
378///     |> circle(
379///         center = [0, 0],
380///         radius = 2,
381///         )              
382///     |> hole(pipeHole, %)
383///     |> sweep(path = sweepPath)   
384///     |> rotate(
385///         roll = 10,
386///         pitch =  10,
387///         yaw = 90,
388///     )
389/// ```
390///
391/// ```no_run
392/// // Rotate a pipe about an axis with an angle.
393///
394/// // Create a path for the sweep.
395/// sweepPath = startSketchOn('XZ')
396///     |> startProfileAt([0.05, 0.05], %)
397///     |> line(end = [0, 7])
398///     |> tangentialArc({
399///         offset: 90,
400///         radius: 5
401///     }, %)
402///     |> line(end = [-3, 0])
403///     |> tangentialArc({
404///         offset: -90,
405///         radius: 5
406///     }, %)
407///     |> line(end = [0, 7])
408///
409/// // Create a hole for the pipe.
410/// pipeHole = startSketchOn('XY')
411///     |> circle(
412///         center = [0, 0],
413///         radius = 1.5,
414///    )
415///
416/// sweepSketch = startSketchOn('XY')
417///     |> circle(
418///         center = [0, 0],
419///         radius = 2,
420///         )              
421///     |> hole(pipeHole, %)
422///     |> sweep(path = sweepPath)   
423///     |> rotate(
424///     axis =  [0, 0, 1.0],
425///     angle = 90,
426///     )
427/// ```
428#[stdlib {
429    name = "rotate",
430    feature_tree_operation = false,
431    keywords = true,
432    unlabeled_first = true,
433    args = {
434        solid = {docs = "The solid to rotate."},
435        roll = {docs = "The roll angle in degrees. Must be used with `pitch` and `yaw`. Must be between -360 and 360.", include_in_snippet = true},
436        pitch = {docs = "The pitch angle in degrees. Must be used with `roll` and `yaw`. Must be between -360 and 360.", include_in_snippet = true},
437        yaw = {docs = "The yaw angle in degrees. Must be used with `roll` and `pitch`. Must be between -360 and 360.", include_in_snippet = true},
438        axis = {docs = "The axis to rotate around. Must be used with `angle`.", include_in_snippet = false},
439        angle = {docs = "The angle to rotate in degrees. Must be used with `axis`. Must be between -360 and 360.", include_in_snippet = false},
440        global = {docs = "If true, the transform is applied in global space. The origin of the model will move. By default, the transform is applied in local sketch axis, therefore the origin will not move."}
441    }
442}]
443#[allow(clippy::too_many_arguments)]
444async fn inner_rotate(
445    solid: Box<Solid>,
446    roll: Option<f64>,
447    pitch: Option<f64>,
448    yaw: Option<f64>,
449    axis: Option<[f64; 3]>,
450    angle: Option<f64>,
451    global: Option<bool>,
452    exec_state: &mut ExecState,
453    args: Args,
454) -> Result<Box<Solid>, KclError> {
455    let id = exec_state.next_uuid();
456
457    if let (Some(roll), Some(pitch), Some(yaw)) = (roll, pitch, yaw) {
458        args.batch_modeling_cmd(
459            id,
460            ModelingCmd::from(mcmd::SetObjectTransform {
461                object_id: solid.id,
462                transforms: vec![shared::ComponentTransform {
463                    rotate_rpy: Some(shared::TransformBy::<Point3d<f64>> {
464                        property: shared::Point3d {
465                            x: roll,
466                            y: pitch,
467                            z: yaw,
468                        },
469                        set: false,
470                        is_local: !global.unwrap_or(false),
471                    }),
472                    scale: None,
473                    rotate_angle_axis: None,
474                    translate: None,
475                }],
476            }),
477        )
478        .await?;
479    }
480
481    if let (Some(axis), Some(angle)) = (axis, angle) {
482        args.batch_modeling_cmd(
483            id,
484            ModelingCmd::from(mcmd::SetObjectTransform {
485                object_id: solid.id,
486                transforms: vec![shared::ComponentTransform {
487                    rotate_angle_axis: Some(shared::TransformBy::<Point4d<f64>> {
488                        property: shared::Point4d {
489                            x: axis[0],
490                            y: axis[1],
491                            z: axis[2],
492                            w: angle,
493                        },
494                        set: false,
495                        is_local: !global.unwrap_or(false),
496                    }),
497                    scale: None,
498                    rotate_rpy: None,
499                    translate: None,
500                }],
501            }),
502        )
503        .await?;
504    }
505
506    Ok(solid)
507}
508
509#[cfg(test)]
510mod tests {
511    use pretty_assertions::assert_eq;
512
513    use crate::execution::parse_execute;
514
515    const PIPE: &str = r#"sweepPath = startSketchOn('XZ')
516    |> startProfileAt([0.05, 0.05], %)
517    |> line(end = [0, 7])
518    |> tangentialArc({
519        offset: 90,
520        radius: 5
521    }, %)
522    |> line(end = [-3, 0])
523    |> tangentialArc({
524        offset: -90,
525        radius: 5
526    }, %)
527    |> line(end = [0, 7])
528
529// Create a hole for the pipe.
530pipeHole = startSketchOn('XY')
531    |> circle(
532        center = [0, 0],
533        radius = 1.5,
534    )
535sweepSketch = startSketchOn('XY')
536    |> circle(
537        center = [0, 0],
538        radius = 2,
539        )              
540    |> hole(pipeHole, %)
541    |> sweep(
542        path = sweepPath,
543    )"#;
544
545    #[tokio::test(flavor = "multi_thread")]
546    async fn test_rotate_empty() {
547        let ast = PIPE.to_string()
548            + r#"
549    |> rotate()
550"#;
551        let result = parse_execute(&ast).await;
552        assert!(result.is_err());
553        assert_eq!(
554            result.unwrap_err().message(),
555            r#"Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided."#.to_string()
556        );
557    }
558
559    #[tokio::test(flavor = "multi_thread")]
560    async fn test_rotate_axis_no_angle() {
561        let ast = PIPE.to_string()
562            + r#"
563    |> rotate(
564    axis =  [0, 0, 1.0],
565    )
566"#;
567        let result = parse_execute(&ast).await;
568        assert!(result.is_err());
569        assert_eq!(
570            result.unwrap_err().message(),
571            r#"Expected `angle` to be provided when `axis` is provided."#.to_string()
572        );
573    }
574
575    #[tokio::test(flavor = "multi_thread")]
576    async fn test_rotate_angle_no_axis() {
577        let ast = PIPE.to_string()
578            + r#"
579    |> rotate(
580    angle = 90,
581    )
582"#;
583        let result = parse_execute(&ast).await;
584        assert!(result.is_err());
585        assert_eq!(
586            result.unwrap_err().message(),
587            r#"Expected `axis` to be provided when `angle` is provided."#.to_string()
588        );
589    }
590
591    #[tokio::test(flavor = "multi_thread")]
592    async fn test_rotate_angle_out_of_range() {
593        let ast = PIPE.to_string()
594            + r#"
595    |> rotate(
596    axis =  [0, 0, 1.0],
597    angle = 900,
598    )
599"#;
600        let result = parse_execute(&ast).await;
601        assert!(result.is_err());
602        assert_eq!(
603            result.unwrap_err().message(),
604            r#"Expected angle to be between -360 and 360, found `900`"#.to_string()
605        );
606    }
607
608    #[tokio::test(flavor = "multi_thread")]
609    async fn test_rotate_angle_axis_yaw() {
610        let ast = PIPE.to_string()
611            + r#"
612    |> rotate(
613    axis =  [0, 0, 1.0],
614    angle = 90,
615    yaw = 90,
616   ) 
617"#;
618        let result = parse_execute(&ast).await;
619        assert!(result.is_err());
620        assert_eq!(
621            result.unwrap_err().message(),
622            r#"Expected `roll` to be provided when `pitch` or `yaw` is provided."#.to_string()
623        );
624    }
625
626    #[tokio::test(flavor = "multi_thread")]
627    async fn test_rotate_yaw_no_pitch() {
628        let ast = PIPE.to_string()
629            + r#"
630    |> rotate(
631    yaw = 90,
632    )
633"#;
634        let result = parse_execute(&ast).await;
635        assert!(result.is_err());
636        assert_eq!(
637            result.unwrap_err().message(),
638            r#"Expected `roll` to be provided when `pitch` or `yaw` is provided."#.to_string()
639        );
640    }
641
642    #[tokio::test(flavor = "multi_thread")]
643    async fn test_rotate_yaw_out_of_range() {
644        let ast = PIPE.to_string()
645            + r#"
646    |> rotate(
647    yaw = 900,
648    pitch = 90,
649    roll = 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 yaw to be between -360 and 360, found `900`"#.to_string()
657        );
658    }
659
660    #[tokio::test(flavor = "multi_thread")]
661    async fn test_rotate_roll_out_of_range() {
662        let ast = PIPE.to_string()
663            + r#"
664    |> rotate(
665    yaw = 90,
666    pitch = 90,
667    roll = 900,
668    )
669"#;
670        let result = parse_execute(&ast).await;
671        assert!(result.is_err());
672        assert_eq!(
673            result.unwrap_err().message(),
674            r#"Expected roll to be between -360 and 360, found `900`"#.to_string()
675        );
676    }
677
678    #[tokio::test(flavor = "multi_thread")]
679    async fn test_rotate_pitch_out_of_range() {
680        let ast = PIPE.to_string()
681            + r#"
682    |> rotate(
683    yaw = 90,
684    pitch = 900,
685    roll = 90,
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 pitch to be between -360 and 360, found `900`"#.to_string()
693        );
694    }
695
696    #[tokio::test(flavor = "multi_thread")]
697    async fn test_rotate_roll_pitch_yaw_with_angle() {
698        let ast = PIPE.to_string()
699            + r#"
700    |> rotate(
701    yaw = 90,
702    pitch = 90,
703    roll = 90,
704    angle = 90,
705    )
706"#;
707        let result = parse_execute(&ast).await;
708        assert!(result.is_err());
709        assert_eq!(
710            result.unwrap_err().message(),
711            r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
712                .to_string()
713        );
714    }
715}