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