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::new(
40 "Expected `x`, `y`, or `z` to be provided.".to_string(),
41 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::new(
223 "Expected `x`, `y`, or `z` to be provided.".to_string(),
224 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::new(
456 "Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided.".to_string(),
457 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::new(
466 "Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."
467 .to_owned(),
468 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::new(
477 "Expected `axis` to be provided when `angle` is provided.".to_string(),
478 vec![args.source_range],
479 )));
480 }
481 if angle.is_none() {
482 return Err(KclError::Semantic(KclErrorDetails::new(
483 "Expected `angle` to be provided when `axis` is provided.".to_string(),
484 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::new(
491 "Expected `roll`, `pitch`, and `yaw` to not be provided when `axis` and `angle` are provided."
492 .to_owned(),
493 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::new(
502 format!("Expected roll to be between -360 and 360, found `{}`", roll.n),
503 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::new(
510 format!("Expected pitch to be between -360 and 360, found `{}`", pitch.n),
511 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::new(
518 format!("Expected yaw to be between -360 and 360, found `{}`", yaw.n),
519 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::new(
528 format!("Expected angle to be between -360 and 360, found `{}`", angle.n),
529 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}