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        // The origin is a point in space, so the engine needs it in mm.
352        origin.map(|a| [a[0].to_mm(), a[1].to_mm(), a[2].to_mm()]),
353        angle.map(|t| t.n),
354        global,
355        exec_state,
356        args,
357    )
358    .await?;
359    Ok(objects.into())
360}
361
362#[allow(clippy::too_many_arguments)]
363async fn inner_rotate(
364    objects: SolidOrSketchOrImportedGeometry,
365    roll: Option<f64>,
366    pitch: Option<f64>,
367    yaw: Option<f64>,
368    axis: Option<[f64; 3]>,
369    origin: Option<[f64; 3]>,
370    angle: Option<f64>,
371    global: Option<bool>,
372    exec_state: &mut ExecState,
373    args: Args,
374) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
375    // If we have a solid, flush the fillets and chamfers.
376    // Only transforms needs this, it is very odd, see: https://github.com/KittyCAD/modeling-app/issues/5880
377    if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
378        exec_state
379            .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), solids)
380            .await?;
381    }
382
383    let origin = if let Some(origin) = origin {
384        OriginType::Custom {
385            origin: shared::Point3d {
386                x: origin[0],
387                y: origin[1],
388                z: origin[2],
389            },
390        }
391    } else if global.unwrap_or(false) {
392        OriginType::Global
393    } else {
394        OriginType::Local
395    };
396
397    let mut objects = objects.clone();
398    for object_id in objects.ids(&args.ctx).await? {
399        if let (Some(axis), Some(angle)) = (&axis, angle) {
400            let transform = shared::ComponentTransform::builder()
401                .rotate_angle_axis(transform_by(
402                    shared::Point4d {
403                        x: axis[0],
404                        y: axis[1],
405                        z: axis[2],
406                        w: angle,
407                    },
408                    false,
409                    origin,
410                ))
411                .build();
412            let transforms = vec![transform];
413            exec_state
414                .batch_modeling_cmd(
415                    ModelingCmdMeta::from_args(exec_state, &args),
416                    ModelingCmd::from(
417                        mcmd::SetObjectTransform::builder()
418                            .object_id(object_id)
419                            .transforms(transforms)
420                            .build(),
421                    ),
422                )
423                .await?;
424        } else {
425            // Do roll, pitch, and yaw.
426            let transform = shared::ComponentTransform::builder()
427                .rotate_rpy(transform_by(
428                    shared::Point3d {
429                        x: roll.unwrap_or(0.0),
430                        y: pitch.unwrap_or(0.0),
431                        z: yaw.unwrap_or(0.0),
432                    },
433                    false,
434                    origin,
435                ))
436                .build();
437            let transforms = vec![transform];
438            exec_state
439                .batch_modeling_cmd(
440                    ModelingCmdMeta::from_args(exec_state, &args),
441                    ModelingCmd::from(
442                        mcmd::SetObjectTransform::builder()
443                            .object_id(object_id)
444                            .transforms(transforms)
445                            .build(),
446                    ),
447                )
448                .await?;
449        }
450    }
451
452    Ok(objects)
453}
454
455/// Hide solids, planes, sketches, helices, or imported objects.
456pub async fn hide(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
457    let objects = args.get_unlabeled_kw_arg(
458        "objects",
459        &RuntimeType::Union(vec![
460            RuntimeType::sketches(),
461            RuntimeType::solids(),
462            RuntimeType::planes(),
463            RuntimeType::helices(),
464            RuntimeType::imported(),
465            RuntimeType::gdts(),
466        ]),
467        exec_state,
468    )?;
469
470    let objects = hide_inner(objects, true, exec_state, args).await?;
471    Ok(objects.into())
472}
473
474async fn hide_inner(
475    mut objects: HideableGeometry,
476    hidden: bool,
477    exec_state: &mut ExecState,
478    args: Args,
479) -> Result<HideableGeometry, KclError> {
480    for object_id in objects.ids(&args.ctx).await? {
481        exec_state
482            .batch_modeling_cmd(
483                ModelingCmdMeta::from_args(exec_state, &args),
484                ModelingCmd::from(
485                    mcmd::ObjectVisible::builder()
486                        .object_id(object_id)
487                        .hidden(hidden)
488                        .build(),
489                ),
490            )
491            .await?;
492    }
493
494    Ok(objects)
495}
496
497/// Delete solids, sketches, helices, imported objects, or GD&T annotations.
498pub async fn delete(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
499    let objects = args.get_unlabeled_kw_arg(
500        "objects",
501        &RuntimeType::Union(vec![
502            RuntimeType::sketches(),
503            RuntimeType::solids(),
504            RuntimeType::helices(),
505            RuntimeType::imported(),
506            RuntimeType::gdts(),
507        ]),
508        exec_state,
509    )?;
510
511    delete_inner(objects, exec_state, args).await.map(|()| KclValue::none())
512}
513
514async fn delete_inner(mut objects: HideableGeometry, exec_state: &mut ExecState, args: Args) -> Result<(), KclError> {
515    let ids = objects.ids(&args.ctx).await?.into_iter().collect();
516    exec_state
517        .batch_modeling_cmd(
518            ModelingCmdMeta::from_args(exec_state, &args),
519            ModelingCmd::from(mcmd::RemoveSceneObjects::builder().object_ids(ids).build()),
520        )
521        .await
522}
523
524#[cfg(test)]
525mod tests {
526    use kittycad_modeling_cmds::ModelingCmd;
527    use kittycad_modeling_cmds::shared::ComponentTransform;
528    use kittycad_modeling_cmds::shared::OriginType;
529    use pretty_assertions::assert_eq;
530
531    use crate::errors::Severity;
532    use crate::errors::Tag;
533    use crate::execution::Artifact;
534    use crate::execution::ExecutorSettings;
535    use crate::execution::MockConfig;
536    use crate::execution::parse_execute;
537
538    const PIPE: &str = r#"sweepPath = startSketchOn(XZ)
539    |> startProfile(at = [0.05, 0.05])
540    |> line(end = [0, 7])
541    |> tangentialArc(angle = 90, radius = 5)
542    |> line(end = [-3, 0])
543    |> tangentialArc(angle = -90, radius = 5)
544    |> line(end = [0, 7])
545
546// Create a hole for the pipe.
547pipeHole = startSketchOn(XY)
548    |> circle(
549        center = [0, 0],
550        radius = 1.5,
551    )
552sweepSketch = startSketchOn(XY)
553    |> circle(
554        center = [0, 0],
555        radius = 2,
556        )              
557    |> subtract2d(tool = pipeHole)
558    |> sweep(
559        path = sweepPath,
560    )"#;
561
562    async fn rotate_transform(arguments: &str) -> ComponentTransform {
563        rotate_transform_with_settings("", arguments).await
564    }
565
566    /// Like `rotate_transform`, but with `settings` (e.g. `@settings(...)`) at
567    /// the top of the file.
568    async fn rotate_transform_with_settings(settings: &str, arguments: &str) -> ComponentTransform {
569        let ast = format!("{settings}{PIPE}\n    |> rotate({arguments})");
570        let result = parse_execute(&ast).await.unwrap();
571
572        result
573            .root_module_artifact_commands()
574            .iter()
575            .find_map(|artifact_command| match &artifact_command.command {
576                ModelingCmd::SetObjectTransform(command) => command
577                    .transforms
578                    .iter()
579                    .find(|transform| transform.rotate_angle_axis.is_some() || transform.rotate_rpy.is_some()),
580                _ => None,
581            })
582            .cloned()
583            .expect("expected rotate() to dispatch a rotation transform")
584    }
585
586    #[tokio::test(flavor = "multi_thread")]
587    async fn test_rotate_empty() {
588        let ast = PIPE.to_string()
589            + r#"
590    |> rotate()
591"#;
592        let result = parse_execute(&ast).await;
593        assert!(result.is_err());
594        assert_eq!(
595            result.unwrap_err().message(),
596            r#"Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided."#.to_string()
597        );
598    }
599
600    #[tokio::test(flavor = "multi_thread")]
601    async fn test_rotate_axis_no_angle() {
602        let ast = PIPE.to_string()
603            + r#"
604    |> rotate(
605    axis =  [0, 0, 1.0],
606    )
607"#;
608        let result = parse_execute(&ast).await;
609        assert!(result.is_err());
610        assert_eq!(
611            result.unwrap_err().message(),
612            r#"Expected `angle` to be provided when `axis` is provided."#.to_string()
613        );
614    }
615
616    #[tokio::test(flavor = "multi_thread")]
617    async fn test_rotate_angle_no_axis() {
618        let ast = PIPE.to_string()
619            + r#"
620    |> rotate(
621    angle = 90,
622    )
623"#;
624        let result = parse_execute(&ast).await;
625        assert!(result.is_err());
626        assert_eq!(
627            result.unwrap_err().message(),
628            r#"Expected `axis` to be provided when `angle` is provided."#.to_string()
629        );
630    }
631
632    #[tokio::test(flavor = "multi_thread")]
633    async fn test_rotate_accepts_axis_angles_outside_one_turn() {
634        for (input, expected) in [("371.5", 371.5), ("-371.5", -371.5), ("720", 720.0)] {
635            let transform = rotate_transform(&format!("axis = [0, 0, 1], angle = {input}")).await;
636            let rotation = transform
637                .rotate_angle_axis
638                .expect("expected an axis-angle rotation transform");
639
640            assert_eq!(rotation.property.w, expected, "input angle: {input}");
641        }
642    }
643
644    #[tokio::test(flavor = "multi_thread")]
645    async fn test_rotate_converts_custom_axis_origin_to_mm() {
646        // https://github.com/KittyCAD/modeling-app/issues/14208
647        // The axis `origin` is a point in space, so like `translate` it must
648        // reach the engine in mm. The axis `direction` only gives a direction,
649        // so it is sent as written.
650        let mm_file = "@settings(defaultLengthUnit = mm)\n";
651        let in_file = "@settings(defaultLengthUnit = in)\n";
652        for (settings, origin, expected) in [
653            (mm_file, "[1in, 2in, -3in]", [25.4, 50.8, -76.2]),
654            (mm_file, "[1cm, 2, 0]", [10.0, 2.0, 0.0]),
655            (mm_file, "[25.4, 50.8, -76.2]", [25.4, 50.8, -76.2]),
656            (in_file, "[1, 2, -3]", [25.4, 50.8, -76.2]),
657            (in_file, "[25.4mm, 50.8mm, -76.2mm]", [25.4, 50.8, -76.2]),
658        ] {
659            let arguments = format!("axis = {{ direction = [0, 0, 2], origin = {origin} }}, angle = 90deg");
660            let transform = rotate_transform_with_settings(settings, &arguments).await;
661            let rotation = transform
662                .rotate_angle_axis
663                .expect("expected an axis-angle rotation transform");
664
665            let OriginType::Custom { origin: actual } = rotation.origin else {
666                panic!("expected a custom origin, got {:?}", rotation.origin);
667            };
668            for (actual, expected) in [actual.x, actual.y, actual.z].into_iter().zip(expected) {
669                assert!(
670                    (actual - expected).abs() < 1e-9,
671                    "{settings}origin = {origin}: expected {expected:?} mm, got {actual:?}"
672                );
673            }
674            assert_eq!(
675                [rotation.property.x, rotation.property.y, rotation.property.z],
676                [0.0, 0.0, 2.0],
677                "{settings}origin = {origin}"
678            );
679        }
680    }
681
682    #[tokio::test(flavor = "multi_thread")]
683    async fn test_rotate_angle_axis_yaw() {
684        let ast = PIPE.to_string()
685            + r#"
686    |> rotate(
687    axis =  [0, 0, 1.0],
688    angle = 90,
689    yaw = 90,
690   ) 
691"#;
692        let result = parse_execute(&ast).await;
693        assert!(result.is_err());
694        assert_eq!(
695            result.unwrap_err().message(),
696            r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
697                .to_string()
698        );
699    }
700
701    #[tokio::test(flavor = "multi_thread")]
702    async fn test_rotate_yaw_only() {
703        let ast = PIPE.to_string()
704            + r#"
705    |> rotate(
706    yaw = 90,
707    )
708"#;
709        parse_execute(&ast).await.unwrap();
710    }
711
712    #[tokio::test(flavor = "multi_thread")]
713    async fn test_rotate_pitch_only() {
714        let ast = PIPE.to_string()
715            + r#"
716    |> rotate(
717    pitch = 90,
718    )
719"#;
720        parse_execute(&ast).await.unwrap();
721    }
722
723    #[tokio::test(flavor = "multi_thread")]
724    async fn test_rotate_roll_only() {
725        let ast = PIPE.to_string()
726            + r#"
727    |> rotate(
728    pitch = 90,
729    )
730"#;
731        parse_execute(&ast).await.unwrap();
732    }
733
734    #[tokio::test(flavor = "multi_thread")]
735    async fn test_rotate_accepts_roll_pitch_and_yaw_outside_one_turn() {
736        let transform = rotate_transform("roll = 371.5, pitch = -371.5, yaw = 720").await;
737        let rotation = transform.rotate_rpy.expect("expected an RPY rotation transform");
738
739        assert_eq!(rotation.property.x, 371.5);
740        assert_eq!(rotation.property.y, -371.5);
741        assert_eq!(rotation.property.z, 720.0);
742    }
743
744    #[tokio::test(flavor = "multi_thread")]
745    async fn test_rotate_rejects_non_finite_angles() {
746        for (arguments, argument_name) in [
747            ("axis = [0, 0, 1], angle = 1 / 0", "angle"),
748            ("roll = 1 / 0", "roll"),
749            ("pitch = 0 / 0", "pitch"),
750            ("yaw = -1 / 0", "yaw"),
751        ] {
752            let ast = format!("{PIPE}\n    |> rotate({arguments})");
753            let err = parse_execute(&ast).await.unwrap_err();
754
755            assert!(matches!(err, crate::errors::KclError::Semantic { .. }));
756            assert_eq!(err.message(), format!("`{argument_name}` must be a finite number."));
757        }
758    }
759
760    #[tokio::test(flavor = "multi_thread")]
761    async fn test_rotate_roll_pitch_yaw_with_angle() {
762        let ast = PIPE.to_string()
763            + r#"
764    |> rotate(
765    yaw = 90,
766    pitch = 90,
767    roll = 90,
768    angle = 90,
769    )
770"#;
771        let result = parse_execute(&ast).await;
772        assert!(result.is_err());
773        assert_eq!(
774            result.unwrap_err().message(),
775            r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
776                .to_string()
777        );
778    }
779
780    #[tokio::test(flavor = "multi_thread")]
781    async fn test_translate_no_args() {
782        let ast = PIPE.to_string()
783            + r#"
784    |> translate(
785    )
786"#;
787        let result = parse_execute(&ast).await;
788        assert!(result.is_err());
789        assert_eq!(
790            result.unwrap_err().message(),
791            r#"Expected `x`, `y`, or `z` to be provided."#.to_string()
792        );
793    }
794
795    #[tokio::test(flavor = "multi_thread")]
796    async fn test_scale_no_args() {
797        let ast = PIPE.to_string()
798            + r#"
799    |> scale(
800    )
801"#;
802        let result = parse_execute(&ast).await;
803        assert!(result.is_err());
804        assert_eq!(
805            result.unwrap_err().message(),
806            r#"Expected `x`, `y`, `z` or `factor` to be provided."#.to_string()
807        );
808    }
809
810    #[tokio::test(flavor = "multi_thread")]
811    async fn delete_marks_gdt_annotation_artifact_consumed() {
812        let program = crate::Program::parse_no_errs(
813            r#"@settings(kclVersion = 2.0)
814
815annotation = gdt::note(note = "Inspect this surface")
816delete(annotation)
817"#,
818        )
819        .unwrap();
820        let ctx = crate::ExecutorContext::new_mock(None).await;
821        let outcome = ctx.run_mock(&program, &MockConfig::default()).await.unwrap();
822        ctx.close().await;
823
824        let annotations = outcome
825            .artifact_graph
826            .values()
827            .filter_map(|artifact| match artifact {
828                Artifact::GdtAnnotation(annotation) => Some(annotation),
829                _ => None,
830            })
831            .collect::<Vec<_>>();
832        assert_eq!(annotations.len(), 1);
833        assert!(annotations[0].consumed);
834    }
835
836    #[tokio::test(flavor = "multi_thread")]
837    async fn delete_marks_imported_geometry_artifact_consumed() {
838        let tmpdir = tempfile::TempDir::with_prefix("delete_imported_geometry").unwrap();
839        tokio::fs::write(tmpdir.path().join("model.obj"), "o model\n")
840            .await
841            .unwrap();
842        let program = crate::Program::parse_no_errs(
843            r#"@settings(kclVersion = 2.0)
844
845import "model.obj" as model
846delete(model)
847"#,
848        )
849        .unwrap();
850        let ctx = crate::ExecutorContext::new_mock(Some(ExecutorSettings {
851            project_directory: Some(crate::TypedPath(tmpdir.path().into())),
852            ..Default::default()
853        }))
854        .await;
855        let outcome = ctx.run_mock(&program, &MockConfig::default()).await.unwrap();
856        ctx.close().await;
857
858        let imported_geometry = outcome
859            .artifact_graph
860            .values()
861            .filter_map(|artifact| match artifact {
862                Artifact::ImportedGeometry(imported_geometry) => Some(imported_geometry),
863                _ => None,
864            })
865            .collect::<Vec<_>>();
866        assert_eq!(imported_geometry.len(), 1);
867        assert!(imported_geometry[0].consumed);
868    }
869
870    #[tokio::test(flavor = "multi_thread")]
871    async fn test_hide_pipe_solid_ok() {
872        let ast = PIPE.to_string()
873            + r#"
874    |> hide()
875"#;
876        parse_execute(&ast).await.unwrap();
877    }
878
879    #[tokio::test(flavor = "multi_thread")]
880    async fn hide_consumed_solid_reports_deprecation_warning() {
881        let code = r#"
882targetSketch = sketch(on = XY) {
883  line1 = line(start = [var -10, var -10], end = [var 10, var -10])
884  line2 = line(start = [var 10, var -10], end = [var 10, var 10])
885  line3 = line(start = [var 10, var 10], end = [var -10, var 10])
886  line4 = line(start = [var -10, var 10], end = [var -10, var -10])
887  coincident([line1.end, line2.start])
888  coincident([line2.end, line3.start])
889  coincident([line3.end, line4.start])
890  coincident([line4.end, line1.start])
891  equalLength([line1, line2, line3, line4])
892}
893
894target = extrude(region(point = [0, 0], sketch = targetSketch), length = 20)
895
896toolSketch = sketch(on = XY) {
897  line1 = line(start = [var -2, var -2], end = [var 2, var -2])
898  line2 = line(start = [var 2, var -2], end = [var 2, var 2])
899  line3 = line(start = [var 2, var 2], end = [var -2, var 2])
900  line4 = line(start = [var -2, var 2], end = [var -2, var -2])
901  coincident([line1.end, line2.start])
902  coincident([line2.end, line3.start])
903  coincident([line3.end, line4.start])
904  coincident([line4.end, line1.start])
905  equalLength([line1, line2, line3, line4])
906}
907
908tool = extrude(region(point = [0, 0], sketch = toolSketch), length = 4)
909
910result = subtract(target, tools = [tool])
911hidden = hide(target)
912"#;
913
914        let program = crate::Program::parse_no_errs(code).unwrap();
915        let ctx = crate::ExecutorContext::new_mock(None).await;
916        let outcome = ctx.run_mock(&program, &MockConfig::default()).await;
917        ctx.close().await;
918        let outcome = outcome.unwrap();
919
920        assert!(
921            outcome.issues.iter().any(|issue| {
922                issue.severity == Severity::Warning
923                    && issue.tag == Tag::Deprecated
924                    && issue
925                        .message
926                        .contains("Calling `hide` with a consumed solid is deprecated")
927                    && issue
928                        .message
929                        .contains("`target` was already consumed by a `subtract` operation")
930            }),
931            "expected hide consumed-solid deprecation warning, got: {:#?}",
932            outcome.issues
933        );
934    }
935
936    #[tokio::test(flavor = "multi_thread")]
937    async fn test_hide_helix() {
938        let ast = r#"helixPath = helix(
939  axis = Z,
940  radius = 5,
941  length = 10,
942  revolutions = 3,
943  angleStart = 360,
944  ccw = false,
945)
946
947hide(helixPath)
948"#;
949        parse_execute(ast).await.unwrap();
950    }
951
952    #[tokio::test(flavor = "multi_thread")]
953    async fn test_hide_sketch_block() {
954        let ast = r#"sketch001 = sketch(on = XY) {
955  circle001 = circle(start = [var 1.16mm, var 4.24mm], center = [var -1.81mm, var -0.5mm])
956}
957
958hide(sketch001)
959"#;
960        parse_execute(ast).await.unwrap();
961    }
962
963    #[tokio::test(flavor = "multi_thread")]
964    async fn test_hide_plane() {
965        let ast = r#"plane001 = offsetPlane(YZ, offset = 500)
966
967hide(plane001)
968"#;
969        let result = parse_execute(ast).await.unwrap();
970        let object_visible_commands = result
971            .root_module_artifact_commands()
972            .iter()
973            .filter_map(|artifact_command| match &artifact_command.command {
974                ModelingCmd::ObjectVisible(object_visible) => Some(object_visible),
975                _ => None,
976            })
977            .collect::<Vec<_>>();
978
979        assert_eq!(
980            object_visible_commands.len(),
981            1,
982            "expected exactly one ObjectVisible command, got: {:#?}",
983            result.root_module_artifact_commands()
984        );
985        assert!(
986            object_visible_commands[0].hidden,
987            "expected ObjectVisible command to hide the plane"
988        );
989    }
990
991    #[tokio::test(flavor = "multi_thread")]
992    async fn test_hide_no_objects() {
993        let ast = r#"hidden = hide()"#;
994        let result = parse_execute(ast).await;
995        assert!(result.is_err());
996        assert_eq!(
997            result.unwrap_err().message(),
998            r#"This function expects an unlabeled first parameter, but you haven't passed it one."#.to_string()
999        );
1000    }
1001}