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