1use anyhow::Result;
4use kcmc::{
5 ModelingCmd, each_cmd as mcmd,
6 length_unit::LengthUnit,
7 shared,
8 shared::{OriginType, Point3d},
9};
10use kittycad_modeling_cmds as kcmc;
11
12use crate::{
13 errors::{KclError, KclErrorDetails},
14 execution::{
15 ExecState, KclValue, ModelingCmdMeta, SolidOrSketchOrImportedGeometry,
16 types::{PrimitiveType, RuntimeType},
17 },
18 std::{Args, args::TyF64, axis_or_reference::Axis3dOrPoint3d},
19};
20
21fn transform_by<T>(property: T, set: bool, is_local: bool, origin: Option<OriginType>) -> shared::TransformBy<T> {
22 shared::TransformBy {
23 property,
24 set,
25 #[expect(deprecated)]
26 is_local,
27 origin,
28 }
29}
30
31pub async fn scale(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
33 let objects = args.get_unlabeled_kw_arg(
34 "objects",
35 &RuntimeType::Union(vec![
36 RuntimeType::sketches(),
37 RuntimeType::solids(),
38 RuntimeType::imported(),
39 ]),
40 exec_state,
41 )?;
42 let scale_x: Option<TyF64> = args.get_kw_arg_opt("x", &RuntimeType::count(), exec_state)?;
43 let scale_y: Option<TyF64> = args.get_kw_arg_opt("y", &RuntimeType::count(), exec_state)?;
44 let scale_z: Option<TyF64> = args.get_kw_arg_opt("z", &RuntimeType::count(), exec_state)?;
45 let factor: Option<TyF64> = args.get_kw_arg_opt("factor", &RuntimeType::count(), exec_state)?;
46 let (scale_x, scale_y, scale_z) = match (scale_x, scale_y, scale_z, factor) {
47 (None, None, None, Some(factor)) => (Some(factor.clone()), Some(factor.clone()), Some(factor)),
48 (None, None, None, None) => {
50 return Err(KclError::new_semantic(KclErrorDetails::new(
51 "Expected `x`, `y`, `z` or `factor` to be provided.".to_string(),
52 vec![args.source_range],
53 )));
54 }
55 (x, y, z, None) => (x, y, z),
56 _ => {
57 return Err(KclError::new_semantic(KclErrorDetails::new(
58 "If you give `factor` then you cannot use `x`, `y`, or `z`".to_string(),
59 vec![args.source_range],
60 )));
61 }
62 };
63 let global = args.get_kw_arg_opt("global", &RuntimeType::bool(), exec_state)?;
64
65 let objects = inner_scale(
66 objects,
67 scale_x.map(|t| t.n),
68 scale_y.map(|t| t.n),
69 scale_z.map(|t| t.n),
70 global,
71 exec_state,
72 args,
73 )
74 .await?;
75 Ok(objects.into())
76}
77
78async fn inner_scale(
79 objects: SolidOrSketchOrImportedGeometry,
80 x: Option<f64>,
81 y: Option<f64>,
82 z: Option<f64>,
83 global: Option<bool>,
84 exec_state: &mut ExecState,
85 args: Args,
86) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
87 if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
90 exec_state
91 .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), solids)
92 .await?;
93 }
94
95 let is_global = global.unwrap_or(false);
96 let origin = if is_global {
97 Some(OriginType::Global)
98 } else {
99 Some(OriginType::Local)
100 };
101
102 let mut objects = objects.clone();
103 for object_id in objects.ids(&args.ctx).await? {
104 exec_state
105 .batch_modeling_cmd(
106 ModelingCmdMeta::from_args(exec_state, &args),
107 ModelingCmd::from(mcmd::SetObjectTransform {
108 object_id,
109 transforms: vec![shared::ComponentTransform {
110 scale: Some(transform_by(
111 Point3d {
112 x: x.unwrap_or(1.0),
113 y: y.unwrap_or(1.0),
114 z: z.unwrap_or(1.0),
115 },
116 false,
117 !is_global,
118 origin,
119 )),
120 translate: None,
121 rotate_rpy: None,
122 rotate_angle_axis: None,
123 }],
124 }),
125 )
126 .await?;
127 }
128
129 Ok(objects)
130}
131
132pub async fn translate(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
134 let objects = args.get_unlabeled_kw_arg(
135 "objects",
136 &RuntimeType::Union(vec![
137 RuntimeType::sketches(),
138 RuntimeType::solids(),
139 RuntimeType::imported(),
140 ]),
141 exec_state,
142 )?;
143 let translate_x: Option<TyF64> = args.get_kw_arg_opt("x", &RuntimeType::length(), exec_state)?;
144 let translate_y: Option<TyF64> = args.get_kw_arg_opt("y", &RuntimeType::length(), exec_state)?;
145 let translate_z: Option<TyF64> = args.get_kw_arg_opt("z", &RuntimeType::length(), exec_state)?;
146 let xyz: Option<[TyF64; 3]> = args.get_kw_arg_opt("xyz", &RuntimeType::point3d(), exec_state)?;
147 let global = args.get_kw_arg_opt("global", &RuntimeType::bool(), exec_state)?;
148
149 let objects = inner_translate(
150 objects,
151 xyz,
152 translate_x,
153 translate_y,
154 translate_z,
155 global,
156 exec_state,
157 args,
158 )
159 .await?;
160 Ok(objects.into())
161}
162
163#[allow(clippy::too_many_arguments)]
164async fn inner_translate(
165 objects: SolidOrSketchOrImportedGeometry,
166 xyz: Option<[TyF64; 3]>,
167 x: Option<TyF64>,
168 y: Option<TyF64>,
169 z: Option<TyF64>,
170 global: Option<bool>,
171 exec_state: &mut ExecState,
172 args: Args,
173) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
174 let (x, y, z) = match (xyz, x, y, z) {
175 (None, None, None, None) => {
176 return Err(KclError::new_semantic(KclErrorDetails::new(
177 "Expected `x`, `y`, or `z` to be provided.".to_string(),
178 vec![args.source_range],
179 )));
180 }
181 (Some(xyz), None, None, None) => {
182 let [x, y, z] = xyz;
183 (Some(x), Some(y), Some(z))
184 }
185 (None, x, y, z) => (x, y, z),
186 (Some(_), _, _, _) => {
187 return Err(KclError::new_semantic(KclErrorDetails::new(
188 "If you provide all 3 distances via the `xyz` arg, you cannot provide them separately via the `x`, `y` or `z` args."
189 .to_string(),
190 vec![args.source_range],
191 )));
192 }
193 };
194 if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
197 exec_state
198 .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), solids)
199 .await?;
200 }
201
202 let is_global = global.unwrap_or(false);
203 let origin = if is_global {
204 Some(OriginType::Global)
205 } else {
206 Some(OriginType::Local)
207 };
208
209 let mut objects = objects.clone();
210 for object_id in objects.ids(&args.ctx).await? {
211 exec_state
212 .batch_modeling_cmd(
213 ModelingCmdMeta::from_args(exec_state, &args),
214 ModelingCmd::from(mcmd::SetObjectTransform {
215 object_id,
216 transforms: vec![shared::ComponentTransform {
217 translate: Some(transform_by(
218 shared::Point3d {
219 x: LengthUnit(x.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
220 y: LengthUnit(y.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
221 z: LengthUnit(z.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
222 },
223 false,
224 !is_global,
225 origin,
226 )),
227 scale: None,
228 rotate_rpy: None,
229 rotate_angle_axis: None,
230 }],
231 }),
232 )
233 .await?;
234 }
235
236 Ok(objects)
237}
238
239pub async fn rotate(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
241 let objects = args.get_unlabeled_kw_arg(
242 "objects",
243 &RuntimeType::Union(vec![
244 RuntimeType::sketches(),
245 RuntimeType::solids(),
246 RuntimeType::imported(),
247 ]),
248 exec_state,
249 )?;
250 let roll: Option<TyF64> = args.get_kw_arg_opt("roll", &RuntimeType::degrees(), exec_state)?;
251 let pitch: Option<TyF64> = args.get_kw_arg_opt("pitch", &RuntimeType::degrees(), exec_state)?;
252 let yaw: Option<TyF64> = args.get_kw_arg_opt("yaw", &RuntimeType::degrees(), exec_state)?;
253 let axis: Option<Axis3dOrPoint3d> = args.get_kw_arg_opt(
254 "axis",
255 &RuntimeType::Union(vec![
256 RuntimeType::Primitive(PrimitiveType::Axis3d),
257 RuntimeType::point3d(),
258 ]),
259 exec_state,
260 )?;
261 let origin = axis.clone().map(|a| a.axis_origin()).unwrap_or_default();
262 let axis = axis.map(|a| a.to_point3d());
263 let angle: Option<TyF64> = args.get_kw_arg_opt("angle", &RuntimeType::degrees(), exec_state)?;
264 let global = args.get_kw_arg_opt("global", &RuntimeType::bool(), exec_state)?;
265
266 if roll.is_none() && pitch.is_none() && yaw.is_none() && axis.is_none() && angle.is_none() {
268 return Err(KclError::new_semantic(KclErrorDetails::new(
269 "Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided.".to_string(),
270 vec![args.source_range],
271 )));
272 }
273
274 if roll.is_some() || pitch.is_some() || yaw.is_some() {
276 if axis.is_some() || angle.is_some() {
278 return Err(KclError::new_semantic(KclErrorDetails::new(
279 "Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."
280 .to_owned(),
281 vec![args.source_range],
282 )));
283 }
284 }
285
286 if axis.is_some() || angle.is_some() {
288 if axis.is_none() {
289 return Err(KclError::new_semantic(KclErrorDetails::new(
290 "Expected `axis` to be provided when `angle` is provided.".to_string(),
291 vec![args.source_range],
292 )));
293 }
294 if angle.is_none() {
295 return Err(KclError::new_semantic(KclErrorDetails::new(
296 "Expected `angle` to be provided when `axis` is provided.".to_string(),
297 vec![args.source_range],
298 )));
299 }
300
301 if roll.is_some() || pitch.is_some() || yaw.is_some() {
303 return Err(KclError::new_semantic(KclErrorDetails::new(
304 "Expected `roll`, `pitch`, and `yaw` to not be provided when `axis` and `angle` are provided."
305 .to_owned(),
306 vec![args.source_range],
307 )));
308 }
309 }
310
311 if let Some(roll) = &roll
313 && !(-360.0..=360.0).contains(&roll.n)
314 {
315 return Err(KclError::new_semantic(KclErrorDetails::new(
316 format!("Expected roll to be between -360 and 360, found `{}`", roll.n),
317 vec![args.source_range],
318 )));
319 }
320 if let Some(pitch) = &pitch
321 && !(-360.0..=360.0).contains(&pitch.n)
322 {
323 return Err(KclError::new_semantic(KclErrorDetails::new(
324 format!("Expected pitch to be between -360 and 360, found `{}`", pitch.n),
325 vec![args.source_range],
326 )));
327 }
328 if let Some(yaw) = &yaw
329 && !(-360.0..=360.0).contains(&yaw.n)
330 {
331 return Err(KclError::new_semantic(KclErrorDetails::new(
332 format!("Expected yaw to be between -360 and 360, found `{}`", yaw.n),
333 vec![args.source_range],
334 )));
335 }
336
337 if let Some(angle) = &angle
339 && !(-360.0..=360.0).contains(&angle.n)
340 {
341 return Err(KclError::new_semantic(KclErrorDetails::new(
342 format!("Expected angle to be between -360 and 360, found `{}`", angle.n),
343 vec![args.source_range],
344 )));
345 }
346
347 let objects = inner_rotate(
348 objects,
349 roll.map(|t| t.n),
350 pitch.map(|t| t.n),
351 yaw.map(|t| t.n),
352 axis.map(|a| [a[0].n, a[1].n, a[2].n]),
355 origin.map(|a| [a[0].n, a[1].n, a[2].n]),
356 angle.map(|t| t.n),
357 global,
358 exec_state,
359 args,
360 )
361 .await?;
362 Ok(objects.into())
363}
364
365#[allow(clippy::too_many_arguments)]
366async fn inner_rotate(
367 objects: SolidOrSketchOrImportedGeometry,
368 roll: Option<f64>,
369 pitch: Option<f64>,
370 yaw: Option<f64>,
371 axis: Option<[f64; 3]>,
372 origin: Option<[f64; 3]>,
373 angle: Option<f64>,
374 global: Option<bool>,
375 exec_state: &mut ExecState,
376 args: Args,
377) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
378 if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
381 exec_state
382 .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), solids)
383 .await?;
384 }
385
386 let origin = if let Some(origin) = origin {
387 Some(OriginType::Custom {
388 origin: shared::Point3d {
389 x: origin[0],
390 y: origin[1],
391 z: origin[2],
392 },
393 })
394 } else if global.unwrap_or(false) {
395 Some(OriginType::Global)
396 } else {
397 Some(OriginType::Local)
398 };
399
400 let mut objects = objects.clone();
401 for object_id in objects.ids(&args.ctx).await? {
402 if let (Some(axis), Some(angle)) = (&axis, angle) {
403 exec_state
404 .batch_modeling_cmd(
405 ModelingCmdMeta::from_args(exec_state, &args),
406 ModelingCmd::from(mcmd::SetObjectTransform {
407 object_id,
408 transforms: vec![shared::ComponentTransform {
409 rotate_angle_axis: Some(transform_by(
410 shared::Point4d {
411 x: axis[0],
412 y: axis[1],
413 z: axis[2],
414 w: angle,
415 },
416 false,
417 !global.unwrap_or(false),
418 origin,
419 )),
420 scale: None,
421 rotate_rpy: None,
422 translate: None,
423 }],
424 }),
425 )
426 .await?;
427 } else {
428 exec_state
430 .batch_modeling_cmd(
431 ModelingCmdMeta::from_args(exec_state, &args),
432 ModelingCmd::from(mcmd::SetObjectTransform {
433 object_id,
434 transforms: vec![shared::ComponentTransform {
435 rotate_rpy: Some(transform_by(
436 shared::Point3d {
437 x: roll.unwrap_or(0.0),
438 y: pitch.unwrap_or(0.0),
439 z: yaw.unwrap_or(0.0),
440 },
441 false,
442 !global.unwrap_or(false),
443 origin,
444 )),
445 scale: None,
446 rotate_angle_axis: None,
447 translate: None,
448 }],
449 }),
450 )
451 .await?;
452 }
453 }
454
455 Ok(objects)
456}
457
458#[cfg(test)]
459mod tests {
460 use pretty_assertions::assert_eq;
461
462 use crate::execution::parse_execute;
463
464 const PIPE: &str = r#"sweepPath = startSketchOn(XZ)
465 |> startProfile(at = [0.05, 0.05])
466 |> line(end = [0, 7])
467 |> tangentialArc(angle = 90, radius = 5)
468 |> line(end = [-3, 0])
469 |> tangentialArc(angle = -90, radius = 5)
470 |> line(end = [0, 7])
471
472// Create a hole for the pipe.
473pipeHole = startSketchOn(XY)
474 |> circle(
475 center = [0, 0],
476 radius = 1.5,
477 )
478sweepSketch = startSketchOn(XY)
479 |> circle(
480 center = [0, 0],
481 radius = 2,
482 )
483 |> subtract2d(tool = pipeHole)
484 |> sweep(
485 path = sweepPath,
486 )"#;
487
488 #[tokio::test(flavor = "multi_thread")]
489 async fn test_rotate_empty() {
490 let ast = PIPE.to_string()
491 + r#"
492 |> rotate()
493"#;
494 let result = parse_execute(&ast).await;
495 assert!(result.is_err());
496 assert_eq!(
497 result.unwrap_err().message(),
498 r#"Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided."#.to_string()
499 );
500 }
501
502 #[tokio::test(flavor = "multi_thread")]
503 async fn test_rotate_axis_no_angle() {
504 let ast = PIPE.to_string()
505 + r#"
506 |> rotate(
507 axis = [0, 0, 1.0],
508 )
509"#;
510 let result = parse_execute(&ast).await;
511 assert!(result.is_err());
512 assert_eq!(
513 result.unwrap_err().message(),
514 r#"Expected `angle` to be provided when `axis` is provided."#.to_string()
515 );
516 }
517
518 #[tokio::test(flavor = "multi_thread")]
519 async fn test_rotate_angle_no_axis() {
520 let ast = PIPE.to_string()
521 + r#"
522 |> rotate(
523 angle = 90,
524 )
525"#;
526 let result = parse_execute(&ast).await;
527 assert!(result.is_err());
528 assert_eq!(
529 result.unwrap_err().message(),
530 r#"Expected `axis` to be provided when `angle` is provided."#.to_string()
531 );
532 }
533
534 #[tokio::test(flavor = "multi_thread")]
535 async fn test_rotate_angle_out_of_range() {
536 let ast = PIPE.to_string()
537 + r#"
538 |> rotate(
539 axis = [0, 0, 1.0],
540 angle = 900,
541 )
542"#;
543 let result = parse_execute(&ast).await;
544 assert!(result.is_err());
545 assert_eq!(
546 result.unwrap_err().message(),
547 r#"Expected angle to be between -360 and 360, found `900`"#.to_string()
548 );
549 }
550
551 #[tokio::test(flavor = "multi_thread")]
552 async fn test_rotate_angle_axis_yaw() {
553 let ast = PIPE.to_string()
554 + r#"
555 |> rotate(
556 axis = [0, 0, 1.0],
557 angle = 90,
558 yaw = 90,
559 )
560"#;
561 let result = parse_execute(&ast).await;
562 assert!(result.is_err());
563 assert_eq!(
564 result.unwrap_err().message(),
565 r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
566 .to_string()
567 );
568 }
569
570 #[tokio::test(flavor = "multi_thread")]
571 async fn test_rotate_yaw_only() {
572 let ast = PIPE.to_string()
573 + r#"
574 |> rotate(
575 yaw = 90,
576 )
577"#;
578 parse_execute(&ast).await.unwrap();
579 }
580
581 #[tokio::test(flavor = "multi_thread")]
582 async fn test_rotate_pitch_only() {
583 let ast = PIPE.to_string()
584 + r#"
585 |> rotate(
586 pitch = 90,
587 )
588"#;
589 parse_execute(&ast).await.unwrap();
590 }
591
592 #[tokio::test(flavor = "multi_thread")]
593 async fn test_rotate_roll_only() {
594 let ast = PIPE.to_string()
595 + r#"
596 |> rotate(
597 pitch = 90,
598 )
599"#;
600 parse_execute(&ast).await.unwrap();
601 }
602
603 #[tokio::test(flavor = "multi_thread")]
604 async fn test_rotate_yaw_out_of_range() {
605 let ast = PIPE.to_string()
606 + r#"
607 |> rotate(
608 yaw = 900,
609 pitch = 90,
610 roll = 90,
611 )
612"#;
613 let result = parse_execute(&ast).await;
614 assert!(result.is_err());
615 assert_eq!(
616 result.unwrap_err().message(),
617 r#"Expected yaw to be between -360 and 360, found `900`"#.to_string()
618 );
619 }
620
621 #[tokio::test(flavor = "multi_thread")]
622 async fn test_rotate_roll_out_of_range() {
623 let ast = PIPE.to_string()
624 + r#"
625 |> rotate(
626 yaw = 90,
627 pitch = 90,
628 roll = 900,
629 )
630"#;
631 let result = parse_execute(&ast).await;
632 assert!(result.is_err());
633 assert_eq!(
634 result.unwrap_err().message(),
635 r#"Expected roll to be between -360 and 360, found `900`"#.to_string()
636 );
637 }
638
639 #[tokio::test(flavor = "multi_thread")]
640 async fn test_rotate_pitch_out_of_range() {
641 let ast = PIPE.to_string()
642 + r#"
643 |> rotate(
644 yaw = 90,
645 pitch = 900,
646 roll = 90,
647 )
648"#;
649 let result = parse_execute(&ast).await;
650 assert!(result.is_err());
651 assert_eq!(
652 result.unwrap_err().message(),
653 r#"Expected pitch to be between -360 and 360, found `900`"#.to_string()
654 );
655 }
656
657 #[tokio::test(flavor = "multi_thread")]
658 async fn test_rotate_roll_pitch_yaw_with_angle() {
659 let ast = PIPE.to_string()
660 + r#"
661 |> rotate(
662 yaw = 90,
663 pitch = 90,
664 roll = 90,
665 angle = 90,
666 )
667"#;
668 let result = parse_execute(&ast).await;
669 assert!(result.is_err());
670 assert_eq!(
671 result.unwrap_err().message(),
672 r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
673 .to_string()
674 );
675 }
676
677 #[tokio::test(flavor = "multi_thread")]
678 async fn test_translate_no_args() {
679 let ast = PIPE.to_string()
680 + r#"
681 |> translate(
682 )
683"#;
684 let result = parse_execute(&ast).await;
685 assert!(result.is_err());
686 assert_eq!(
687 result.unwrap_err().message(),
688 r#"Expected `x`, `y`, or `z` to be provided."#.to_string()
689 );
690 }
691
692 #[tokio::test(flavor = "multi_thread")]
693 async fn test_scale_no_args() {
694 let ast = PIPE.to_string()
695 + r#"
696 |> scale(
697 )
698"#;
699 let result = parse_execute(&ast).await;
700 assert!(result.is_err());
701 assert_eq!(
702 result.unwrap_err().message(),
703 r#"Expected `x`, `y`, `z` or `factor` to be provided."#.to_string()
704 );
705 }
706}