Skip to main content

kcl_lib/std/
transform.rs

1//! Standard library transforms.
2
3use anyhow::Result;
4use kcmc::ModelingCmd;
5use kcmc::each_cmd as mcmd;
6use kcmc::length_unit::LengthUnit;
7use kcmc::shared;
8use kcmc::shared::OriginType;
9use kcmc::shared::Point3d;
10use kittycad_modeling_cmds as kcmc;
11
12use crate::errors::KclError;
13use crate::errors::KclErrorDetails;
14use crate::execution::ExecState;
15use crate::execution::HideableGeometry;
16use crate::execution::KclValue;
17use crate::execution::ModelingCmdMeta;
18use crate::execution::SolidOrSketchOrImportedGeometry;
19use crate::execution::types::PrimitiveType;
20use crate::execution::types::RuntimeType;
21use crate::std::Args;
22use crate::std::args::TyF64;
23use crate::std::axis_or_reference::Axis3dOrPoint3d;
24
25fn transform_by<T>(property: T, set: bool, origin: OriginType) -> shared::TransformBy<T> {
26    shared::TransformBy::builder()
27        .property(property)
28        .set(set)
29        .origin(origin)
30        .build()
31}
32
33/// Scale a solid or a sketch.
34pub async fn scale(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
35    let objects = args.get_unlabeled_kw_arg(
36        "objects",
37        &RuntimeType::Union(vec![
38            RuntimeType::sketches(),
39            RuntimeType::solids(),
40            RuntimeType::imported(),
41        ]),
42        exec_state,
43    )?;
44    let scale_x: Option<TyF64> = args.get_kw_arg_opt("x", &RuntimeType::count(), exec_state)?;
45    let scale_y: Option<TyF64> = args.get_kw_arg_opt("y", &RuntimeType::count(), exec_state)?;
46    let scale_z: Option<TyF64> = args.get_kw_arg_opt("z", &RuntimeType::count(), exec_state)?;
47    let factor: Option<TyF64> = args.get_kw_arg_opt("factor", &RuntimeType::count(), exec_state)?;
48    for scale_dim in [&scale_x, &scale_y, &scale_z, &factor] {
49        if let Some(num) = scale_dim
50            && num.n == 0.0
51        {
52            return Err(KclError::new_semantic(KclErrorDetails::new(
53                "Cannot scale by 0".to_string(),
54                vec![args.source_range],
55            )));
56        }
57    }
58    let (scale_x, scale_y, scale_z) = match (scale_x, scale_y, scale_z, factor) {
59        (None, None, None, Some(factor)) => (Some(factor.clone()), Some(factor.clone()), Some(factor)),
60        // Ensure at least one scale value is provided.
61        (None, None, None, None) => {
62            return Err(KclError::new_semantic(KclErrorDetails::new(
63                "Expected `x`, `y`, `z` or `factor` to be provided.".to_string(),
64                vec![args.source_range],
65            )));
66        }
67        (x, y, z, None) => (x, y, z),
68        _ => {
69            return Err(KclError::new_semantic(KclErrorDetails::new(
70                "If you give `factor` then you cannot use  `x`, `y`, or `z`".to_string(),
71                vec![args.source_range],
72            )));
73        }
74    };
75    let global = args.get_kw_arg_opt("global", &RuntimeType::bool(), exec_state)?;
76
77    let objects = inner_scale(
78        objects,
79        scale_x.map(|t| t.n),
80        scale_y.map(|t| t.n),
81        scale_z.map(|t| t.n),
82        global,
83        exec_state,
84        args,
85    )
86    .await?;
87    Ok(objects.into())
88}
89
90async fn inner_scale(
91    objects: SolidOrSketchOrImportedGeometry,
92    x: Option<f64>,
93    y: Option<f64>,
94    z: Option<f64>,
95    global: Option<bool>,
96    exec_state: &mut ExecState,
97    args: Args,
98) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
99    // If we have a solid, flush the fillets and chamfers.
100    // Only transforms needs this, it is very odd, see: https://github.com/KittyCAD/modeling-app/issues/5880
101    if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
102        exec_state
103            .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), solids)
104            .await?;
105    }
106
107    let is_global = global.unwrap_or(false);
108    let origin = if is_global {
109        OriginType::Global
110    } else {
111        OriginType::Local
112    };
113
114    let mut objects = objects.clone();
115    for object_id in objects.ids(&args.ctx).await? {
116        let transform = shared::ComponentTransform::builder()
117            .scale(transform_by(
118                Point3d {
119                    x: x.unwrap_or(1.0),
120                    y: y.unwrap_or(1.0),
121                    z: z.unwrap_or(1.0),
122                },
123                false,
124                origin,
125            ))
126            .build();
127        let transforms = vec![transform];
128        exec_state
129            .batch_modeling_cmd(
130                ModelingCmdMeta::from_args(exec_state, &args),
131                ModelingCmd::from(
132                    mcmd::SetObjectTransform::builder()
133                        .object_id(object_id)
134                        .transforms(transforms)
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        OriginType::Global
217    } else {
218        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        let transform = shared::ComponentTransform::builder()
229            .translate(transform_by(translation, false, origin))
230            .build();
231        let transforms = vec![transform];
232        exec_state
233            .batch_modeling_cmd(
234                ModelingCmdMeta::from_args(exec_state, &args),
235                ModelingCmd::from(
236                    mcmd::SetObjectTransform::builder()
237                        .object_id(object_id)
238                        .transforms(transforms)
239                        .build(),
240                ),
241            )
242            .await?;
243    }
244
245    Ok(objects)
246}
247
248/// Rotate a solid or a sketch.
249pub async fn rotate(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
250    let objects = args.get_unlabeled_kw_arg(
251        "objects",
252        &RuntimeType::Union(vec![
253            RuntimeType::sketches(),
254            RuntimeType::solids(),
255            RuntimeType::imported(),
256        ]),
257        exec_state,
258    )?;
259    let roll: Option<TyF64> = args.get_kw_arg_opt("roll", &RuntimeType::degrees(), exec_state)?;
260    let pitch: Option<TyF64> = args.get_kw_arg_opt("pitch", &RuntimeType::degrees(), exec_state)?;
261    let yaw: Option<TyF64> = args.get_kw_arg_opt("yaw", &RuntimeType::degrees(), exec_state)?;
262    let axis: Option<Axis3dOrPoint3d> = args.get_kw_arg_opt(
263        "axis",
264        &RuntimeType::Union(vec![
265            RuntimeType::Primitive(PrimitiveType::Axis3d),
266            RuntimeType::point3d(),
267        ]),
268        exec_state,
269    )?;
270    let origin = axis.clone().map(|a| a.axis_origin()).unwrap_or_default();
271    let axis = axis.map(|a| a.to_point3d());
272    let angle: Option<TyF64> = args.get_kw_arg_opt("angle", &RuntimeType::degrees(), exec_state)?;
273    let global = args.get_kw_arg_opt("global", &RuntimeType::bool(), exec_state)?;
274
275    // Check if no rotation values are provided.
276    if roll.is_none() && pitch.is_none() && yaw.is_none() && axis.is_none() && angle.is_none() {
277        return Err(KclError::new_semantic(KclErrorDetails::new(
278            "Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided.".to_string(),
279            vec![args.source_range],
280        )));
281    }
282
283    // If they give us a roll, pitch, or yaw, they must give us at least one of them.
284    if roll.is_some() || pitch.is_some() || yaw.is_some() {
285        // Ensure they didn't also provide an axis or angle.
286        if axis.is_some() || angle.is_some() {
287            return Err(KclError::new_semantic(KclErrorDetails::new(
288                "Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."
289                    .to_owned(),
290                vec![args.source_range],
291            )));
292        }
293    }
294
295    // If they give us an axis or angle, they must give us both.
296    if axis.is_some() || angle.is_some() {
297        if axis.is_none() {
298            return Err(KclError::new_semantic(KclErrorDetails::new(
299                "Expected `axis` to be provided when `angle` is provided.".to_string(),
300                vec![args.source_range],
301            )));
302        }
303        if angle.is_none() {
304            return Err(KclError::new_semantic(KclErrorDetails::new(
305                "Expected `angle` to be provided when `axis` is provided.".to_string(),
306                vec![args.source_range],
307            )));
308        }
309
310        // Ensure they didn't also provide a roll, pitch, or yaw.
311        if roll.is_some() || pitch.is_some() || yaw.is_some() {
312            return Err(KclError::new_semantic(KclErrorDetails::new(
313                "Expected `roll`, `pitch`, and `yaw` to not be provided when `axis` and `angle` are provided."
314                    .to_owned(),
315                vec![args.source_range],
316            )));
317        }
318    }
319
320    // Validate the roll, pitch, and yaw values.
321    if let Some(roll) = &roll
322        && !(-360.0..=360.0).contains(&roll.n)
323    {
324        return Err(KclError::new_semantic(KclErrorDetails::new(
325            format!("Expected roll to be between -360 and 360, found `{}`", roll.n),
326            vec![args.source_range],
327        )));
328    }
329    if let Some(pitch) = &pitch
330        && !(-360.0..=360.0).contains(&pitch.n)
331    {
332        return Err(KclError::new_semantic(KclErrorDetails::new(
333            format!("Expected pitch to be between -360 and 360, found `{}`", pitch.n),
334            vec![args.source_range],
335        )));
336    }
337    if let Some(yaw) = &yaw
338        && !(-360.0..=360.0).contains(&yaw.n)
339    {
340        return Err(KclError::new_semantic(KclErrorDetails::new(
341            format!("Expected yaw to be between -360 and 360, found `{}`", yaw.n),
342            vec![args.source_range],
343        )));
344    }
345
346    // Validate the axis and angle values.
347    if let Some(angle) = &angle
348        && !(-360.0..=360.0).contains(&angle.n)
349    {
350        return Err(KclError::new_semantic(KclErrorDetails::new(
351            format!("Expected angle to be between -360 and 360, found `{}`", angle.n),
352            vec![args.source_range],
353        )));
354    }
355
356    let objects = inner_rotate(
357        objects,
358        roll.map(|t| t.n),
359        pitch.map(|t| t.n),
360        yaw.map(|t| t.n),
361        // Don't adjust axis units since the axis must be normalized and only the direction
362        // should be significant, not the magnitude.
363        axis.map(|a| [a[0].n, a[1].n, a[2].n]),
364        origin.map(|a| [a[0].n, a[1].n, a[2].n]),
365        angle.map(|t| t.n),
366        global,
367        exec_state,
368        args,
369    )
370    .await?;
371    Ok(objects.into())
372}
373
374#[allow(clippy::too_many_arguments)]
375async fn inner_rotate(
376    objects: SolidOrSketchOrImportedGeometry,
377    roll: Option<f64>,
378    pitch: Option<f64>,
379    yaw: Option<f64>,
380    axis: Option<[f64; 3]>,
381    origin: Option<[f64; 3]>,
382    angle: Option<f64>,
383    global: Option<bool>,
384    exec_state: &mut ExecState,
385    args: Args,
386) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
387    // If we have a solid, flush the fillets and chamfers.
388    // Only transforms needs this, it is very odd, see: https://github.com/KittyCAD/modeling-app/issues/5880
389    if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
390        exec_state
391            .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), solids)
392            .await?;
393    }
394
395    let origin = if let Some(origin) = origin {
396        OriginType::Custom {
397            origin: shared::Point3d {
398                x: origin[0],
399                y: origin[1],
400                z: origin[2],
401            },
402        }
403    } else if global.unwrap_or(false) {
404        OriginType::Global
405    } else {
406        OriginType::Local
407    };
408
409    let mut objects = objects.clone();
410    for object_id in objects.ids(&args.ctx).await? {
411        if let (Some(axis), Some(angle)) = (&axis, angle) {
412            let transform = shared::ComponentTransform::builder()
413                .rotate_angle_axis(transform_by(
414                    shared::Point4d {
415                        x: axis[0],
416                        y: axis[1],
417                        z: axis[2],
418                        w: angle,
419                    },
420                    false,
421                    origin,
422                ))
423                .build();
424            let transforms = vec![transform];
425            exec_state
426                .batch_modeling_cmd(
427                    ModelingCmdMeta::from_args(exec_state, &args),
428                    ModelingCmd::from(
429                        mcmd::SetObjectTransform::builder()
430                            .object_id(object_id)
431                            .transforms(transforms)
432                            .build(),
433                    ),
434                )
435                .await?;
436        } else {
437            // Do roll, pitch, and yaw.
438            let transform = shared::ComponentTransform::builder()
439                .rotate_rpy(transform_by(
440                    shared::Point3d {
441                        x: roll.unwrap_or(0.0),
442                        y: pitch.unwrap_or(0.0),
443                        z: yaw.unwrap_or(0.0),
444                    },
445                    false,
446                    origin,
447                ))
448                .build();
449            let transforms = vec![transform];
450            exec_state
451                .batch_modeling_cmd(
452                    ModelingCmdMeta::from_args(exec_state, &args),
453                    ModelingCmd::from(
454                        mcmd::SetObjectTransform::builder()
455                            .object_id(object_id)
456                            .transforms(transforms)
457                            .build(),
458                    ),
459                )
460                .await?;
461        }
462    }
463
464    Ok(objects)
465}
466
467/// Hide solids, planes, sketches, helices, or imported objects.
468pub async fn hide(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
469    let objects = args.get_unlabeled_kw_arg(
470        "objects",
471        &RuntimeType::Union(vec![
472            RuntimeType::sketches(),
473            RuntimeType::solids(),
474            RuntimeType::planes(),
475            RuntimeType::helices(),
476            RuntimeType::imported(),
477            RuntimeType::gdts(),
478        ]),
479        exec_state,
480    )?;
481
482    let objects = hide_inner(objects, true, exec_state, args).await?;
483    Ok(objects.into())
484}
485
486async fn hide_inner(
487    mut objects: HideableGeometry,
488    hidden: bool,
489    exec_state: &mut ExecState,
490    args: Args,
491) -> Result<HideableGeometry, KclError> {
492    for object_id in objects.ids(&args.ctx).await? {
493        exec_state
494            .batch_modeling_cmd(
495                ModelingCmdMeta::from_args(exec_state, &args),
496                ModelingCmd::from(
497                    mcmd::ObjectVisible::builder()
498                        .object_id(object_id)
499                        .hidden(hidden)
500                        .build(),
501                ),
502            )
503            .await?;
504    }
505
506    Ok(objects)
507}
508
509/// Delete solids, sketches, helices, or imported objects.
510pub async fn delete(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
511    let objects = args.get_unlabeled_kw_arg(
512        "objects",
513        &RuntimeType::Union(vec![
514            RuntimeType::sketches(),
515            RuntimeType::solids(),
516            RuntimeType::helices(),
517            RuntimeType::imported(),
518            RuntimeType::gdts(),
519        ]),
520        exec_state,
521    )?;
522
523    delete_inner(objects, exec_state, args).await.map(|()| KclValue::none())
524}
525
526async fn delete_inner(mut objects: HideableGeometry, exec_state: &mut ExecState, args: Args) -> Result<(), KclError> {
527    let ids = objects.ids(&args.ctx).await?.into_iter().collect();
528    exec_state
529        .batch_modeling_cmd(
530            ModelingCmdMeta::from_args(exec_state, &args),
531            ModelingCmd::from(mcmd::RemoveSceneObjects::builder().object_ids(ids).build()),
532        )
533        .await
534}
535
536#[cfg(test)]
537mod tests {
538    use kittycad_modeling_cmds::ModelingCmd;
539    use pretty_assertions::assert_eq;
540
541    use crate::errors::Severity;
542    use crate::errors::Tag;
543    use crate::execution::MockConfig;
544    use crate::execution::parse_execute;
545
546    const PIPE: &str = r#"sweepPath = startSketchOn(XZ)
547    |> startProfile(at = [0.05, 0.05])
548    |> line(end = [0, 7])
549    |> tangentialArc(angle = 90, radius = 5)
550    |> line(end = [-3, 0])
551    |> tangentialArc(angle = -90, radius = 5)
552    |> line(end = [0, 7])
553
554// Create a hole for the pipe.
555pipeHole = startSketchOn(XY)
556    |> circle(
557        center = [0, 0],
558        radius = 1.5,
559    )
560sweepSketch = startSketchOn(XY)
561    |> circle(
562        center = [0, 0],
563        radius = 2,
564        )              
565    |> subtract2d(tool = pipeHole)
566    |> sweep(
567        path = sweepPath,
568    )"#;
569
570    #[tokio::test(flavor = "multi_thread")]
571    async fn test_rotate_empty() {
572        let ast = PIPE.to_string()
573            + r#"
574    |> rotate()
575"#;
576        let result = parse_execute(&ast).await;
577        assert!(result.is_err());
578        assert_eq!(
579            result.unwrap_err().message(),
580            r#"Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided."#.to_string()
581        );
582    }
583
584    #[tokio::test(flavor = "multi_thread")]
585    async fn test_rotate_axis_no_angle() {
586        let ast = PIPE.to_string()
587            + r#"
588    |> rotate(
589    axis =  [0, 0, 1.0],
590    )
591"#;
592        let result = parse_execute(&ast).await;
593        assert!(result.is_err());
594        assert_eq!(
595            result.unwrap_err().message(),
596            r#"Expected `angle` to be provided when `axis` is provided."#.to_string()
597        );
598    }
599
600    #[tokio::test(flavor = "multi_thread")]
601    async fn test_rotate_angle_no_axis() {
602        let ast = PIPE.to_string()
603            + r#"
604    |> rotate(
605    angle = 90,
606    )
607"#;
608        let result = parse_execute(&ast).await;
609        assert!(result.is_err());
610        assert_eq!(
611            result.unwrap_err().message(),
612            r#"Expected `axis` to be provided when `angle` is provided."#.to_string()
613        );
614    }
615
616    #[tokio::test(flavor = "multi_thread")]
617    async fn test_rotate_angle_out_of_range() {
618        let ast = PIPE.to_string()
619            + r#"
620    |> rotate(
621    axis =  [0, 0, 1.0],
622    angle = 900,
623    )
624"#;
625        let result = parse_execute(&ast).await;
626        assert!(result.is_err());
627        assert_eq!(
628            result.unwrap_err().message(),
629            r#"Expected angle to be between -360 and 360, found `900`"#.to_string()
630        );
631    }
632
633    #[tokio::test(flavor = "multi_thread")]
634    async fn test_rotate_angle_axis_yaw() {
635        let ast = PIPE.to_string()
636            + r#"
637    |> rotate(
638    axis =  [0, 0, 1.0],
639    angle = 90,
640    yaw = 90,
641   ) 
642"#;
643        let result = parse_execute(&ast).await;
644        assert!(result.is_err());
645        assert_eq!(
646            result.unwrap_err().message(),
647            r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
648                .to_string()
649        );
650    }
651
652    #[tokio::test(flavor = "multi_thread")]
653    async fn test_rotate_yaw_only() {
654        let ast = PIPE.to_string()
655            + r#"
656    |> rotate(
657    yaw = 90,
658    )
659"#;
660        parse_execute(&ast).await.unwrap();
661    }
662
663    #[tokio::test(flavor = "multi_thread")]
664    async fn test_rotate_pitch_only() {
665        let ast = PIPE.to_string()
666            + r#"
667    |> rotate(
668    pitch = 90,
669    )
670"#;
671        parse_execute(&ast).await.unwrap();
672    }
673
674    #[tokio::test(flavor = "multi_thread")]
675    async fn test_rotate_roll_only() {
676        let ast = PIPE.to_string()
677            + r#"
678    |> rotate(
679    pitch = 90,
680    )
681"#;
682        parse_execute(&ast).await.unwrap();
683    }
684
685    #[tokio::test(flavor = "multi_thread")]
686    async fn test_rotate_yaw_out_of_range() {
687        let ast = PIPE.to_string()
688            + r#"
689    |> rotate(
690    yaw = 900,
691    pitch = 90,
692    roll = 90,
693    )
694"#;
695        let result = parse_execute(&ast).await;
696        assert!(result.is_err());
697        assert_eq!(
698            result.unwrap_err().message(),
699            r#"Expected yaw to be between -360 and 360, found `900`"#.to_string()
700        );
701    }
702
703    #[tokio::test(flavor = "multi_thread")]
704    async fn test_rotate_roll_out_of_range() {
705        let ast = PIPE.to_string()
706            + r#"
707    |> rotate(
708    yaw = 90,
709    pitch = 90,
710    roll = 900,
711    )
712"#;
713        let result = parse_execute(&ast).await;
714        assert!(result.is_err());
715        assert_eq!(
716            result.unwrap_err().message(),
717            r#"Expected roll to be between -360 and 360, found `900`"#.to_string()
718        );
719    }
720
721    #[tokio::test(flavor = "multi_thread")]
722    async fn test_rotate_pitch_out_of_range() {
723        let ast = PIPE.to_string()
724            + r#"
725    |> rotate(
726    yaw = 90,
727    pitch = 900,
728    roll = 90,
729    )
730"#;
731        let result = parse_execute(&ast).await;
732        assert!(result.is_err());
733        assert_eq!(
734            result.unwrap_err().message(),
735            r#"Expected pitch to be between -360 and 360, found `900`"#.to_string()
736        );
737    }
738
739    #[tokio::test(flavor = "multi_thread")]
740    async fn test_rotate_roll_pitch_yaw_with_angle() {
741        let ast = PIPE.to_string()
742            + r#"
743    |> rotate(
744    yaw = 90,
745    pitch = 90,
746    roll = 90,
747    angle = 90,
748    )
749"#;
750        let result = parse_execute(&ast).await;
751        assert!(result.is_err());
752        assert_eq!(
753            result.unwrap_err().message(),
754            r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
755                .to_string()
756        );
757    }
758
759    #[tokio::test(flavor = "multi_thread")]
760    async fn test_translate_no_args() {
761        let ast = PIPE.to_string()
762            + r#"
763    |> translate(
764    )
765"#;
766        let result = parse_execute(&ast).await;
767        assert!(result.is_err());
768        assert_eq!(
769            result.unwrap_err().message(),
770            r#"Expected `x`, `y`, or `z` to be provided."#.to_string()
771        );
772    }
773
774    #[tokio::test(flavor = "multi_thread")]
775    async fn test_scale_no_args() {
776        let ast = PIPE.to_string()
777            + r#"
778    |> scale(
779    )
780"#;
781        let result = parse_execute(&ast).await;
782        assert!(result.is_err());
783        assert_eq!(
784            result.unwrap_err().message(),
785            r#"Expected `x`, `y`, `z` or `factor` to be provided."#.to_string()
786        );
787    }
788
789    #[tokio::test(flavor = "multi_thread")]
790    async fn test_hide_pipe_solid_ok() {
791        let ast = PIPE.to_string()
792            + r#"
793    |> hide()
794"#;
795        parse_execute(&ast).await.unwrap();
796    }
797
798    #[tokio::test(flavor = "multi_thread")]
799    async fn hide_consumed_solid_reports_deprecation_warning() {
800        let code = r#"
801targetSketch = sketch(on = XY) {
802  line1 = line(start = [var -10, var -10], end = [var 10, var -10])
803  line2 = line(start = [var 10, var -10], end = [var 10, var 10])
804  line3 = line(start = [var 10, var 10], end = [var -10, var 10])
805  line4 = line(start = [var -10, var 10], end = [var -10, var -10])
806  coincident([line1.end, line2.start])
807  coincident([line2.end, line3.start])
808  coincident([line3.end, line4.start])
809  coincident([line4.end, line1.start])
810  equalLength([line1, line2, line3, line4])
811}
812
813target = extrude(region(point = [0, 0], sketch = targetSketch), length = 20)
814
815toolSketch = sketch(on = XY) {
816  line1 = line(start = [var -2, var -2], end = [var 2, var -2])
817  line2 = line(start = [var 2, var -2], end = [var 2, var 2])
818  line3 = line(start = [var 2, var 2], end = [var -2, var 2])
819  line4 = line(start = [var -2, var 2], end = [var -2, var -2])
820  coincident([line1.end, line2.start])
821  coincident([line2.end, line3.start])
822  coincident([line3.end, line4.start])
823  coincident([line4.end, line1.start])
824  equalLength([line1, line2, line3, line4])
825}
826
827tool = extrude(region(point = [0, 0], sketch = toolSketch), length = 4)
828
829result = subtract(target, tools = [tool])
830hidden = hide(target)
831"#;
832
833        let program = crate::Program::parse_no_errs(code).unwrap();
834        let ctx = crate::ExecutorContext::new_mock(None).await;
835        let outcome = ctx.run_mock(&program, &MockConfig::default()).await;
836        ctx.close().await;
837        let outcome = outcome.unwrap();
838
839        assert!(
840            outcome.issues.iter().any(|issue| {
841                issue.severity == Severity::Warning
842                    && issue.tag == Tag::Deprecated
843                    && issue
844                        .message
845                        .contains("Calling `hide` with a consumed solid is deprecated")
846                    && issue
847                        .message
848                        .contains("`target` was already consumed by a `subtract` operation")
849            }),
850            "expected hide consumed-solid deprecation warning, got: {:#?}",
851            outcome.issues
852        );
853    }
854
855    #[tokio::test(flavor = "multi_thread")]
856    async fn test_hide_helix() {
857        let ast = r#"helixPath = helix(
858  axis = Z,
859  radius = 5,
860  length = 10,
861  revolutions = 3,
862  angleStart = 360,
863  ccw = false,
864)
865
866hide(helixPath)
867"#;
868        parse_execute(ast).await.unwrap();
869    }
870
871    #[tokio::test(flavor = "multi_thread")]
872    async fn test_hide_sketch_block() {
873        let ast = r#"sketch001 = sketch(on = XY) {
874  circle001 = circle(start = [var 1.16mm, var 4.24mm], center = [var -1.81mm, var -0.5mm])
875}
876
877hide(sketch001)
878"#;
879        parse_execute(ast).await.unwrap();
880    }
881
882    #[tokio::test(flavor = "multi_thread")]
883    async fn test_hide_plane() {
884        let ast = r#"plane001 = offsetPlane(YZ, offset = 500)
885
886hide(plane001)
887"#;
888        let result = parse_execute(ast).await.unwrap();
889        let object_visible_commands = result
890            .root_module_artifact_commands()
891            .iter()
892            .filter_map(|artifact_command| match &artifact_command.command {
893                ModelingCmd::ObjectVisible(object_visible) => Some(object_visible),
894                _ => None,
895            })
896            .collect::<Vec<_>>();
897
898        assert_eq!(
899            object_visible_commands.len(),
900            1,
901            "expected exactly one ObjectVisible command, got: {:#?}",
902            result.root_module_artifact_commands()
903        );
904        assert!(
905            object_visible_commands[0].hidden,
906            "expected ObjectVisible command to hide the plane"
907        );
908    }
909
910    #[tokio::test(flavor = "multi_thread")]
911    async fn test_hide_no_objects() {
912        let ast = r#"hidden = hide()"#;
913        let result = parse_execute(ast).await;
914        assert!(result.is_err());
915        assert_eq!(
916            result.unwrap_err().message(),
917            r#"This function expects an unlabeled first parameter, but you haven't passed it one."#.to_string()
918        );
919    }
920}