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