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 translation = shared::Point3d {
210 x: LengthUnit(x.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
211 y: LengthUnit(y.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
212 z: LengthUnit(z.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
213 };
214 let mut objects = objects.clone();
215 for object_id in objects.ids(&args.ctx).await? {
216 exec_state
217 .batch_modeling_cmd(
218 ModelingCmdMeta::from_args(exec_state, &args),
219 ModelingCmd::from(mcmd::SetObjectTransform {
220 object_id,
221 transforms: vec![shared::ComponentTransform {
222 translate: Some(transform_by(translation, false, !is_global, origin)),
223 scale: None,
224 rotate_rpy: None,
225 rotate_angle_axis: None,
226 }],
227 }),
228 )
229 .await?;
230 }
231
232 Ok(objects)
233}
234
235pub async fn rotate(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
237 let objects = args.get_unlabeled_kw_arg(
238 "objects",
239 &RuntimeType::Union(vec![
240 RuntimeType::sketches(),
241 RuntimeType::solids(),
242 RuntimeType::imported(),
243 ]),
244 exec_state,
245 )?;
246 let roll: Option<TyF64> = args.get_kw_arg_opt("roll", &RuntimeType::degrees(), exec_state)?;
247 let pitch: Option<TyF64> = args.get_kw_arg_opt("pitch", &RuntimeType::degrees(), exec_state)?;
248 let yaw: Option<TyF64> = args.get_kw_arg_opt("yaw", &RuntimeType::degrees(), exec_state)?;
249 let axis: Option<Axis3dOrPoint3d> = args.get_kw_arg_opt(
250 "axis",
251 &RuntimeType::Union(vec![
252 RuntimeType::Primitive(PrimitiveType::Axis3d),
253 RuntimeType::point3d(),
254 ]),
255 exec_state,
256 )?;
257 let origin = axis.clone().map(|a| a.axis_origin()).unwrap_or_default();
258 let axis = axis.map(|a| a.to_point3d());
259 let angle: Option<TyF64> = args.get_kw_arg_opt("angle", &RuntimeType::degrees(), exec_state)?;
260 let global = args.get_kw_arg_opt("global", &RuntimeType::bool(), exec_state)?;
261
262 if roll.is_none() && pitch.is_none() && yaw.is_none() && axis.is_none() && angle.is_none() {
264 return Err(KclError::new_semantic(KclErrorDetails::new(
265 "Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided.".to_string(),
266 vec![args.source_range],
267 )));
268 }
269
270 if roll.is_some() || pitch.is_some() || yaw.is_some() {
272 if axis.is_some() || angle.is_some() {
274 return Err(KclError::new_semantic(KclErrorDetails::new(
275 "Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."
276 .to_owned(),
277 vec![args.source_range],
278 )));
279 }
280 }
281
282 if axis.is_some() || angle.is_some() {
284 if axis.is_none() {
285 return Err(KclError::new_semantic(KclErrorDetails::new(
286 "Expected `axis` to be provided when `angle` is provided.".to_string(),
287 vec![args.source_range],
288 )));
289 }
290 if angle.is_none() {
291 return Err(KclError::new_semantic(KclErrorDetails::new(
292 "Expected `angle` to be provided when `axis` is provided.".to_string(),
293 vec![args.source_range],
294 )));
295 }
296
297 if roll.is_some() || pitch.is_some() || yaw.is_some() {
299 return Err(KclError::new_semantic(KclErrorDetails::new(
300 "Expected `roll`, `pitch`, and `yaw` to not be provided when `axis` and `angle` are provided."
301 .to_owned(),
302 vec![args.source_range],
303 )));
304 }
305 }
306
307 if let Some(roll) = &roll
309 && !(-360.0..=360.0).contains(&roll.n)
310 {
311 return Err(KclError::new_semantic(KclErrorDetails::new(
312 format!("Expected roll to be between -360 and 360, found `{}`", roll.n),
313 vec![args.source_range],
314 )));
315 }
316 if let Some(pitch) = &pitch
317 && !(-360.0..=360.0).contains(&pitch.n)
318 {
319 return Err(KclError::new_semantic(KclErrorDetails::new(
320 format!("Expected pitch to be between -360 and 360, found `{}`", pitch.n),
321 vec![args.source_range],
322 )));
323 }
324 if let Some(yaw) = &yaw
325 && !(-360.0..=360.0).contains(&yaw.n)
326 {
327 return Err(KclError::new_semantic(KclErrorDetails::new(
328 format!("Expected yaw to be between -360 and 360, found `{}`", yaw.n),
329 vec![args.source_range],
330 )));
331 }
332
333 if let Some(angle) = &angle
335 && !(-360.0..=360.0).contains(&angle.n)
336 {
337 return Err(KclError::new_semantic(KclErrorDetails::new(
338 format!("Expected angle to be between -360 and 360, found `{}`", angle.n),
339 vec![args.source_range],
340 )));
341 }
342
343 let objects = inner_rotate(
344 objects,
345 roll.map(|t| t.n),
346 pitch.map(|t| t.n),
347 yaw.map(|t| t.n),
348 axis.map(|a| [a[0].n, a[1].n, a[2].n]),
351 origin.map(|a| [a[0].n, a[1].n, a[2].n]),
352 angle.map(|t| t.n),
353 global,
354 exec_state,
355 args,
356 )
357 .await?;
358 Ok(objects.into())
359}
360
361#[allow(clippy::too_many_arguments)]
362async fn inner_rotate(
363 objects: SolidOrSketchOrImportedGeometry,
364 roll: Option<f64>,
365 pitch: Option<f64>,
366 yaw: Option<f64>,
367 axis: Option<[f64; 3]>,
368 origin: Option<[f64; 3]>,
369 angle: Option<f64>,
370 global: Option<bool>,
371 exec_state: &mut ExecState,
372 args: Args,
373) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
374 if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
377 exec_state
378 .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), solids)
379 .await?;
380 }
381
382 let origin = if let Some(origin) = origin {
383 Some(OriginType::Custom {
384 origin: shared::Point3d {
385 x: origin[0],
386 y: origin[1],
387 z: origin[2],
388 },
389 })
390 } else if global.unwrap_or(false) {
391 Some(OriginType::Global)
392 } else {
393 Some(OriginType::Local)
394 };
395
396 let mut objects = objects.clone();
397 for object_id in objects.ids(&args.ctx).await? {
398 if let (Some(axis), Some(angle)) = (&axis, angle) {
399 exec_state
400 .batch_modeling_cmd(
401 ModelingCmdMeta::from_args(exec_state, &args),
402 ModelingCmd::from(mcmd::SetObjectTransform {
403 object_id,
404 transforms: vec![shared::ComponentTransform {
405 rotate_angle_axis: Some(transform_by(
406 shared::Point4d {
407 x: axis[0],
408 y: axis[1],
409 z: axis[2],
410 w: angle,
411 },
412 false,
413 !global.unwrap_or(false),
414 origin,
415 )),
416 scale: None,
417 rotate_rpy: None,
418 translate: None,
419 }],
420 }),
421 )
422 .await?;
423 } else {
424 exec_state
426 .batch_modeling_cmd(
427 ModelingCmdMeta::from_args(exec_state, &args),
428 ModelingCmd::from(mcmd::SetObjectTransform {
429 object_id,
430 transforms: vec![shared::ComponentTransform {
431 rotate_rpy: Some(transform_by(
432 shared::Point3d {
433 x: roll.unwrap_or(0.0),
434 y: pitch.unwrap_or(0.0),
435 z: yaw.unwrap_or(0.0),
436 },
437 false,
438 !global.unwrap_or(false),
439 origin,
440 )),
441 scale: None,
442 rotate_angle_axis: None,
443 translate: None,
444 }],
445 }),
446 )
447 .await?;
448 }
449 }
450
451 Ok(objects)
452}
453
454#[cfg(test)]
455mod tests {
456 use pretty_assertions::assert_eq;
457
458 use crate::execution::parse_execute;
459
460 const PIPE: &str = r#"sweepPath = startSketchOn(XZ)
461 |> startProfile(at = [0.05, 0.05])
462 |> line(end = [0, 7])
463 |> tangentialArc(angle = 90, radius = 5)
464 |> line(end = [-3, 0])
465 |> tangentialArc(angle = -90, radius = 5)
466 |> line(end = [0, 7])
467
468// Create a hole for the pipe.
469pipeHole = startSketchOn(XY)
470 |> circle(
471 center = [0, 0],
472 radius = 1.5,
473 )
474sweepSketch = startSketchOn(XY)
475 |> circle(
476 center = [0, 0],
477 radius = 2,
478 )
479 |> subtract2d(tool = pipeHole)
480 |> sweep(
481 path = sweepPath,
482 )"#;
483
484 #[tokio::test(flavor = "multi_thread")]
485 async fn test_rotate_empty() {
486 let ast = PIPE.to_string()
487 + r#"
488 |> rotate()
489"#;
490 let result = parse_execute(&ast).await;
491 assert!(result.is_err());
492 assert_eq!(
493 result.unwrap_err().message(),
494 r#"Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided."#.to_string()
495 );
496 }
497
498 #[tokio::test(flavor = "multi_thread")]
499 async fn test_rotate_axis_no_angle() {
500 let ast = PIPE.to_string()
501 + r#"
502 |> rotate(
503 axis = [0, 0, 1.0],
504 )
505"#;
506 let result = parse_execute(&ast).await;
507 assert!(result.is_err());
508 assert_eq!(
509 result.unwrap_err().message(),
510 r#"Expected `angle` to be provided when `axis` is provided."#.to_string()
511 );
512 }
513
514 #[tokio::test(flavor = "multi_thread")]
515 async fn test_rotate_angle_no_axis() {
516 let ast = PIPE.to_string()
517 + r#"
518 |> rotate(
519 angle = 90,
520 )
521"#;
522 let result = parse_execute(&ast).await;
523 assert!(result.is_err());
524 assert_eq!(
525 result.unwrap_err().message(),
526 r#"Expected `axis` to be provided when `angle` is provided."#.to_string()
527 );
528 }
529
530 #[tokio::test(flavor = "multi_thread")]
531 async fn test_rotate_angle_out_of_range() {
532 let ast = PIPE.to_string()
533 + r#"
534 |> rotate(
535 axis = [0, 0, 1.0],
536 angle = 900,
537 )
538"#;
539 let result = parse_execute(&ast).await;
540 assert!(result.is_err());
541 assert_eq!(
542 result.unwrap_err().message(),
543 r#"Expected angle to be between -360 and 360, found `900`"#.to_string()
544 );
545 }
546
547 #[tokio::test(flavor = "multi_thread")]
548 async fn test_rotate_angle_axis_yaw() {
549 let ast = PIPE.to_string()
550 + r#"
551 |> rotate(
552 axis = [0, 0, 1.0],
553 angle = 90,
554 yaw = 90,
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 `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
562 .to_string()
563 );
564 }
565
566 #[tokio::test(flavor = "multi_thread")]
567 async fn test_rotate_yaw_only() {
568 let ast = PIPE.to_string()
569 + r#"
570 |> rotate(
571 yaw = 90,
572 )
573"#;
574 parse_execute(&ast).await.unwrap();
575 }
576
577 #[tokio::test(flavor = "multi_thread")]
578 async fn test_rotate_pitch_only() {
579 let ast = PIPE.to_string()
580 + r#"
581 |> rotate(
582 pitch = 90,
583 )
584"#;
585 parse_execute(&ast).await.unwrap();
586 }
587
588 #[tokio::test(flavor = "multi_thread")]
589 async fn test_rotate_roll_only() {
590 let ast = PIPE.to_string()
591 + r#"
592 |> rotate(
593 pitch = 90,
594 )
595"#;
596 parse_execute(&ast).await.unwrap();
597 }
598
599 #[tokio::test(flavor = "multi_thread")]
600 async fn test_rotate_yaw_out_of_range() {
601 let ast = PIPE.to_string()
602 + r#"
603 |> rotate(
604 yaw = 900,
605 pitch = 90,
606 roll = 90,
607 )
608"#;
609 let result = parse_execute(&ast).await;
610 assert!(result.is_err());
611 assert_eq!(
612 result.unwrap_err().message(),
613 r#"Expected yaw to be between -360 and 360, found `900`"#.to_string()
614 );
615 }
616
617 #[tokio::test(flavor = "multi_thread")]
618 async fn test_rotate_roll_out_of_range() {
619 let ast = PIPE.to_string()
620 + r#"
621 |> rotate(
622 yaw = 90,
623 pitch = 90,
624 roll = 900,
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 roll to be between -360 and 360, found `900`"#.to_string()
632 );
633 }
634
635 #[tokio::test(flavor = "multi_thread")]
636 async fn test_rotate_pitch_out_of_range() {
637 let ast = PIPE.to_string()
638 + r#"
639 |> rotate(
640 yaw = 90,
641 pitch = 900,
642 roll = 90,
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 pitch to be between -360 and 360, found `900`"#.to_string()
650 );
651 }
652
653 #[tokio::test(flavor = "multi_thread")]
654 async fn test_rotate_roll_pitch_yaw_with_angle() {
655 let ast = PIPE.to_string()
656 + r#"
657 |> rotate(
658 yaw = 90,
659 pitch = 90,
660 roll = 90,
661 angle = 90,
662 )
663"#;
664 let result = parse_execute(&ast).await;
665 assert!(result.is_err());
666 assert_eq!(
667 result.unwrap_err().message(),
668 r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
669 .to_string()
670 );
671 }
672
673 #[tokio::test(flavor = "multi_thread")]
674 async fn test_translate_no_args() {
675 let ast = PIPE.to_string()
676 + r#"
677 |> translate(
678 )
679"#;
680 let result = parse_execute(&ast).await;
681 assert!(result.is_err());
682 assert_eq!(
683 result.unwrap_err().message(),
684 r#"Expected `x`, `y`, or `z` to be provided."#.to_string()
685 );
686 }
687
688 #[tokio::test(flavor = "multi_thread")]
689 async fn test_scale_no_args() {
690 let ast = PIPE.to_string()
691 + r#"
692 |> scale(
693 )
694"#;
695 let result = parse_execute(&ast).await;
696 assert!(result.is_err());
697 assert_eq!(
698 result.unwrap_err().message(),
699 r#"Expected `x`, `y`, `z` or `factor` to be provided."#.to_string()
700 );
701 }
702}