Skip to main content

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, HideableGeometry, 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    for scale_dim in [&scale_x, &scale_y, &scale_z, &factor] {
47        if let Some(num) = scale_dim
48            && num.n == 0.0
49        {
50            return Err(KclError::new_semantic(KclErrorDetails::new(
51                "Cannot scale by 0".to_string(),
52                vec![args.source_range],
53            )));
54        }
55    }
56    let (scale_x, scale_y, scale_z) = match (scale_x, scale_y, scale_z, factor) {
57        (None, None, None, Some(factor)) => (Some(factor.clone()), Some(factor.clone()), Some(factor)),
58        // Ensure at least one scale value is provided.
59        (None, None, None, None) => {
60            return Err(KclError::new_semantic(KclErrorDetails::new(
61                "Expected `x`, `y`, `z` or `factor` to be provided.".to_string(),
62                vec![args.source_range],
63            )));
64        }
65        (x, y, z, None) => (x, y, z),
66        _ => {
67            return Err(KclError::new_semantic(KclErrorDetails::new(
68                "If you give `factor` then you cannot use  `x`, `y`, or `z`".to_string(),
69                vec![args.source_range],
70            )));
71        }
72    };
73    let global = args.get_kw_arg_opt("global", &RuntimeType::bool(), exec_state)?;
74
75    let objects = inner_scale(
76        objects,
77        scale_x.map(|t| t.n),
78        scale_y.map(|t| t.n),
79        scale_z.map(|t| t.n),
80        global,
81        exec_state,
82        args,
83    )
84    .await?;
85    Ok(objects.into())
86}
87
88async fn inner_scale(
89    objects: SolidOrSketchOrImportedGeometry,
90    x: Option<f64>,
91    y: Option<f64>,
92    z: Option<f64>,
93    global: Option<bool>,
94    exec_state: &mut ExecState,
95    args: Args,
96) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
97    // If we have a solid, flush the fillets and chamfers.
98    // Only transforms needs this, it is very odd, see: https://github.com/KittyCAD/modeling-app/issues/5880
99    if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
100        exec_state
101            .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), solids)
102            .await?;
103    }
104
105    let is_global = global.unwrap_or(false);
106    let origin = if is_global {
107        Some(OriginType::Global)
108    } else {
109        Some(OriginType::Local)
110    };
111
112    let mut objects = objects.clone();
113    for object_id in objects.ids(&args.ctx).await? {
114        exec_state
115            .batch_modeling_cmd(
116                ModelingCmdMeta::from_args(exec_state, &args),
117                ModelingCmd::from(
118                    mcmd::SetObjectTransform::builder()
119                        .object_id(object_id)
120                        .transforms(vec![shared::ComponentTransform {
121                            scale: Some(transform_by(
122                                Point3d {
123                                    x: x.unwrap_or(1.0),
124                                    y: y.unwrap_or(1.0),
125                                    z: z.unwrap_or(1.0),
126                                },
127                                false,
128                                !is_global,
129                                origin,
130                            )),
131                            translate: None,
132                            rotate_rpy: None,
133                            rotate_angle_axis: None,
134                        }])
135                        .build(),
136                ),
137            )
138            .await?;
139    }
140
141    Ok(objects)
142}
143
144/// Move a solid or a sketch.
145pub async fn translate(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
146    let objects = args.get_unlabeled_kw_arg(
147        "objects",
148        &RuntimeType::Union(vec![
149            RuntimeType::sketches(),
150            RuntimeType::solids(),
151            RuntimeType::imported(),
152        ]),
153        exec_state,
154    )?;
155    let translate_x: Option<TyF64> = args.get_kw_arg_opt("x", &RuntimeType::length(), exec_state)?;
156    let translate_y: Option<TyF64> = args.get_kw_arg_opt("y", &RuntimeType::length(), exec_state)?;
157    let translate_z: Option<TyF64> = args.get_kw_arg_opt("z", &RuntimeType::length(), exec_state)?;
158    let xyz: Option<[TyF64; 3]> = args.get_kw_arg_opt("xyz", &RuntimeType::point3d(), exec_state)?;
159    let global = args.get_kw_arg_opt("global", &RuntimeType::bool(), exec_state)?;
160
161    let objects = inner_translate(
162        objects,
163        xyz,
164        translate_x,
165        translate_y,
166        translate_z,
167        global,
168        exec_state,
169        args,
170    )
171    .await?;
172    Ok(objects.into())
173}
174
175#[allow(clippy::too_many_arguments)]
176async fn inner_translate(
177    objects: SolidOrSketchOrImportedGeometry,
178    xyz: Option<[TyF64; 3]>,
179    x: Option<TyF64>,
180    y: Option<TyF64>,
181    z: Option<TyF64>,
182    global: Option<bool>,
183    exec_state: &mut ExecState,
184    args: Args,
185) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
186    let (x, y, z) = match (xyz, x, y, z) {
187        (None, None, None, None) => {
188            return Err(KclError::new_semantic(KclErrorDetails::new(
189                "Expected `x`, `y`, or `z` to be provided.".to_string(),
190                vec![args.source_range],
191            )));
192        }
193        (Some(xyz), None, None, None) => {
194            let [x, y, z] = xyz;
195            (Some(x), Some(y), Some(z))
196        }
197        (None, x, y, z) => (x, y, z),
198        (Some(_), _, _, _) => {
199            return Err(KclError::new_semantic(KclErrorDetails::new(
200                "If you provide all 3 distances via the `xyz` arg, you cannot provide them separately via the `x`, `y` or `z` args."
201                    .to_string(),
202                vec![args.source_range],
203            )));
204        }
205    };
206    // If we have a solid, flush the fillets and chamfers.
207    // Only transforms needs this, it is very odd, see: https://github.com/KittyCAD/modeling-app/issues/5880
208    if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
209        exec_state
210            .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), solids)
211            .await?;
212    }
213
214    let is_global = global.unwrap_or(false);
215    let origin = if is_global {
216        Some(OriginType::Global)
217    } else {
218        Some(OriginType::Local)
219    };
220
221    let translation = shared::Point3d {
222        x: LengthUnit(x.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
223        y: LengthUnit(y.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
224        z: LengthUnit(z.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
225    };
226    let mut objects = objects.clone();
227    for object_id in objects.ids(&args.ctx).await? {
228        exec_state
229            .batch_modeling_cmd(
230                ModelingCmdMeta::from_args(exec_state, &args),
231                ModelingCmd::from(
232                    mcmd::SetObjectTransform::builder()
233                        .object_id(object_id)
234                        .transforms(vec![shared::ComponentTransform {
235                            translate: Some(transform_by(translation, false, !is_global, origin)),
236                            scale: None,
237                            rotate_rpy: None,
238                            rotate_angle_axis: None,
239                        }])
240                        .build(),
241                ),
242            )
243            .await?;
244    }
245
246    Ok(objects)
247}
248
249/// Rotate a solid or a sketch.
250pub async fn rotate(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
251    let objects = args.get_unlabeled_kw_arg(
252        "objects",
253        &RuntimeType::Union(vec![
254            RuntimeType::sketches(),
255            RuntimeType::solids(),
256            RuntimeType::imported(),
257        ]),
258        exec_state,
259    )?;
260    let roll: Option<TyF64> = args.get_kw_arg_opt("roll", &RuntimeType::degrees(), exec_state)?;
261    let pitch: Option<TyF64> = args.get_kw_arg_opt("pitch", &RuntimeType::degrees(), exec_state)?;
262    let yaw: Option<TyF64> = args.get_kw_arg_opt("yaw", &RuntimeType::degrees(), exec_state)?;
263    let axis: Option<Axis3dOrPoint3d> = args.get_kw_arg_opt(
264        "axis",
265        &RuntimeType::Union(vec![
266            RuntimeType::Primitive(PrimitiveType::Axis3d),
267            RuntimeType::point3d(),
268        ]),
269        exec_state,
270    )?;
271    let origin = axis.clone().map(|a| a.axis_origin()).unwrap_or_default();
272    let axis = axis.map(|a| a.to_point3d());
273    let angle: Option<TyF64> = args.get_kw_arg_opt("angle", &RuntimeType::degrees(), exec_state)?;
274    let global = args.get_kw_arg_opt("global", &RuntimeType::bool(), exec_state)?;
275
276    // Check if no rotation values are provided.
277    if roll.is_none() && pitch.is_none() && yaw.is_none() && axis.is_none() && angle.is_none() {
278        return Err(KclError::new_semantic(KclErrorDetails::new(
279            "Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided.".to_string(),
280            vec![args.source_range],
281        )));
282    }
283
284    // If they give us a roll, pitch, or yaw, they must give us at least one of them.
285    if roll.is_some() || pitch.is_some() || yaw.is_some() {
286        // Ensure they didn't also provide an axis or angle.
287        if axis.is_some() || angle.is_some() {
288            return Err(KclError::new_semantic(KclErrorDetails::new(
289                "Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."
290                    .to_owned(),
291                vec![args.source_range],
292            )));
293        }
294    }
295
296    // If they give us an axis or angle, they must give us both.
297    if axis.is_some() || angle.is_some() {
298        if axis.is_none() {
299            return Err(KclError::new_semantic(KclErrorDetails::new(
300                "Expected `axis` to be provided when `angle` is provided.".to_string(),
301                vec![args.source_range],
302            )));
303        }
304        if angle.is_none() {
305            return Err(KclError::new_semantic(KclErrorDetails::new(
306                "Expected `angle` to be provided when `axis` is provided.".to_string(),
307                vec![args.source_range],
308            )));
309        }
310
311        // Ensure they didn't also provide a roll, pitch, or yaw.
312        if roll.is_some() || pitch.is_some() || yaw.is_some() {
313            return Err(KclError::new_semantic(KclErrorDetails::new(
314                "Expected `roll`, `pitch`, and `yaw` to not be provided when `axis` and `angle` are provided."
315                    .to_owned(),
316                vec![args.source_range],
317            )));
318        }
319    }
320
321    // Validate the roll, pitch, and yaw values.
322    if let Some(roll) = &roll
323        && !(-360.0..=360.0).contains(&roll.n)
324    {
325        return Err(KclError::new_semantic(KclErrorDetails::new(
326            format!("Expected roll to be between -360 and 360, found `{}`", roll.n),
327            vec![args.source_range],
328        )));
329    }
330    if let Some(pitch) = &pitch
331        && !(-360.0..=360.0).contains(&pitch.n)
332    {
333        return Err(KclError::new_semantic(KclErrorDetails::new(
334            format!("Expected pitch to be between -360 and 360, found `{}`", pitch.n),
335            vec![args.source_range],
336        )));
337    }
338    if let Some(yaw) = &yaw
339        && !(-360.0..=360.0).contains(&yaw.n)
340    {
341        return Err(KclError::new_semantic(KclErrorDetails::new(
342            format!("Expected yaw to be between -360 and 360, found `{}`", yaw.n),
343            vec![args.source_range],
344        )));
345    }
346
347    // Validate the axis and angle values.
348    if let Some(angle) = &angle
349        && !(-360.0..=360.0).contains(&angle.n)
350    {
351        return Err(KclError::new_semantic(KclErrorDetails::new(
352            format!("Expected angle to be between -360 and 360, found `{}`", angle.n),
353            vec![args.source_range],
354        )));
355    }
356
357    let objects = inner_rotate(
358        objects,
359        roll.map(|t| t.n),
360        pitch.map(|t| t.n),
361        yaw.map(|t| t.n),
362        // Don't adjust axis units since the axis must be normalized and only the direction
363        // should be significant, not the magnitude.
364        axis.map(|a| [a[0].n, a[1].n, a[2].n]),
365        origin.map(|a| [a[0].n, a[1].n, a[2].n]),
366        angle.map(|t| t.n),
367        global,
368        exec_state,
369        args,
370    )
371    .await?;
372    Ok(objects.into())
373}
374
375#[allow(clippy::too_many_arguments)]
376async fn inner_rotate(
377    objects: SolidOrSketchOrImportedGeometry,
378    roll: Option<f64>,
379    pitch: Option<f64>,
380    yaw: Option<f64>,
381    axis: Option<[f64; 3]>,
382    origin: Option<[f64; 3]>,
383    angle: Option<f64>,
384    global: Option<bool>,
385    exec_state: &mut ExecState,
386    args: Args,
387) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
388    // If we have a solid, flush the fillets and chamfers.
389    // Only transforms needs this, it is very odd, see: https://github.com/KittyCAD/modeling-app/issues/5880
390    if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
391        exec_state
392            .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), solids)
393            .await?;
394    }
395
396    let origin = if let Some(origin) = origin {
397        Some(OriginType::Custom {
398            origin: shared::Point3d {
399                x: origin[0],
400                y: origin[1],
401                z: origin[2],
402            },
403        })
404    } else if global.unwrap_or(false) {
405        Some(OriginType::Global)
406    } else {
407        Some(OriginType::Local)
408    };
409
410    let mut objects = objects.clone();
411    for object_id in objects.ids(&args.ctx).await? {
412        if let (Some(axis), Some(angle)) = (&axis, angle) {
413            exec_state
414                .batch_modeling_cmd(
415                    ModelingCmdMeta::from_args(exec_state, &args),
416                    ModelingCmd::from(
417                        mcmd::SetObjectTransform::builder()
418                            .object_id(object_id)
419                            .transforms(vec![shared::ComponentTransform {
420                                rotate_angle_axis: Some(transform_by(
421                                    shared::Point4d {
422                                        x: axis[0],
423                                        y: axis[1],
424                                        z: axis[2],
425                                        w: angle,
426                                    },
427                                    false,
428                                    !global.unwrap_or(false),
429                                    origin,
430                                )),
431                                scale: None,
432                                rotate_rpy: None,
433                                translate: None,
434                            }])
435                            .build(),
436                    ),
437                )
438                .await?;
439        } else {
440            // Do roll, pitch, and yaw.
441            exec_state
442                .batch_modeling_cmd(
443                    ModelingCmdMeta::from_args(exec_state, &args),
444                    ModelingCmd::from(
445                        mcmd::SetObjectTransform::builder()
446                            .object_id(object_id)
447                            .transforms(vec![shared::ComponentTransform {
448                                rotate_rpy: Some(transform_by(
449                                    shared::Point3d {
450                                        x: roll.unwrap_or(0.0),
451                                        y: pitch.unwrap_or(0.0),
452                                        z: yaw.unwrap_or(0.0),
453                                    },
454                                    false,
455                                    !global.unwrap_or(false),
456                                    origin,
457                                )),
458                                scale: None,
459                                rotate_angle_axis: None,
460                                translate: None,
461                            }])
462                            .build(),
463                    ),
464                )
465                .await?;
466        }
467    }
468
469    Ok(objects)
470}
471
472/// Hide solids, sketches, or imported objects.
473pub async fn hide(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
474    let objects = args.get_unlabeled_kw_arg(
475        "objects",
476        &RuntimeType::Union(vec![
477            RuntimeType::solids(),
478            RuntimeType::helices(),
479            RuntimeType::imported(),
480        ]),
481        exec_state,
482    )?;
483
484    let objects = hide_inner(objects, true, exec_state, args).await?;
485    Ok(objects.into())
486}
487
488async fn hide_inner(
489    mut objects: HideableGeometry,
490    hidden: bool,
491    exec_state: &mut ExecState,
492    args: Args,
493) -> Result<HideableGeometry, KclError> {
494    for object_id in objects.ids(&args.ctx).await? {
495        exec_state
496            .batch_modeling_cmd(
497                ModelingCmdMeta::from_args(exec_state, &args),
498                ModelingCmd::from(
499                    mcmd::ObjectVisible::builder()
500                        .object_id(object_id)
501                        .hidden(hidden)
502                        .build(),
503                ),
504            )
505            .await?;
506    }
507
508    Ok(objects)
509}
510
511#[cfg(test)]
512mod tests {
513    use pretty_assertions::assert_eq;
514
515    use crate::execution::parse_execute;
516
517    const PIPE: &str = r#"sweepPath = startSketchOn(XZ)
518    |> startProfile(at = [0.05, 0.05])
519    |> line(end = [0, 7])
520    |> tangentialArc(angle = 90, radius = 5)
521    |> line(end = [-3, 0])
522    |> tangentialArc(angle = -90, radius = 5)
523    |> line(end = [0, 7])
524
525// Create a hole for the pipe.
526pipeHole = startSketchOn(XY)
527    |> circle(
528        center = [0, 0],
529        radius = 1.5,
530    )
531sweepSketch = startSketchOn(XY)
532    |> circle(
533        center = [0, 0],
534        radius = 2,
535        )              
536    |> subtract2d(tool = pipeHole)
537    |> sweep(
538        path = sweepPath,
539    )"#;
540
541    #[tokio::test(flavor = "multi_thread")]
542    async fn test_rotate_empty() {
543        let ast = PIPE.to_string()
544            + r#"
545    |> rotate()
546"#;
547        let result = parse_execute(&ast).await;
548        assert!(result.is_err());
549        assert_eq!(
550            result.unwrap_err().message(),
551            r#"Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided."#.to_string()
552        );
553    }
554
555    #[tokio::test(flavor = "multi_thread")]
556    async fn test_rotate_axis_no_angle() {
557        let ast = PIPE.to_string()
558            + r#"
559    |> rotate(
560    axis =  [0, 0, 1.0],
561    )
562"#;
563        let result = parse_execute(&ast).await;
564        assert!(result.is_err());
565        assert_eq!(
566            result.unwrap_err().message(),
567            r#"Expected `angle` to be provided when `axis` is provided."#.to_string()
568        );
569    }
570
571    #[tokio::test(flavor = "multi_thread")]
572    async fn test_rotate_angle_no_axis() {
573        let ast = PIPE.to_string()
574            + r#"
575    |> rotate(
576    angle = 90,
577    )
578"#;
579        let result = parse_execute(&ast).await;
580        assert!(result.is_err());
581        assert_eq!(
582            result.unwrap_err().message(),
583            r#"Expected `axis` to be provided when `angle` is provided."#.to_string()
584        );
585    }
586
587    #[tokio::test(flavor = "multi_thread")]
588    async fn test_rotate_angle_out_of_range() {
589        let ast = PIPE.to_string()
590            + r#"
591    |> rotate(
592    axis =  [0, 0, 1.0],
593    angle = 900,
594    )
595"#;
596        let result = parse_execute(&ast).await;
597        assert!(result.is_err());
598        assert_eq!(
599            result.unwrap_err().message(),
600            r#"Expected angle to be between -360 and 360, found `900`"#.to_string()
601        );
602    }
603
604    #[tokio::test(flavor = "multi_thread")]
605    async fn test_rotate_angle_axis_yaw() {
606        let ast = PIPE.to_string()
607            + r#"
608    |> rotate(
609    axis =  [0, 0, 1.0],
610    angle = 90,
611    yaw = 90,
612   ) 
613"#;
614        let result = parse_execute(&ast).await;
615        assert!(result.is_err());
616        assert_eq!(
617            result.unwrap_err().message(),
618            r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
619                .to_string()
620        );
621    }
622
623    #[tokio::test(flavor = "multi_thread")]
624    async fn test_rotate_yaw_only() {
625        let ast = PIPE.to_string()
626            + r#"
627    |> rotate(
628    yaw = 90,
629    )
630"#;
631        parse_execute(&ast).await.unwrap();
632    }
633
634    #[tokio::test(flavor = "multi_thread")]
635    async fn test_rotate_pitch_only() {
636        let ast = PIPE.to_string()
637            + r#"
638    |> rotate(
639    pitch = 90,
640    )
641"#;
642        parse_execute(&ast).await.unwrap();
643    }
644
645    #[tokio::test(flavor = "multi_thread")]
646    async fn test_rotate_roll_only() {
647        let ast = PIPE.to_string()
648            + r#"
649    |> rotate(
650    pitch = 90,
651    )
652"#;
653        parse_execute(&ast).await.unwrap();
654    }
655
656    #[tokio::test(flavor = "multi_thread")]
657    async fn test_rotate_yaw_out_of_range() {
658        let ast = PIPE.to_string()
659            + r#"
660    |> rotate(
661    yaw = 900,
662    pitch = 90,
663    roll = 90,
664    )
665"#;
666        let result = parse_execute(&ast).await;
667        assert!(result.is_err());
668        assert_eq!(
669            result.unwrap_err().message(),
670            r#"Expected yaw to be between -360 and 360, found `900`"#.to_string()
671        );
672    }
673
674    #[tokio::test(flavor = "multi_thread")]
675    async fn test_rotate_roll_out_of_range() {
676        let ast = PIPE.to_string()
677            + r#"
678    |> rotate(
679    yaw = 90,
680    pitch = 90,
681    roll = 900,
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 roll to be between -360 and 360, found `900`"#.to_string()
689        );
690    }
691
692    #[tokio::test(flavor = "multi_thread")]
693    async fn test_rotate_pitch_out_of_range() {
694        let ast = PIPE.to_string()
695            + r#"
696    |> rotate(
697    yaw = 90,
698    pitch = 900,
699    roll = 90,
700    )
701"#;
702        let result = parse_execute(&ast).await;
703        assert!(result.is_err());
704        assert_eq!(
705            result.unwrap_err().message(),
706            r#"Expected pitch to be between -360 and 360, found `900`"#.to_string()
707        );
708    }
709
710    #[tokio::test(flavor = "multi_thread")]
711    async fn test_rotate_roll_pitch_yaw_with_angle() {
712        let ast = PIPE.to_string()
713            + r#"
714    |> rotate(
715    yaw = 90,
716    pitch = 90,
717    roll = 90,
718    angle = 90,
719    )
720"#;
721        let result = parse_execute(&ast).await;
722        assert!(result.is_err());
723        assert_eq!(
724            result.unwrap_err().message(),
725            r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
726                .to_string()
727        );
728    }
729
730    #[tokio::test(flavor = "multi_thread")]
731    async fn test_translate_no_args() {
732        let ast = PIPE.to_string()
733            + r#"
734    |> translate(
735    )
736"#;
737        let result = parse_execute(&ast).await;
738        assert!(result.is_err());
739        assert_eq!(
740            result.unwrap_err().message(),
741            r#"Expected `x`, `y`, or `z` to be provided."#.to_string()
742        );
743    }
744
745    #[tokio::test(flavor = "multi_thread")]
746    async fn test_scale_no_args() {
747        let ast = PIPE.to_string()
748            + r#"
749    |> scale(
750    )
751"#;
752        let result = parse_execute(&ast).await;
753        assert!(result.is_err());
754        assert_eq!(
755            result.unwrap_err().message(),
756            r#"Expected `x`, `y`, `z` or `factor` to be provided."#.to_string()
757        );
758    }
759
760    #[tokio::test(flavor = "multi_thread")]
761    async fn test_hide_pipe_solid_ok() {
762        let ast = PIPE.to_string()
763            + r#"
764    |> hide()
765"#;
766        parse_execute(&ast).await.unwrap();
767    }
768
769    #[tokio::test(flavor = "multi_thread")]
770    async fn test_hide_helix() {
771        let ast = r#"helixPath = helix(
772  axis = Z,
773  radius = 5,
774  length = 10,
775  revolutions = 3,
776  angleStart = 360,
777  ccw = false,
778)
779
780hide(helixPath)
781"#;
782        parse_execute(ast).await.unwrap();
783    }
784
785    #[tokio::test(flavor = "multi_thread")]
786    async fn test_hide_no_objects() {
787        let ast = r#"hidden = hide()"#;
788        let result = parse_execute(ast).await;
789        assert!(result.is_err());
790        assert_eq!(
791            result.unwrap_err().message(),
792            r#"This function expects an unlabeled first parameter, but you haven't passed it one."#.to_string()
793        );
794    }
795}