kcl_lib/std/
transform.rs

1//! Standard library transforms.
2
3use anyhow::Result;
4use kcl_derive_docs::stdlib;
5use kcmc::{
6    each_cmd as mcmd,
7    length_unit::LengthUnit,
8    shared,
9    shared::{Point3d, Point4d},
10    ModelingCmd,
11};
12use kittycad_modeling_cmds as kcmc;
13
14use crate::{
15    errors::{KclError, KclErrorDetails},
16    execution::{
17        types::{PrimitiveType, RuntimeType},
18        ExecState, KclValue, SolidOrSketchOrImportedGeometry,
19    },
20    std::{args::TyF64, axis_or_reference::Axis3dOrPoint3d, Args},
21};
22
23/// Scale a solid or a sketch.
24pub async fn scale(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
25    let objects = args.get_unlabeled_kw_arg_typed(
26        "objects",
27        &RuntimeType::Union(vec![
28            RuntimeType::sketches(),
29            RuntimeType::solids(),
30            RuntimeType::imported(),
31        ]),
32        exec_state,
33    )?;
34    let scale_x: Option<TyF64> = args.get_kw_arg_opt_typed("x", &RuntimeType::count(), exec_state)?;
35    let scale_y: Option<TyF64> = args.get_kw_arg_opt_typed("y", &RuntimeType::count(), exec_state)?;
36    let scale_z: Option<TyF64> = args.get_kw_arg_opt_typed("z", &RuntimeType::count(), exec_state)?;
37    let global = args.get_kw_arg_opt("global")?;
38
39    // Ensure at least one scale value is provided.
40    if scale_x.is_none() && scale_y.is_none() && scale_z.is_none() {
41        return Err(KclError::Semantic(KclErrorDetails::new(
42            "Expected `x`, `y`, or `z` to be provided.".to_string(),
43            vec![args.source_range],
44        )));
45    }
46
47    let objects = inner_scale(
48        objects,
49        scale_x.map(|t| t.n),
50        scale_y.map(|t| t.n),
51        scale_z.map(|t| t.n),
52        global,
53        exec_state,
54        args,
55    )
56    .await?;
57    Ok(objects.into())
58}
59
60/// Scale a solid or a sketch.
61///
62/// This is really useful for resizing parts. You can create a part and then scale it to the
63/// correct size.
64///
65/// For sketches, you can use this to scale a sketch and then loft it with another sketch.
66///
67/// By default the transform is applied in local sketch axis, therefore the origin will not move.
68///
69/// If you want to apply the transform in global space, set `global` to `true`. The origin of the
70/// model will move. If the model is not centered on origin and you scale globally it will
71/// look like the model moves and gets bigger at the same time. Say you have a square
72/// `(1,1) - (1,2) - (2,2) - (2,1)` and you scale by 2 globally it will become
73/// `(2,2) - (2,4)`...etc so the origin has moved from `(1.5, 1.5)` to `(2,2)`.
74///
75/// ```no_run
76/// // Scale a pipe.
77///
78/// // Create a path for the sweep.
79/// sweepPath = startSketchOn(XZ)
80///     |> startProfile(at = [0.05, 0.05])
81///     |> line(end = [0, 7])
82///     |> tangentialArc(angle = 90, radius = 5)
83///     |> line(end = [-3, 0])
84///     |> tangentialArc(angle = -90, radius = 5)
85///     |> line(end = [0, 7])
86///
87/// // Create a hole for the pipe.
88/// pipeHole = startSketchOn(XY)
89///     |> circle(
90///         center = [0, 0],
91///         radius = 1.5,
92///     )
93///
94/// sweepSketch = startSketchOn(XY)
95///     |> circle(
96///         center = [0, 0],
97///         radius = 2,
98///         )              
99///     |> subtract2d(tool = pipeHole)
100///     |> sweep(path = sweepPath)   
101///     |> scale(
102///     z = 2.5,
103///     )
104/// ```
105///
106/// ```no_run
107/// // Scale an imported model.
108///
109/// import "tests/inputs/cube.sldprt" as cube
110///
111/// cube
112///     |> scale(
113///     y = 2.5,
114///     )
115/// ```
116///
117/// ```
118/// // Sweep two sketches along the same path.
119///
120/// sketch001 = startSketchOn(XY)
121/// rectangleSketch = startProfile(sketch001, at = [-200, 23.86])
122///     |> angledLine(angle = 0, length = 73.47, tag = $rectangleSegmentA001)
123///     |> angledLine(
124///         angle = segAng(rectangleSegmentA001) - 90,
125///         length = 50.61,
126///     )
127///     |> angledLine(
128///         angle = segAng(rectangleSegmentA001),
129///         length = -segLen(rectangleSegmentA001),
130///     )
131///     |> line(endAbsolute = [profileStartX(%), profileStartY(%)])
132///     |> close()
133///
134/// circleSketch = circle(sketch001, center = [200, -30.29], radius = 32.63)
135///
136/// sketch002 = startSketchOn(YZ)
137/// sweepPath = startProfile(sketch002, at = [0, 0])
138///     |> yLine(length = 231.81)
139///     |> tangentialArc(radius = 80, angle = -90)
140///     |> xLine(length = 384.93)
141///
142/// parts = sweep([rectangleSketch, circleSketch], path = sweepPath)
143///
144/// // Scale the sweep.
145/// scale(parts, z = 0.5)
146/// ```
147#[stdlib {
148    name = "scale",
149    feature_tree_operation = false,
150    unlabeled_first = true,
151    args = {
152        objects = {docs = "The solid, sketch, or set of solids or sketches to scale."},
153        x = {docs = "The scale factor for the x axis. Default is 1 if not provided.", include_in_snippet = true},
154        y = {docs = "The scale factor for the y axis. Default is 1 if not provided.", include_in_snippet = true},
155        z = {docs = "The scale factor for the z axis. Default is 1 if not provided.", include_in_snippet = true},
156        global = {docs = "If true, the transform is applied in global space. The origin of the model will move. By default, the transform is applied in local sketch axis, therefore the origin will not move."}
157    },
158    tags = ["transform"]
159}]
160async fn inner_scale(
161    objects: SolidOrSketchOrImportedGeometry,
162    x: Option<f64>,
163    y: Option<f64>,
164    z: Option<f64>,
165    global: Option<bool>,
166    exec_state: &mut ExecState,
167    args: Args,
168) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
169    // If we have a solid, flush the fillets and chamfers.
170    // Only transforms needs this, it is very odd, see: https://github.com/KittyCAD/modeling-app/issues/5880
171    if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
172        args.flush_batch_for_solids(exec_state, solids).await?;
173    }
174
175    let mut objects = objects.clone();
176    for object_id in objects.ids(&args.ctx).await? {
177        let id = exec_state.next_uuid();
178
179        args.batch_modeling_cmd(
180            id,
181            ModelingCmd::from(mcmd::SetObjectTransform {
182                object_id,
183                transforms: vec![shared::ComponentTransform {
184                    scale: Some(shared::TransformBy::<Point3d<f64>> {
185                        property: Point3d {
186                            x: x.unwrap_or(1.0),
187                            y: y.unwrap_or(1.0),
188                            z: z.unwrap_or(1.0),
189                        },
190                        set: false,
191                        is_local: !global.unwrap_or(false),
192                    }),
193                    translate: None,
194                    rotate_rpy: None,
195                    rotate_angle_axis: None,
196                }],
197            }),
198        )
199        .await?;
200    }
201
202    Ok(objects)
203}
204
205/// Move a solid or a sketch.
206pub async fn translate(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
207    let objects = args.get_unlabeled_kw_arg_typed(
208        "objects",
209        &RuntimeType::Union(vec![
210            RuntimeType::sketches(),
211            RuntimeType::solids(),
212            RuntimeType::imported(),
213        ]),
214        exec_state,
215    )?;
216    let translate_x: Option<TyF64> = args.get_kw_arg_opt_typed("x", &RuntimeType::length(), exec_state)?;
217    let translate_y: Option<TyF64> = args.get_kw_arg_opt_typed("y", &RuntimeType::length(), exec_state)?;
218    let translate_z: Option<TyF64> = args.get_kw_arg_opt_typed("z", &RuntimeType::length(), exec_state)?;
219    let global = args.get_kw_arg_opt("global")?;
220
221    // Ensure at least one translation value is provided.
222    if translate_x.is_none() && translate_y.is_none() && translate_z.is_none() {
223        return Err(KclError::Semantic(KclErrorDetails::new(
224            "Expected `x`, `y`, or `z` to be provided.".to_string(),
225            vec![args.source_range],
226        )));
227    }
228
229    let objects = inner_translate(objects, translate_x, translate_y, translate_z, global, exec_state, args).await?;
230    Ok(objects.into())
231}
232
233/// Move a solid or a sketch.
234///
235/// This is really useful for assembling parts together. You can create a part
236/// and then move it to the correct location.
237///
238/// Translate is really useful for sketches if you want to move a sketch
239/// and then rotate it using the `rotate` function to create a loft.
240///
241/// ```no_run
242/// // Move a pipe.
243///
244/// // Create a path for the sweep.
245/// sweepPath = startSketchOn(XZ)
246///     |> startProfile(at = [0.05, 0.05])
247///     |> line(end = [0, 7])
248///     |> tangentialArc(angle = 90, radius = 5)
249///     |> line(end = [-3, 0])
250///     |> tangentialArc(angle = -90, radius = 5)
251///     |> line(end = [0, 7])
252///
253/// // Create a hole for the pipe.
254/// pipeHole = startSketchOn(XY)
255///     |> circle(
256///         center = [0, 0],
257///         radius = 1.5,
258///     )
259///
260/// sweepSketch = startSketchOn(XY)
261///     |> circle(
262///         center = [0, 0],
263///         radius = 2,
264///         )              
265///     |> subtract2d(tool = pipeHole)
266///     |> sweep(path = sweepPath)   
267///     |> translate(
268///         x = 1.0,
269///         y = 1.0,
270///         z = 2.5,
271///     )
272/// ```
273///
274/// ```no_run
275/// // Move an imported model.
276///
277/// import "tests/inputs/cube.sldprt" as cube
278///
279/// // Circle so you actually see the move.
280/// startSketchOn(XY)
281///     |> circle(
282///         center = [-10, -10],
283///         radius = 10,
284///         )
285///     |> extrude(
286///     length = 10,
287///     )
288///
289/// cube
290///     |> translate(
291///     x = 10.0,
292///     y = 10.0,
293///     z = 2.5,
294///     )
295/// ```
296///
297/// ```
298/// // Sweep two sketches along the same path.
299///
300/// sketch001 = startSketchOn(XY)
301/// rectangleSketch = startProfile(sketch001, at = [-200, 23.86])
302///     |> angledLine(angle = 0, length = 73.47, tag = $rectangleSegmentA001)
303///     |> angledLine(
304///         angle = segAng(rectangleSegmentA001) - 90,
305///         length = 50.61,
306///     )
307///     |> angledLine(
308///         angle = segAng(rectangleSegmentA001),
309///         length = -segLen(rectangleSegmentA001),
310///     )
311///     |> line(endAbsolute = [profileStartX(%), profileStartY(%)])
312///     |> close()
313///
314/// circleSketch = circle(sketch001, center = [200, -30.29], radius = 32.63)
315///
316/// sketch002 = startSketchOn(YZ)
317/// sweepPath = startProfile(sketch002, at = [0, 0])
318///     |> yLine(length = 231.81)
319///     |> tangentialArc(radius = 80, angle = -90)
320///     |> xLine(length = 384.93)
321///
322/// parts = sweep([rectangleSketch, circleSketch], path = sweepPath)
323///
324/// // Move the sweeps.
325/// translate(parts, x = 1.0, y = 1.0, z = 2.5)
326/// ```
327///
328/// ```no_run
329/// // Move a sketch.
330///
331/// fn square(@length){
332///     l = length / 2
333///     p0 = [-l, -l]
334///     p1 = [-l, l]
335///     p2 = [l, l]
336///     p3 = [l, -l]
337///
338///     return startSketchOn(XY)
339///         |> startProfile(at = p0)
340///         |> line(endAbsolute = p1)
341///         |> line(endAbsolute = p2)
342///         |> line(endAbsolute = p3)
343///         |> close()
344/// }
345///
346/// square(10)
347///     |> translate(
348///         x = 5,
349///         y = 5,
350///     )
351///     |> extrude(
352///         length = 10,
353///     )
354/// ```
355///
356/// ```no_run
357/// // Translate and rotate a sketch to create a loft.
358/// sketch001 = startSketchOn(XY)
359///
360/// fn square() {
361///     return  startProfile(sketch001, at = [-10, 10])
362///         |> xLine(length = 20)
363///         |> yLine(length = -20)
364///         |> xLine(length = -20)
365///         |> line(endAbsolute = [profileStartX(%), profileStartY(%)])
366///         |> close()
367/// }
368///
369/// profile001 = square()
370///
371/// profile002 = square()
372///     |> translate(z = 20)
373///     |> rotate(axis = [0, 0, 1.0], angle = 45)
374///
375/// loft([profile001, profile002])
376/// ```
377#[stdlib {
378    name = "translate",
379    feature_tree_operation = false,
380    unlabeled_first = true,
381    args = {
382        objects = {docs = "The solid, sketch, or set of solids or sketches to move."},
383        x = {docs = "The amount to move the solid or sketch along the x axis. Defaults to 0 if not provided.", include_in_snippet = true},
384        y = {docs = "The amount to move the solid or sketch along the y axis. Defaults to 0 if not provided.", include_in_snippet = true},
385        z = {docs = "The amount to move the solid or sketch along the z axis. Defaults to 0 if not provided.", include_in_snippet = true},
386        global = {docs = "If true, the transform is applied in global space. The origin of the model will move. By default, the transform is applied in local sketch axis, therefore the origin will not move."}
387    },
388    tags = ["transform"]
389}]
390async fn inner_translate(
391    objects: SolidOrSketchOrImportedGeometry,
392    x: Option<TyF64>,
393    y: Option<TyF64>,
394    z: Option<TyF64>,
395    global: Option<bool>,
396    exec_state: &mut ExecState,
397    args: Args,
398) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
399    // If we have a solid, flush the fillets and chamfers.
400    // Only transforms needs this, it is very odd, see: https://github.com/KittyCAD/modeling-app/issues/5880
401    if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
402        args.flush_batch_for_solids(exec_state, solids).await?;
403    }
404
405    let mut objects = objects.clone();
406    for object_id in objects.ids(&args.ctx).await? {
407        let id = exec_state.next_uuid();
408
409        args.batch_modeling_cmd(
410            id,
411            ModelingCmd::from(mcmd::SetObjectTransform {
412                object_id,
413                transforms: vec![shared::ComponentTransform {
414                    translate: Some(shared::TransformBy::<Point3d<LengthUnit>> {
415                        property: shared::Point3d {
416                            x: LengthUnit(x.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
417                            y: LengthUnit(y.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
418                            z: LengthUnit(z.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
419                        },
420                        set: false,
421                        is_local: !global.unwrap_or(false),
422                    }),
423                    scale: None,
424                    rotate_rpy: None,
425                    rotate_angle_axis: None,
426                }],
427            }),
428        )
429        .await?;
430    }
431
432    Ok(objects)
433}
434
435/// Rotate a solid or a sketch.
436pub async fn rotate(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
437    let objects = args.get_unlabeled_kw_arg_typed(
438        "objects",
439        &RuntimeType::Union(vec![
440            RuntimeType::sketches(),
441            RuntimeType::solids(),
442            RuntimeType::imported(),
443        ]),
444        exec_state,
445    )?;
446    let roll: Option<TyF64> = args.get_kw_arg_opt_typed("roll", &RuntimeType::degrees(), exec_state)?;
447    let pitch: Option<TyF64> = args.get_kw_arg_opt_typed("pitch", &RuntimeType::degrees(), exec_state)?;
448    let yaw: Option<TyF64> = args.get_kw_arg_opt_typed("yaw", &RuntimeType::degrees(), exec_state)?;
449    let axis: Option<Axis3dOrPoint3d> = args.get_kw_arg_opt_typed(
450        "axis",
451        &RuntimeType::Union(vec![
452            RuntimeType::Primitive(PrimitiveType::Axis3d),
453            RuntimeType::point3d(),
454        ]),
455        exec_state,
456    )?;
457    let axis = axis.map(|a| a.to_point3d());
458    let angle: Option<TyF64> = args.get_kw_arg_opt_typed("angle", &RuntimeType::degrees(), exec_state)?;
459    let global = args.get_kw_arg_opt("global")?;
460
461    // Check if no rotation values are provided.
462    if roll.is_none() && pitch.is_none() && yaw.is_none() && axis.is_none() && angle.is_none() {
463        return Err(KclError::Semantic(KclErrorDetails::new(
464            "Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided.".to_string(),
465            vec![args.source_range],
466        )));
467    }
468
469    // If they give us a roll, pitch, or yaw, they must give us at least one of them.
470    if roll.is_some() || pitch.is_some() || yaw.is_some() {
471        // Ensure they didn't also provide an axis or angle.
472        if axis.is_some() || angle.is_some() {
473            return Err(KclError::Semantic(KclErrorDetails::new(
474                "Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."
475                    .to_owned(),
476                vec![args.source_range],
477            )));
478        }
479    }
480
481    // If they give us an axis or angle, they must give us both.
482    if axis.is_some() || angle.is_some() {
483        if axis.is_none() {
484            return Err(KclError::Semantic(KclErrorDetails::new(
485                "Expected `axis` to be provided when `angle` is provided.".to_string(),
486                vec![args.source_range],
487            )));
488        }
489        if angle.is_none() {
490            return Err(KclError::Semantic(KclErrorDetails::new(
491                "Expected `angle` to be provided when `axis` is provided.".to_string(),
492                vec![args.source_range],
493            )));
494        }
495
496        // Ensure they didn't also provide a roll, pitch, or yaw.
497        if roll.is_some() || pitch.is_some() || yaw.is_some() {
498            return Err(KclError::Semantic(KclErrorDetails::new(
499                "Expected `roll`, `pitch`, and `yaw` to not be provided when `axis` and `angle` are provided."
500                    .to_owned(),
501                vec![args.source_range],
502            )));
503        }
504    }
505
506    // Validate the roll, pitch, and yaw values.
507    if let Some(roll) = &roll {
508        if !(-360.0..=360.0).contains(&roll.n) {
509            return Err(KclError::Semantic(KclErrorDetails::new(
510                format!("Expected roll to be between -360 and 360, found `{}`", roll.n),
511                vec![args.source_range],
512            )));
513        }
514    }
515    if let Some(pitch) = &pitch {
516        if !(-360.0..=360.0).contains(&pitch.n) {
517            return Err(KclError::Semantic(KclErrorDetails::new(
518                format!("Expected pitch to be between -360 and 360, found `{}`", pitch.n),
519                vec![args.source_range],
520            )));
521        }
522    }
523    if let Some(yaw) = &yaw {
524        if !(-360.0..=360.0).contains(&yaw.n) {
525            return Err(KclError::Semantic(KclErrorDetails::new(
526                format!("Expected yaw to be between -360 and 360, found `{}`", yaw.n),
527                vec![args.source_range],
528            )));
529        }
530    }
531
532    // Validate the axis and angle values.
533    if let Some(angle) = &angle {
534        if !(-360.0..=360.0).contains(&angle.n) {
535            return Err(KclError::Semantic(KclErrorDetails::new(
536                format!("Expected angle to be between -360 and 360, found `{}`", angle.n),
537                vec![args.source_range],
538            )));
539        }
540    }
541
542    let objects = inner_rotate(
543        objects,
544        roll.map(|t| t.n),
545        pitch.map(|t| t.n),
546        yaw.map(|t| t.n),
547        // Don't adjust axis units since the axis must be normalized and only the direction
548        // should be significant, not the magnitude.
549        axis.map(|a| [a[0].n, a[1].n, a[2].n]),
550        angle.map(|t| t.n),
551        global,
552        exec_state,
553        args,
554    )
555    .await?;
556    Ok(objects.into())
557}
558
559/// Rotate a solid or a sketch.
560///
561/// This is really useful for assembling parts together. You can create a part
562/// and then rotate it to the correct orientation.
563///
564/// For sketches, you can use this to rotate a sketch and then loft it with another sketch.
565///
566/// ### Using Roll, Pitch, and Yaw
567///
568/// When rotating a part in 3D space, "roll," "pitch," and "yaw" refer to the
569/// three rotational axes used to describe its orientation: roll is rotation
570/// around the longitudinal axis (front-to-back), pitch is rotation around the
571/// lateral axis (wing-to-wing), and yaw is rotation around the vertical axis
572/// (up-down); essentially, it's like tilting the part on its side (roll),
573/// tipping the nose up or down (pitch), and turning it left or right (yaw).
574///
575/// So, in the context of a 3D model:
576///
577/// - **Roll**: Imagine spinning a pencil on its tip - that's a roll movement.
578///
579/// - **Pitch**: Think of a seesaw motion, where the object tilts up or down along its side axis.
580///
581/// - **Yaw**: Like turning your head left or right, this is a rotation around the vertical axis
582///
583/// ### Using an Axis and Angle
584///
585/// When rotating a part around an axis, you specify the axis of rotation and the angle of
586/// rotation.
587///
588/// ```no_run
589/// // Rotate a pipe with roll, pitch, and yaw.
590///
591/// // Create a path for the sweep.
592/// sweepPath = startSketchOn(XZ)
593///     |> startProfile(at = [0.05, 0.05])
594///     |> line(end = [0, 7])
595///     |> tangentialArc(angle = 90, radius = 5)
596///     |> line(end = [-3, 0])
597///     |> tangentialArc(angle = -90, radius = 5)
598///     |> line(end = [0, 7])
599///
600/// // Create a hole for the pipe.
601/// pipeHole = startSketchOn(XY)
602///     |> circle(
603///         center = [0, 0],
604///         radius = 1.5,
605///     )
606///
607/// sweepSketch = startSketchOn(XY)
608///     |> circle(
609///         center = [0, 0],
610///         radius = 2,
611///         )              
612///     |> subtract2d(tool = pipeHole)
613///     |> sweep(path = sweepPath)   
614///     |> rotate(
615///         roll = 10,
616///         pitch =  10,
617///         yaw = 90,
618///     )
619/// ```
620///
621/// ```no_run
622/// // Rotate a pipe with just roll.
623///
624/// // Create a path for the sweep.
625/// sweepPath = startSketchOn(XZ)
626///     |> startProfile(at = [0.05, 0.05])
627///     |> line(end = [0, 7])
628///     |> tangentialArc(angle = 90, radius = 5)
629///     |> line(end = [-3, 0])
630///     |> tangentialArc(angle = -90, radius = 5)
631///     |> line(end = [0, 7])
632///
633/// // Create a hole for the pipe.
634/// pipeHole = startSketchOn(XY)
635///     |> circle(
636///         center = [0, 0],
637///         radius = 1.5,
638///     )
639///
640/// sweepSketch = startSketchOn(XY)
641///     |> circle(
642///         center = [0, 0],
643///         radius = 2,
644///         )              
645///     |> subtract2d(tool = pipeHole)
646///     |> sweep(path = sweepPath)   
647///     |> rotate(
648///         roll = 10,
649///     )
650/// ```
651///
652/// ```no_run
653/// // Rotate a pipe about a named axis with an angle.
654///
655/// // Create a path for the sweep.
656/// sweepPath = startSketchOn(XZ)
657///     |> startProfile(at = [0.05, 0.05])
658///     |> line(end = [0, 7])
659///     |> tangentialArc(angle = 90, radius = 5)
660///     |> line(end = [-3, 0])
661///     |> tangentialArc(angle = -90, radius = 5)
662///     |> line(end = [0, 7])
663///
664/// // Create a hole for the pipe.
665/// pipeHole = startSketchOn(XY)
666///     |> circle(
667///         center = [0, 0],
668///         radius = 1.5,
669///    )
670///
671/// sweepSketch = startSketchOn(XY)
672///     |> circle(
673///         center = [0, 0],
674///         radius = 2,
675///         )              
676///     |> subtract2d(tool = pipeHole)
677///     |> sweep(path = sweepPath)   
678///     |> rotate(
679///     axis =  Z,
680///     angle = 90,
681///     )
682/// ```
683///
684/// ```no_run
685/// // Rotate an imported model.
686///
687/// import "tests/inputs/cube.sldprt" as cube
688///
689/// cube
690///     |> rotate(
691///     axis =  [0, 0, 1.0],
692///     angle = 9,
693///     )
694/// ```
695///
696/// ```no_run
697/// // Rotate a pipe about a raw axis with an angle.
698///
699/// // Create a path for the sweep.
700/// sweepPath = startSketchOn(XZ)
701///     |> startProfile(at = [0.05, 0.05])
702///     |> line(end = [0, 7])
703///     |> tangentialArc(angle = 90, radius = 5)
704///     |> line(end = [-3, 0])
705///     |> tangentialArc(angle = -90, radius = 5)
706///     |> line(end = [0, 7])
707///
708/// // Create a hole for the pipe.
709/// pipeHole = startSketchOn(XY)
710///     |> circle(
711///         center = [0, 0],
712///         radius = 1.5,
713///    )
714///
715/// sweepSketch = startSketchOn(XY)
716///     |> circle(
717///         center = [0, 0],
718///         radius = 2,
719///         )              
720///     |> subtract2d(tool = pipeHole)
721///     |> sweep(path = sweepPath)   
722///     |> rotate(
723///     axis =  [0, 0, 1.0],
724///     angle = 90,
725///     )
726/// ```
727///
728/// ```
729/// // Sweep two sketches along the same path.
730///
731/// sketch001 = startSketchOn(XY)
732/// rectangleSketch = startProfile(sketch001, at = [-200, 23.86])
733///     |> angledLine(angle = 0, length = 73.47, tag = $rectangleSegmentA001)
734///     |> angledLine(
735///         angle = segAng(rectangleSegmentA001) - 90,
736///         length = 50.61,
737///     )
738///     |> angledLine(
739///         angle = segAng(rectangleSegmentA001),
740///         length = -segLen(rectangleSegmentA001),
741///     )
742///     |> line(endAbsolute = [profileStartX(%), profileStartY(%)])
743///     |> close()
744///
745/// circleSketch = circle(sketch001, center = [200, -30.29], radius = 32.63)
746///
747/// sketch002 = startSketchOn(YZ)
748/// sweepPath = startProfile(sketch002, at = [0, 0])
749///     |> yLine(length = 231.81)
750///     |> tangentialArc(radius = 80, angle = -90)
751///     |> xLine(length = 384.93)
752///
753/// parts = sweep([rectangleSketch, circleSketch], path = sweepPath)
754///
755/// // Rotate the sweeps.
756/// rotate(parts, axis =  [0, 0, 1.0], angle = 90)
757/// ```
758///
759/// ```no_run
760/// // Translate and rotate a sketch to create a loft.
761/// sketch001 = startSketchOn(XY)
762///
763/// fn square() {
764///     return  startProfile(sketch001, at = [-10, 10])
765///         |> xLine(length = 20)
766///         |> yLine(length = -20)
767///         |> xLine(length = -20)
768///         |> line(endAbsolute = [profileStartX(%), profileStartY(%)])
769///         |> close()
770/// }
771///
772/// profile001 = square()
773///
774/// profile002 = square()
775///     |> translate(x = 0, y = 0, z = 20)
776///     |> rotate(axis = [0, 0, 1.0], angle = 45)
777///
778/// loft([profile001, profile002])
779/// ```
780#[stdlib {
781    name = "rotate",
782    feature_tree_operation = false,
783    unlabeled_first = true,
784    args = {
785        objects = {docs = "The solid, sketch, or set of solids or sketches to rotate."},
786        roll = {docs = "The roll angle in degrees. Must be between -360 and 360. Default is 0 if not given.", include_in_snippet = true},
787        pitch = {docs = "The pitch angle in degrees. Must be between -360 and 360. Default is 0 if not given.", include_in_snippet = true},
788        yaw = {docs = "The yaw angle in degrees. Must be between -360 and 360. Default is 0 if not given.", include_in_snippet = true},
789        axis = {docs = "The axis to rotate around. Must be used with `angle`.", include_in_snippet = false},
790        angle = {docs = "The angle to rotate in degrees. Must be used with `axis`. Must be between -360 and 360.", include_in_snippet = false},
791        global = {docs = "If true, the transform is applied in global space. The origin of the model will move. By default, the transform is applied in local sketch axis, therefore the origin will not move."}
792    },
793    tags = ["transform"]
794}]
795#[allow(clippy::too_many_arguments)]
796async fn inner_rotate(
797    objects: SolidOrSketchOrImportedGeometry,
798    roll: Option<f64>,
799    pitch: Option<f64>,
800    yaw: Option<f64>,
801    axis: Option<[f64; 3]>,
802    angle: Option<f64>,
803    global: Option<bool>,
804    exec_state: &mut ExecState,
805    args: Args,
806) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
807    // If we have a solid, flush the fillets and chamfers.
808    // Only transforms needs this, it is very odd, see: https://github.com/KittyCAD/modeling-app/issues/5880
809    if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
810        args.flush_batch_for_solids(exec_state, solids).await?;
811    }
812
813    let mut objects = objects.clone();
814    for object_id in objects.ids(&args.ctx).await? {
815        let id = exec_state.next_uuid();
816
817        if let (Some(axis), Some(angle)) = (&axis, angle) {
818            args.batch_modeling_cmd(
819                id,
820                ModelingCmd::from(mcmd::SetObjectTransform {
821                    object_id,
822                    transforms: vec![shared::ComponentTransform {
823                        rotate_angle_axis: Some(shared::TransformBy::<Point4d<f64>> {
824                            property: shared::Point4d {
825                                x: axis[0],
826                                y: axis[1],
827                                z: axis[2],
828                                w: angle,
829                            },
830                            set: false,
831                            is_local: !global.unwrap_or(false),
832                        }),
833                        scale: None,
834                        rotate_rpy: None,
835                        translate: None,
836                    }],
837                }),
838            )
839            .await?;
840        } else {
841            // Do roll, pitch, and yaw.
842            args.batch_modeling_cmd(
843                id,
844                ModelingCmd::from(mcmd::SetObjectTransform {
845                    object_id,
846                    transforms: vec![shared::ComponentTransform {
847                        rotate_rpy: Some(shared::TransformBy::<Point3d<f64>> {
848                            property: shared::Point3d {
849                                x: roll.unwrap_or(0.0),
850                                y: pitch.unwrap_or(0.0),
851                                z: yaw.unwrap_or(0.0),
852                            },
853                            set: false,
854                            is_local: !global.unwrap_or(false),
855                        }),
856                        scale: None,
857                        rotate_angle_axis: None,
858                        translate: None,
859                    }],
860                }),
861            )
862            .await?;
863        }
864    }
865
866    Ok(objects)
867}
868
869#[cfg(test)]
870mod tests {
871    use pretty_assertions::assert_eq;
872
873    use crate::execution::parse_execute;
874
875    const PIPE: &str = r#"sweepPath = startSketchOn(XZ)
876    |> startProfile(at = [0.05, 0.05])
877    |> line(end = [0, 7])
878    |> tangentialArc(angle = 90, radius = 5)
879    |> line(end = [-3, 0])
880    |> tangentialArc(angle = -90, radius = 5)
881    |> line(end = [0, 7])
882
883// Create a hole for the pipe.
884pipeHole = startSketchOn(XY)
885    |> circle(
886        center = [0, 0],
887        radius = 1.5,
888    )
889sweepSketch = startSketchOn(XY)
890    |> circle(
891        center = [0, 0],
892        radius = 2,
893        )              
894    |> subtract2d(tool = pipeHole)
895    |> sweep(
896        path = sweepPath,
897    )"#;
898
899    #[tokio::test(flavor = "multi_thread")]
900    async fn test_rotate_empty() {
901        let ast = PIPE.to_string()
902            + r#"
903    |> rotate()
904"#;
905        let result = parse_execute(&ast).await;
906        assert!(result.is_err());
907        assert_eq!(
908            result.unwrap_err().message(),
909            r#"Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided."#.to_string()
910        );
911    }
912
913    #[tokio::test(flavor = "multi_thread")]
914    async fn test_rotate_axis_no_angle() {
915        let ast = PIPE.to_string()
916            + r#"
917    |> rotate(
918    axis =  [0, 0, 1.0],
919    )
920"#;
921        let result = parse_execute(&ast).await;
922        assert!(result.is_err());
923        assert_eq!(
924            result.unwrap_err().message(),
925            r#"Expected `angle` to be provided when `axis` is provided."#.to_string()
926        );
927    }
928
929    #[tokio::test(flavor = "multi_thread")]
930    async fn test_rotate_angle_no_axis() {
931        let ast = PIPE.to_string()
932            + r#"
933    |> rotate(
934    angle = 90,
935    )
936"#;
937        let result = parse_execute(&ast).await;
938        assert!(result.is_err());
939        assert_eq!(
940            result.unwrap_err().message(),
941            r#"Expected `axis` to be provided when `angle` is provided."#.to_string()
942        );
943    }
944
945    #[tokio::test(flavor = "multi_thread")]
946    async fn test_rotate_angle_out_of_range() {
947        let ast = PIPE.to_string()
948            + r#"
949    |> rotate(
950    axis =  [0, 0, 1.0],
951    angle = 900,
952    )
953"#;
954        let result = parse_execute(&ast).await;
955        assert!(result.is_err());
956        assert_eq!(
957            result.unwrap_err().message(),
958            r#"Expected angle to be between -360 and 360, found `900`"#.to_string()
959        );
960    }
961
962    #[tokio::test(flavor = "multi_thread")]
963    async fn test_rotate_angle_axis_yaw() {
964        let ast = PIPE.to_string()
965            + r#"
966    |> rotate(
967    axis =  [0, 0, 1.0],
968    angle = 90,
969    yaw = 90,
970   ) 
971"#;
972        let result = parse_execute(&ast).await;
973        assert!(result.is_err());
974        assert_eq!(
975            result.unwrap_err().message(),
976            r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
977                .to_string()
978        );
979    }
980
981    #[tokio::test(flavor = "multi_thread")]
982    async fn test_rotate_yaw_only() {
983        let ast = PIPE.to_string()
984            + r#"
985    |> rotate(
986    yaw = 90,
987    )
988"#;
989        parse_execute(&ast).await.unwrap();
990    }
991
992    #[tokio::test(flavor = "multi_thread")]
993    async fn test_rotate_pitch_only() {
994        let ast = PIPE.to_string()
995            + r#"
996    |> rotate(
997    pitch = 90,
998    )
999"#;
1000        parse_execute(&ast).await.unwrap();
1001    }
1002
1003    #[tokio::test(flavor = "multi_thread")]
1004    async fn test_rotate_roll_only() {
1005        let ast = PIPE.to_string()
1006            + r#"
1007    |> rotate(
1008    pitch = 90,
1009    )
1010"#;
1011        parse_execute(&ast).await.unwrap();
1012    }
1013
1014    #[tokio::test(flavor = "multi_thread")]
1015    async fn test_rotate_yaw_out_of_range() {
1016        let ast = PIPE.to_string()
1017            + r#"
1018    |> rotate(
1019    yaw = 900,
1020    pitch = 90,
1021    roll = 90,
1022    )
1023"#;
1024        let result = parse_execute(&ast).await;
1025        assert!(result.is_err());
1026        assert_eq!(
1027            result.unwrap_err().message(),
1028            r#"Expected yaw to be between -360 and 360, found `900`"#.to_string()
1029        );
1030    }
1031
1032    #[tokio::test(flavor = "multi_thread")]
1033    async fn test_rotate_roll_out_of_range() {
1034        let ast = PIPE.to_string()
1035            + r#"
1036    |> rotate(
1037    yaw = 90,
1038    pitch = 90,
1039    roll = 900,
1040    )
1041"#;
1042        let result = parse_execute(&ast).await;
1043        assert!(result.is_err());
1044        assert_eq!(
1045            result.unwrap_err().message(),
1046            r#"Expected roll to be between -360 and 360, found `900`"#.to_string()
1047        );
1048    }
1049
1050    #[tokio::test(flavor = "multi_thread")]
1051    async fn test_rotate_pitch_out_of_range() {
1052        let ast = PIPE.to_string()
1053            + r#"
1054    |> rotate(
1055    yaw = 90,
1056    pitch = 900,
1057    roll = 90,
1058    )
1059"#;
1060        let result = parse_execute(&ast).await;
1061        assert!(result.is_err());
1062        assert_eq!(
1063            result.unwrap_err().message(),
1064            r#"Expected pitch to be between -360 and 360, found `900`"#.to_string()
1065        );
1066    }
1067
1068    #[tokio::test(flavor = "multi_thread")]
1069    async fn test_rotate_roll_pitch_yaw_with_angle() {
1070        let ast = PIPE.to_string()
1071            + r#"
1072    |> rotate(
1073    yaw = 90,
1074    pitch = 90,
1075    roll = 90,
1076    angle = 90,
1077    )
1078"#;
1079        let result = parse_execute(&ast).await;
1080        assert!(result.is_err());
1081        assert_eq!(
1082            result.unwrap_err().message(),
1083            r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
1084                .to_string()
1085        );
1086    }
1087
1088    #[tokio::test(flavor = "multi_thread")]
1089    async fn test_translate_no_args() {
1090        let ast = PIPE.to_string()
1091            + r#"
1092    |> translate(
1093    )
1094"#;
1095        let result = parse_execute(&ast).await;
1096        assert!(result.is_err());
1097        assert_eq!(
1098            result.unwrap_err().message(),
1099            r#"Expected `x`, `y`, or `z` to be provided."#.to_string()
1100        );
1101    }
1102
1103    #[tokio::test(flavor = "multi_thread")]
1104    async fn test_scale_no_args() {
1105        let ast = PIPE.to_string()
1106            + r#"
1107    |> scale(
1108    )
1109"#;
1110        let result = parse_execute(&ast).await;
1111        assert!(result.is_err());
1112        assert_eq!(
1113            result.unwrap_err().message(),
1114            r#"Expected `x`, `y`, or `z` to be provided."#.to_string()
1115        );
1116    }
1117}