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, imported objects, or GD&T annotations.
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::Artifact;
532    use crate::execution::ExecutorSettings;
533    use crate::execution::MockConfig;
534    use crate::execution::parse_execute;
535
536    const PIPE: &str = r#"sweepPath = startSketchOn(XZ)
537    |> startProfile(at = [0.05, 0.05])
538    |> line(end = [0, 7])
539    |> tangentialArc(angle = 90, radius = 5)
540    |> line(end = [-3, 0])
541    |> tangentialArc(angle = -90, radius = 5)
542    |> line(end = [0, 7])
543
544// Create a hole for the pipe.
545pipeHole = startSketchOn(XY)
546    |> circle(
547        center = [0, 0],
548        radius = 1.5,
549    )
550sweepSketch = startSketchOn(XY)
551    |> circle(
552        center = [0, 0],
553        radius = 2,
554        )              
555    |> subtract2d(tool = pipeHole)
556    |> sweep(
557        path = sweepPath,
558    )"#;
559
560    async fn rotate_transform(arguments: &str) -> ComponentTransform {
561        let ast = format!("{PIPE}\n    |> rotate({arguments})");
562        let result = parse_execute(&ast).await.unwrap();
563
564        result
565            .root_module_artifact_commands()
566            .iter()
567            .find_map(|artifact_command| match &artifact_command.command {
568                ModelingCmd::SetObjectTransform(command) => command
569                    .transforms
570                    .iter()
571                    .find(|transform| transform.rotate_angle_axis.is_some() || transform.rotate_rpy.is_some()),
572                _ => None,
573            })
574            .cloned()
575            .expect("expected rotate() to dispatch a rotation transform")
576    }
577
578    #[tokio::test(flavor = "multi_thread")]
579    async fn test_rotate_empty() {
580        let ast = PIPE.to_string()
581            + r#"
582    |> rotate()
583"#;
584        let result = parse_execute(&ast).await;
585        assert!(result.is_err());
586        assert_eq!(
587            result.unwrap_err().message(),
588            r#"Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided."#.to_string()
589        );
590    }
591
592    #[tokio::test(flavor = "multi_thread")]
593    async fn test_rotate_axis_no_angle() {
594        let ast = PIPE.to_string()
595            + r#"
596    |> rotate(
597    axis =  [0, 0, 1.0],
598    )
599"#;
600        let result = parse_execute(&ast).await;
601        assert!(result.is_err());
602        assert_eq!(
603            result.unwrap_err().message(),
604            r#"Expected `angle` to be provided when `axis` is provided."#.to_string()
605        );
606    }
607
608    #[tokio::test(flavor = "multi_thread")]
609    async fn test_rotate_angle_no_axis() {
610        let ast = PIPE.to_string()
611            + r#"
612    |> rotate(
613    angle = 90,
614    )
615"#;
616        let result = parse_execute(&ast).await;
617        assert!(result.is_err());
618        assert_eq!(
619            result.unwrap_err().message(),
620            r#"Expected `axis` to be provided when `angle` is provided."#.to_string()
621        );
622    }
623
624    #[tokio::test(flavor = "multi_thread")]
625    async fn test_rotate_accepts_axis_angles_outside_one_turn() {
626        for (input, expected) in [("371.5", 371.5), ("-371.5", -371.5), ("720", 720.0)] {
627            let transform = rotate_transform(&format!("axis = [0, 0, 1], angle = {input}")).await;
628            let rotation = transform
629                .rotate_angle_axis
630                .expect("expected an axis-angle rotation transform");
631
632            assert_eq!(rotation.property.w, expected, "input angle: {input}");
633        }
634    }
635
636    #[tokio::test(flavor = "multi_thread")]
637    async fn test_rotate_angle_axis_yaw() {
638        let ast = PIPE.to_string()
639            + r#"
640    |> rotate(
641    axis =  [0, 0, 1.0],
642    angle = 90,
643    yaw = 90,
644   ) 
645"#;
646        let result = parse_execute(&ast).await;
647        assert!(result.is_err());
648        assert_eq!(
649            result.unwrap_err().message(),
650            r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
651                .to_string()
652        );
653    }
654
655    #[tokio::test(flavor = "multi_thread")]
656    async fn test_rotate_yaw_only() {
657        let ast = PIPE.to_string()
658            + r#"
659    |> rotate(
660    yaw = 90,
661    )
662"#;
663        parse_execute(&ast).await.unwrap();
664    }
665
666    #[tokio::test(flavor = "multi_thread")]
667    async fn test_rotate_pitch_only() {
668        let ast = PIPE.to_string()
669            + r#"
670    |> rotate(
671    pitch = 90,
672    )
673"#;
674        parse_execute(&ast).await.unwrap();
675    }
676
677    #[tokio::test(flavor = "multi_thread")]
678    async fn test_rotate_roll_only() {
679        let ast = PIPE.to_string()
680            + r#"
681    |> rotate(
682    pitch = 90,
683    )
684"#;
685        parse_execute(&ast).await.unwrap();
686    }
687
688    #[tokio::test(flavor = "multi_thread")]
689    async fn test_rotate_accepts_roll_pitch_and_yaw_outside_one_turn() {
690        let transform = rotate_transform("roll = 371.5, pitch = -371.5, yaw = 720").await;
691        let rotation = transform.rotate_rpy.expect("expected an RPY rotation transform");
692
693        assert_eq!(rotation.property.x, 371.5);
694        assert_eq!(rotation.property.y, -371.5);
695        assert_eq!(rotation.property.z, 720.0);
696    }
697
698    #[tokio::test(flavor = "multi_thread")]
699    async fn test_rotate_rejects_non_finite_angles() {
700        for (arguments, argument_name) in [
701            ("axis = [0, 0, 1], angle = 1 / 0", "angle"),
702            ("roll = 1 / 0", "roll"),
703            ("pitch = 0 / 0", "pitch"),
704            ("yaw = -1 / 0", "yaw"),
705        ] {
706            let ast = format!("{PIPE}\n    |> rotate({arguments})");
707            let err = parse_execute(&ast).await.unwrap_err();
708
709            assert!(matches!(err, crate::errors::KclError::Semantic { .. }));
710            assert_eq!(err.message(), format!("`{argument_name}` must be a finite number."));
711        }
712    }
713
714    #[tokio::test(flavor = "multi_thread")]
715    async fn test_rotate_roll_pitch_yaw_with_angle() {
716        let ast = PIPE.to_string()
717            + r#"
718    |> rotate(
719    yaw = 90,
720    pitch = 90,
721    roll = 90,
722    angle = 90,
723    )
724"#;
725        let result = parse_execute(&ast).await;
726        assert!(result.is_err());
727        assert_eq!(
728            result.unwrap_err().message(),
729            r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
730                .to_string()
731        );
732    }
733
734    #[tokio::test(flavor = "multi_thread")]
735    async fn test_translate_no_args() {
736        let ast = PIPE.to_string()
737            + r#"
738    |> translate(
739    )
740"#;
741        let result = parse_execute(&ast).await;
742        assert!(result.is_err());
743        assert_eq!(
744            result.unwrap_err().message(),
745            r#"Expected `x`, `y`, or `z` to be provided."#.to_string()
746        );
747    }
748
749    #[tokio::test(flavor = "multi_thread")]
750    async fn test_scale_no_args() {
751        let ast = PIPE.to_string()
752            + r#"
753    |> scale(
754    )
755"#;
756        let result = parse_execute(&ast).await;
757        assert!(result.is_err());
758        assert_eq!(
759            result.unwrap_err().message(),
760            r#"Expected `x`, `y`, `z` or `factor` to be provided."#.to_string()
761        );
762    }
763
764    #[tokio::test(flavor = "multi_thread")]
765    async fn delete_marks_gdt_annotation_artifact_consumed() {
766        let program = crate::Program::parse_no_errs(
767            r#"@settings(kclVersion = 2.0)
768
769annotation = gdt::note(note = "Inspect this surface")
770delete(annotation)
771"#,
772        )
773        .unwrap();
774        let ctx = crate::ExecutorContext::new_mock(None).await;
775        let outcome = ctx.run_mock(&program, &MockConfig::default()).await.unwrap();
776        ctx.close().await;
777
778        let annotations = outcome
779            .artifact_graph
780            .values()
781            .filter_map(|artifact| match artifact {
782                Artifact::GdtAnnotation(annotation) => Some(annotation),
783                _ => None,
784            })
785            .collect::<Vec<_>>();
786        assert_eq!(annotations.len(), 1);
787        assert!(annotations[0].consumed);
788    }
789
790    #[tokio::test(flavor = "multi_thread")]
791    async fn delete_marks_imported_geometry_artifact_consumed() {
792        let tmpdir = tempfile::TempDir::with_prefix("delete_imported_geometry").unwrap();
793        tokio::fs::write(tmpdir.path().join("model.obj"), "o model\n")
794            .await
795            .unwrap();
796        let program = crate::Program::parse_no_errs(
797            r#"@settings(kclVersion = 2.0)
798
799import "model.obj" as model
800delete(model)
801"#,
802        )
803        .unwrap();
804        let ctx = crate::ExecutorContext::new_mock(Some(ExecutorSettings {
805            project_directory: Some(crate::TypedPath(tmpdir.path().into())),
806            ..Default::default()
807        }))
808        .await;
809        let outcome = ctx.run_mock(&program, &MockConfig::default()).await.unwrap();
810        ctx.close().await;
811
812        let imported_geometry = outcome
813            .artifact_graph
814            .values()
815            .filter_map(|artifact| match artifact {
816                Artifact::ImportedGeometry(imported_geometry) => Some(imported_geometry),
817                _ => None,
818            })
819            .collect::<Vec<_>>();
820        assert_eq!(imported_geometry.len(), 1);
821        assert!(imported_geometry[0].consumed);
822    }
823
824    #[tokio::test(flavor = "multi_thread")]
825    async fn test_hide_pipe_solid_ok() {
826        let ast = PIPE.to_string()
827            + r#"
828    |> hide()
829"#;
830        parse_execute(&ast).await.unwrap();
831    }
832
833    #[tokio::test(flavor = "multi_thread")]
834    async fn hide_consumed_solid_reports_deprecation_warning() {
835        let code = r#"
836targetSketch = sketch(on = XY) {
837  line1 = line(start = [var -10, var -10], end = [var 10, var -10])
838  line2 = line(start = [var 10, var -10], end = [var 10, var 10])
839  line3 = line(start = [var 10, var 10], end = [var -10, var 10])
840  line4 = line(start = [var -10, var 10], end = [var -10, var -10])
841  coincident([line1.end, line2.start])
842  coincident([line2.end, line3.start])
843  coincident([line3.end, line4.start])
844  coincident([line4.end, line1.start])
845  equalLength([line1, line2, line3, line4])
846}
847
848target = extrude(region(point = [0, 0], sketch = targetSketch), length = 20)
849
850toolSketch = sketch(on = XY) {
851  line1 = line(start = [var -2, var -2], end = [var 2, var -2])
852  line2 = line(start = [var 2, var -2], end = [var 2, var 2])
853  line3 = line(start = [var 2, var 2], end = [var -2, var 2])
854  line4 = line(start = [var -2, var 2], end = [var -2, var -2])
855  coincident([line1.end, line2.start])
856  coincident([line2.end, line3.start])
857  coincident([line3.end, line4.start])
858  coincident([line4.end, line1.start])
859  equalLength([line1, line2, line3, line4])
860}
861
862tool = extrude(region(point = [0, 0], sketch = toolSketch), length = 4)
863
864result = subtract(target, tools = [tool])
865hidden = hide(target)
866"#;
867
868        let program = crate::Program::parse_no_errs(code).unwrap();
869        let ctx = crate::ExecutorContext::new_mock(None).await;
870        let outcome = ctx.run_mock(&program, &MockConfig::default()).await;
871        ctx.close().await;
872        let outcome = outcome.unwrap();
873
874        assert!(
875            outcome.issues.iter().any(|issue| {
876                issue.severity == Severity::Warning
877                    && issue.tag == Tag::Deprecated
878                    && issue
879                        .message
880                        .contains("Calling `hide` with a consumed solid is deprecated")
881                    && issue
882                        .message
883                        .contains("`target` was already consumed by a `subtract` operation")
884            }),
885            "expected hide consumed-solid deprecation warning, got: {:#?}",
886            outcome.issues
887        );
888    }
889
890    #[tokio::test(flavor = "multi_thread")]
891    async fn test_hide_helix() {
892        let ast = r#"helixPath = helix(
893  axis = Z,
894  radius = 5,
895  length = 10,
896  revolutions = 3,
897  angleStart = 360,
898  ccw = false,
899)
900
901hide(helixPath)
902"#;
903        parse_execute(ast).await.unwrap();
904    }
905
906    #[tokio::test(flavor = "multi_thread")]
907    async fn test_hide_sketch_block() {
908        let ast = r#"sketch001 = sketch(on = XY) {
909  circle001 = circle(start = [var 1.16mm, var 4.24mm], center = [var -1.81mm, var -0.5mm])
910}
911
912hide(sketch001)
913"#;
914        parse_execute(ast).await.unwrap();
915    }
916
917    #[tokio::test(flavor = "multi_thread")]
918    async fn test_hide_plane() {
919        let ast = r#"plane001 = offsetPlane(YZ, offset = 500)
920
921hide(plane001)
922"#;
923        let result = parse_execute(ast).await.unwrap();
924        let object_visible_commands = result
925            .root_module_artifact_commands()
926            .iter()
927            .filter_map(|artifact_command| match &artifact_command.command {
928                ModelingCmd::ObjectVisible(object_visible) => Some(object_visible),
929                _ => None,
930            })
931            .collect::<Vec<_>>();
932
933        assert_eq!(
934            object_visible_commands.len(),
935            1,
936            "expected exactly one ObjectVisible command, got: {:#?}",
937            result.root_module_artifact_commands()
938        );
939        assert!(
940            object_visible_commands[0].hidden,
941            "expected ObjectVisible command to hide the plane"
942        );
943    }
944
945    #[tokio::test(flavor = "multi_thread")]
946    async fn test_hide_no_objects() {
947        let ast = r#"hidden = hide()"#;
948        let result = parse_execute(ast).await;
949        assert!(result.is_err());
950        assert_eq!(
951            result.unwrap_err().message(),
952            r#"This function expects an unlabeled first parameter, but you haven't passed it one."#.to_string()
953        );
954    }
955}