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, a sketch, or a helix.
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::helices(),
152            RuntimeType::imported(),
153        ]),
154        exec_state,
155    )?;
156    let translate_x: Option<TyF64> = args.get_kw_arg_opt("x", &RuntimeType::length(), exec_state)?;
157    let translate_y: Option<TyF64> = args.get_kw_arg_opt("y", &RuntimeType::length(), exec_state)?;
158    let translate_z: Option<TyF64> = args.get_kw_arg_opt("z", &RuntimeType::length(), exec_state)?;
159    let xyz: Option<[TyF64; 3]> = args.get_kw_arg_opt("xyz", &RuntimeType::point3d(), exec_state)?;
160    let global = args.get_kw_arg_opt("global", &RuntimeType::bool(), exec_state)?;
161
162    let objects = inner_translate(
163        objects,
164        xyz,
165        translate_x,
166        translate_y,
167        translate_z,
168        global,
169        exec_state,
170        args,
171    )
172    .await?;
173    Ok(objects.into())
174}
175
176#[allow(clippy::too_many_arguments)]
177async fn inner_translate(
178    objects: SolidOrSketchOrImportedGeometry,
179    xyz: Option<[TyF64; 3]>,
180    x: Option<TyF64>,
181    y: Option<TyF64>,
182    z: Option<TyF64>,
183    global: Option<bool>,
184    exec_state: &mut ExecState,
185    args: Args,
186) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
187    let (x, y, z) = match (xyz, x, y, z) {
188        (None, None, None, None) => {
189            return Err(KclError::new_semantic(KclErrorDetails::new(
190                "Expected `x`, `y`, or `z` to be provided.".to_string(),
191                vec![args.source_range],
192            )));
193        }
194        (Some(xyz), None, None, None) => {
195            let [x, y, z] = xyz;
196            (Some(x), Some(y), Some(z))
197        }
198        (None, x, y, z) => (x, y, z),
199        (Some(_), _, _, _) => {
200            return Err(KclError::new_semantic(KclErrorDetails::new(
201                "If you provide all 3 distances via the `xyz` arg, you cannot provide them separately via the `x`, `y` or `z` args."
202                    .to_string(),
203                vec![args.source_range],
204            )));
205        }
206    };
207    // If we have a solid, flush the fillets and chamfers.
208    // Only transforms needs this, it is very odd, see: https://github.com/KittyCAD/modeling-app/issues/5880
209    if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
210        exec_state
211            .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), solids)
212            .await?;
213    }
214
215    let is_global = global.unwrap_or(false);
216    let origin = if is_global {
217        OriginType::Global
218    } else {
219        OriginType::Local
220    };
221
222    let translation = shared::Point3d {
223        x: LengthUnit(x.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
224        y: LengthUnit(y.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
225        z: LengthUnit(z.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
226    };
227    let mut objects = objects.clone();
228    for object_id in objects.ids(&args.ctx).await? {
229        let transform = shared::ComponentTransform::builder()
230            .translate(transform_by(translation, false, origin))
231            .build();
232        let transforms = vec![transform];
233        exec_state
234            .batch_modeling_cmd(
235                ModelingCmdMeta::from_args(exec_state, &args),
236                ModelingCmd::from(
237                    mcmd::SetObjectTransform::builder()
238                        .object_id(object_id)
239                        .transforms(transforms)
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        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        OriginType::Global
406    } else {
407        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            let transform = shared::ComponentTransform::builder()
414                .rotate_angle_axis(transform_by(
415                    shared::Point4d {
416                        x: axis[0],
417                        y: axis[1],
418                        z: axis[2],
419                        w: angle,
420                    },
421                    false,
422                    origin,
423                ))
424                .build();
425            let transforms = vec![transform];
426            exec_state
427                .batch_modeling_cmd(
428                    ModelingCmdMeta::from_args(exec_state, &args),
429                    ModelingCmd::from(
430                        mcmd::SetObjectTransform::builder()
431                            .object_id(object_id)
432                            .transforms(transforms)
433                            .build(),
434                    ),
435                )
436                .await?;
437        } else {
438            // Do roll, pitch, and yaw.
439            let transform = shared::ComponentTransform::builder()
440                .rotate_rpy(transform_by(
441                    shared::Point3d {
442                        x: roll.unwrap_or(0.0),
443                        y: pitch.unwrap_or(0.0),
444                        z: yaw.unwrap_or(0.0),
445                    },
446                    false,
447                    origin,
448                ))
449                .build();
450            let transforms = vec![transform];
451            exec_state
452                .batch_modeling_cmd(
453                    ModelingCmdMeta::from_args(exec_state, &args),
454                    ModelingCmd::from(
455                        mcmd::SetObjectTransform::builder()
456                            .object_id(object_id)
457                            .transforms(transforms)
458                            .build(),
459                    ),
460                )
461                .await?;
462        }
463    }
464
465    Ok(objects)
466}
467
468/// Hide solids, planes, sketches, helices, or imported objects.
469pub async fn hide(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
470    let objects = args.get_unlabeled_kw_arg(
471        "objects",
472        &RuntimeType::Union(vec![
473            RuntimeType::sketches(),
474            RuntimeType::solids(),
475            RuntimeType::planes(),
476            RuntimeType::helices(),
477            RuntimeType::imported(),
478            RuntimeType::gdts(),
479        ]),
480        exec_state,
481    )?;
482
483    let objects = hide_inner(objects, true, exec_state, args).await?;
484    Ok(objects.into())
485}
486
487async fn hide_inner(
488    mut objects: HideableGeometry,
489    hidden: bool,
490    exec_state: &mut ExecState,
491    args: Args,
492) -> Result<HideableGeometry, KclError> {
493    for object_id in objects.ids(&args.ctx).await? {
494        exec_state
495            .batch_modeling_cmd(
496                ModelingCmdMeta::from_args(exec_state, &args),
497                ModelingCmd::from(
498                    mcmd::ObjectVisible::builder()
499                        .object_id(object_id)
500                        .hidden(hidden)
501                        .build(),
502                ),
503            )
504            .await?;
505    }
506
507    Ok(objects)
508}
509
510/// Delete solids, sketches, helices, or imported objects.
511pub async fn delete(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
512    let objects = args.get_unlabeled_kw_arg(
513        "objects",
514        &RuntimeType::Union(vec![
515            RuntimeType::sketches(),
516            RuntimeType::solids(),
517            RuntimeType::helices(),
518            RuntimeType::imported(),
519            RuntimeType::gdts(),
520        ]),
521        exec_state,
522    )?;
523
524    delete_inner(objects, exec_state, args).await.map(|()| KclValue::none())
525}
526
527async fn delete_inner(mut objects: HideableGeometry, exec_state: &mut ExecState, args: Args) -> Result<(), KclError> {
528    let ids = objects.ids(&args.ctx).await?.into_iter().collect();
529    exec_state
530        .batch_modeling_cmd(
531            ModelingCmdMeta::from_args(exec_state, &args),
532            ModelingCmd::from(mcmd::RemoveSceneObjects::builder().object_ids(ids).build()),
533        )
534        .await
535}
536
537#[cfg(test)]
538mod tests {
539    use kittycad_modeling_cmds::ModelingCmd;
540    use pretty_assertions::assert_eq;
541
542    use crate::errors::Severity;
543    use crate::errors::Tag;
544    use crate::execution::MockConfig;
545    use crate::execution::parse_execute;
546
547    const PIPE: &str = r#"sweepPath = startSketchOn(XZ)
548    |> startProfile(at = [0.05, 0.05])
549    |> line(end = [0, 7])
550    |> tangentialArc(angle = 90, radius = 5)
551    |> line(end = [-3, 0])
552    |> tangentialArc(angle = -90, radius = 5)
553    |> line(end = [0, 7])
554
555// Create a hole for the pipe.
556pipeHole = startSketchOn(XY)
557    |> circle(
558        center = [0, 0],
559        radius = 1.5,
560    )
561sweepSketch = startSketchOn(XY)
562    |> circle(
563        center = [0, 0],
564        radius = 2,
565        )              
566    |> subtract2d(tool = pipeHole)
567    |> sweep(
568        path = sweepPath,
569    )"#;
570
571    #[tokio::test(flavor = "multi_thread")]
572    async fn test_rotate_empty() {
573        let ast = PIPE.to_string()
574            + r#"
575    |> rotate()
576"#;
577        let result = parse_execute(&ast).await;
578        assert!(result.is_err());
579        assert_eq!(
580            result.unwrap_err().message(),
581            r#"Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided."#.to_string()
582        );
583    }
584
585    #[tokio::test(flavor = "multi_thread")]
586    async fn test_rotate_axis_no_angle() {
587        let ast = PIPE.to_string()
588            + r#"
589    |> rotate(
590    axis =  [0, 0, 1.0],
591    )
592"#;
593        let result = parse_execute(&ast).await;
594        assert!(result.is_err());
595        assert_eq!(
596            result.unwrap_err().message(),
597            r#"Expected `angle` to be provided when `axis` is provided."#.to_string()
598        );
599    }
600
601    #[tokio::test(flavor = "multi_thread")]
602    async fn test_rotate_angle_no_axis() {
603        let ast = PIPE.to_string()
604            + r#"
605    |> rotate(
606    angle = 90,
607    )
608"#;
609        let result = parse_execute(&ast).await;
610        assert!(result.is_err());
611        assert_eq!(
612            result.unwrap_err().message(),
613            r#"Expected `axis` to be provided when `angle` is provided."#.to_string()
614        );
615    }
616
617    #[tokio::test(flavor = "multi_thread")]
618    async fn test_rotate_angle_out_of_range() {
619        let ast = PIPE.to_string()
620            + r#"
621    |> rotate(
622    axis =  [0, 0, 1.0],
623    angle = 900,
624    )
625"#;
626        let result = parse_execute(&ast).await;
627        assert!(result.is_err());
628        assert_eq!(
629            result.unwrap_err().message(),
630            r#"Expected angle to be between -360 and 360, found `900`"#.to_string()
631        );
632    }
633
634    #[tokio::test(flavor = "multi_thread")]
635    async fn test_rotate_angle_axis_yaw() {
636        let ast = PIPE.to_string()
637            + r#"
638    |> rotate(
639    axis =  [0, 0, 1.0],
640    angle = 90,
641    yaw = 90,
642   ) 
643"#;
644        let result = parse_execute(&ast).await;
645        assert!(result.is_err());
646        assert_eq!(
647            result.unwrap_err().message(),
648            r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
649                .to_string()
650        );
651    }
652
653    #[tokio::test(flavor = "multi_thread")]
654    async fn test_rotate_yaw_only() {
655        let ast = PIPE.to_string()
656            + r#"
657    |> rotate(
658    yaw = 90,
659    )
660"#;
661        parse_execute(&ast).await.unwrap();
662    }
663
664    #[tokio::test(flavor = "multi_thread")]
665    async fn test_rotate_pitch_only() {
666        let ast = PIPE.to_string()
667            + r#"
668    |> rotate(
669    pitch = 90,
670    )
671"#;
672        parse_execute(&ast).await.unwrap();
673    }
674
675    #[tokio::test(flavor = "multi_thread")]
676    async fn test_rotate_roll_only() {
677        let ast = PIPE.to_string()
678            + r#"
679    |> rotate(
680    pitch = 90,
681    )
682"#;
683        parse_execute(&ast).await.unwrap();
684    }
685
686    #[tokio::test(flavor = "multi_thread")]
687    async fn test_rotate_yaw_out_of_range() {
688        let ast = PIPE.to_string()
689            + r#"
690    |> rotate(
691    yaw = 900,
692    pitch = 90,
693    roll = 90,
694    )
695"#;
696        let result = parse_execute(&ast).await;
697        assert!(result.is_err());
698        assert_eq!(
699            result.unwrap_err().message(),
700            r#"Expected yaw to be between -360 and 360, found `900`"#.to_string()
701        );
702    }
703
704    #[tokio::test(flavor = "multi_thread")]
705    async fn test_rotate_roll_out_of_range() {
706        let ast = PIPE.to_string()
707            + r#"
708    |> rotate(
709    yaw = 90,
710    pitch = 90,
711    roll = 900,
712    )
713"#;
714        let result = parse_execute(&ast).await;
715        assert!(result.is_err());
716        assert_eq!(
717            result.unwrap_err().message(),
718            r#"Expected roll to be between -360 and 360, found `900`"#.to_string()
719        );
720    }
721
722    #[tokio::test(flavor = "multi_thread")]
723    async fn test_rotate_pitch_out_of_range() {
724        let ast = PIPE.to_string()
725            + r#"
726    |> rotate(
727    yaw = 90,
728    pitch = 900,
729    roll = 90,
730    )
731"#;
732        let result = parse_execute(&ast).await;
733        assert!(result.is_err());
734        assert_eq!(
735            result.unwrap_err().message(),
736            r#"Expected pitch to be between -360 and 360, found `900`"#.to_string()
737        );
738    }
739
740    #[tokio::test(flavor = "multi_thread")]
741    async fn test_rotate_roll_pitch_yaw_with_angle() {
742        let ast = PIPE.to_string()
743            + r#"
744    |> rotate(
745    yaw = 90,
746    pitch = 90,
747    roll = 90,
748    angle = 90,
749    )
750"#;
751        let result = parse_execute(&ast).await;
752        assert!(result.is_err());
753        assert_eq!(
754            result.unwrap_err().message(),
755            r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
756                .to_string()
757        );
758    }
759
760    #[tokio::test(flavor = "multi_thread")]
761    async fn test_translate_no_args() {
762        let ast = PIPE.to_string()
763            + r#"
764    |> translate(
765    )
766"#;
767        let result = parse_execute(&ast).await;
768        assert!(result.is_err());
769        assert_eq!(
770            result.unwrap_err().message(),
771            r#"Expected `x`, `y`, or `z` to be provided."#.to_string()
772        );
773    }
774
775    #[tokio::test(flavor = "multi_thread")]
776    async fn test_scale_no_args() {
777        let ast = PIPE.to_string()
778            + r#"
779    |> scale(
780    )
781"#;
782        let result = parse_execute(&ast).await;
783        assert!(result.is_err());
784        assert_eq!(
785            result.unwrap_err().message(),
786            r#"Expected `x`, `y`, `z` or `factor` to be provided."#.to_string()
787        );
788    }
789
790    #[tokio::test(flavor = "multi_thread")]
791    async fn test_hide_pipe_solid_ok() {
792        let ast = PIPE.to_string()
793            + r#"
794    |> hide()
795"#;
796        parse_execute(&ast).await.unwrap();
797    }
798
799    #[tokio::test(flavor = "multi_thread")]
800    async fn hide_consumed_solid_reports_deprecation_warning() {
801        let code = r#"
802targetSketch = sketch(on = XY) {
803  line1 = line(start = [var -10, var -10], end = [var 10, var -10])
804  line2 = line(start = [var 10, var -10], end = [var 10, var 10])
805  line3 = line(start = [var 10, var 10], end = [var -10, var 10])
806  line4 = line(start = [var -10, var 10], end = [var -10, var -10])
807  coincident([line1.end, line2.start])
808  coincident([line2.end, line3.start])
809  coincident([line3.end, line4.start])
810  coincident([line4.end, line1.start])
811  equalLength([line1, line2, line3, line4])
812}
813
814target = extrude(region(point = [0, 0], sketch = targetSketch), length = 20)
815
816toolSketch = sketch(on = XY) {
817  line1 = line(start = [var -2, var -2], end = [var 2, var -2])
818  line2 = line(start = [var 2, var -2], end = [var 2, var 2])
819  line3 = line(start = [var 2, var 2], end = [var -2, var 2])
820  line4 = line(start = [var -2, var 2], end = [var -2, var -2])
821  coincident([line1.end, line2.start])
822  coincident([line2.end, line3.start])
823  coincident([line3.end, line4.start])
824  coincident([line4.end, line1.start])
825  equalLength([line1, line2, line3, line4])
826}
827
828tool = extrude(region(point = [0, 0], sketch = toolSketch), length = 4)
829
830result = subtract(target, tools = [tool])
831hidden = hide(target)
832"#;
833
834        let program = crate::Program::parse_no_errs(code).unwrap();
835        let ctx = crate::ExecutorContext::new_mock(None).await;
836        let outcome = ctx.run_mock(&program, &MockConfig::default()).await;
837        ctx.close().await;
838        let outcome = outcome.unwrap();
839
840        assert!(
841            outcome.issues.iter().any(|issue| {
842                issue.severity == Severity::Warning
843                    && issue.tag == Tag::Deprecated
844                    && issue
845                        .message
846                        .contains("Calling `hide` with a consumed solid is deprecated")
847                    && issue
848                        .message
849                        .contains("`target` was already consumed by a `subtract` operation")
850            }),
851            "expected hide consumed-solid deprecation warning, got: {:#?}",
852            outcome.issues
853        );
854    }
855
856    #[tokio::test(flavor = "multi_thread")]
857    async fn test_hide_helix() {
858        let ast = r#"helixPath = helix(
859  axis = Z,
860  radius = 5,
861  length = 10,
862  revolutions = 3,
863  angleStart = 360,
864  ccw = false,
865)
866
867hide(helixPath)
868"#;
869        parse_execute(ast).await.unwrap();
870    }
871
872    #[tokio::test(flavor = "multi_thread")]
873    async fn test_hide_sketch_block() {
874        let ast = r#"sketch001 = sketch(on = XY) {
875  circle001 = circle(start = [var 1.16mm, var 4.24mm], center = [var -1.81mm, var -0.5mm])
876}
877
878hide(sketch001)
879"#;
880        parse_execute(ast).await.unwrap();
881    }
882
883    #[tokio::test(flavor = "multi_thread")]
884    async fn test_hide_plane() {
885        let ast = r#"plane001 = offsetPlane(YZ, offset = 500)
886
887hide(plane001)
888"#;
889        let result = parse_execute(ast).await.unwrap();
890        let object_visible_commands = result
891            .root_module_artifact_commands()
892            .iter()
893            .filter_map(|artifact_command| match &artifact_command.command {
894                ModelingCmd::ObjectVisible(object_visible) => Some(object_visible),
895                _ => None,
896            })
897            .collect::<Vec<_>>();
898
899        assert_eq!(
900            object_visible_commands.len(),
901            1,
902            "expected exactly one ObjectVisible command, got: {:#?}",
903            result.root_module_artifact_commands()
904        );
905        assert!(
906            object_visible_commands[0].hidden,
907            "expected ObjectVisible command to hide the plane"
908        );
909    }
910
911    #[tokio::test(flavor = "multi_thread")]
912    async fn test_hide_no_objects() {
913        let ast = r#"hidden = hide()"#;
914        let result = parse_execute(ast).await;
915        assert!(result.is_err());
916        assert_eq!(
917            result.unwrap_err().message(),
918            r#"This function expects an unlabeled first parameter, but you haven't passed it one."#.to_string()
919        );
920    }
921}