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}