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(
108 mcmd::SetObjectTransform::builder()
109 .object_id(object_id)
110 .transforms(vec![shared::ComponentTransform {
111 scale: Some(transform_by(
112 Point3d {
113 x: x.unwrap_or(1.0),
114 y: y.unwrap_or(1.0),
115 z: z.unwrap_or(1.0),
116 },
117 false,
118 !is_global,
119 origin,
120 )),
121 translate: None,
122 rotate_rpy: None,
123 rotate_angle_axis: None,
124 }])
125 .build(),
126 ),
127 )
128 .await?;
129 }
130
131 Ok(objects)
132}
133
134pub async fn translate(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
136 let objects = args.get_unlabeled_kw_arg(
137 "objects",
138 &RuntimeType::Union(vec![
139 RuntimeType::sketches(),
140 RuntimeType::solids(),
141 RuntimeType::imported(),
142 ]),
143 exec_state,
144 )?;
145 let translate_x: Option<TyF64> = args.get_kw_arg_opt("x", &RuntimeType::length(), exec_state)?;
146 let translate_y: Option<TyF64> = args.get_kw_arg_opt("y", &RuntimeType::length(), exec_state)?;
147 let translate_z: Option<TyF64> = args.get_kw_arg_opt("z", &RuntimeType::length(), exec_state)?;
148 let xyz: Option<[TyF64; 3]> = args.get_kw_arg_opt("xyz", &RuntimeType::point3d(), exec_state)?;
149 let global = args.get_kw_arg_opt("global", &RuntimeType::bool(), exec_state)?;
150
151 let objects = inner_translate(
152 objects,
153 xyz,
154 translate_x,
155 translate_y,
156 translate_z,
157 global,
158 exec_state,
159 args,
160 )
161 .await?;
162 Ok(objects.into())
163}
164
165#[allow(clippy::too_many_arguments)]
166async fn inner_translate(
167 objects: SolidOrSketchOrImportedGeometry,
168 xyz: Option<[TyF64; 3]>,
169 x: Option<TyF64>,
170 y: Option<TyF64>,
171 z: Option<TyF64>,
172 global: Option<bool>,
173 exec_state: &mut ExecState,
174 args: Args,
175) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
176 let (x, y, z) = match (xyz, x, y, z) {
177 (None, None, None, None) => {
178 return Err(KclError::new_semantic(KclErrorDetails::new(
179 "Expected `x`, `y`, or `z` to be provided.".to_string(),
180 vec![args.source_range],
181 )));
182 }
183 (Some(xyz), None, None, None) => {
184 let [x, y, z] = xyz;
185 (Some(x), Some(y), Some(z))
186 }
187 (None, x, y, z) => (x, y, z),
188 (Some(_), _, _, _) => {
189 return Err(KclError::new_semantic(KclErrorDetails::new(
190 "If you provide all 3 distances via the `xyz` arg, you cannot provide them separately via the `x`, `y` or `z` args."
191 .to_string(),
192 vec![args.source_range],
193 )));
194 }
195 };
196 if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
199 exec_state
200 .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), solids)
201 .await?;
202 }
203
204 let is_global = global.unwrap_or(false);
205 let origin = if is_global {
206 Some(OriginType::Global)
207 } else {
208 Some(OriginType::Local)
209 };
210
211 let translation = shared::Point3d {
212 x: LengthUnit(x.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
213 y: LengthUnit(y.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
214 z: LengthUnit(z.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
215 };
216 let mut objects = objects.clone();
217 for object_id in objects.ids(&args.ctx).await? {
218 exec_state
219 .batch_modeling_cmd(
220 ModelingCmdMeta::from_args(exec_state, &args),
221 ModelingCmd::from(
222 mcmd::SetObjectTransform::builder()
223 .object_id(object_id)
224 .transforms(vec![shared::ComponentTransform {
225 translate: Some(transform_by(translation, false, !is_global, origin)),
226 scale: None,
227 rotate_rpy: None,
228 rotate_angle_axis: None,
229 }])
230 .build(),
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(
407 mcmd::SetObjectTransform::builder()
408 .object_id(object_id)
409 .transforms(vec![shared::ComponentTransform {
410 rotate_angle_axis: Some(transform_by(
411 shared::Point4d {
412 x: axis[0],
413 y: axis[1],
414 z: axis[2],
415 w: angle,
416 },
417 false,
418 !global.unwrap_or(false),
419 origin,
420 )),
421 scale: None,
422 rotate_rpy: None,
423 translate: None,
424 }])
425 .build(),
426 ),
427 )
428 .await?;
429 } else {
430 exec_state
432 .batch_modeling_cmd(
433 ModelingCmdMeta::from_args(exec_state, &args),
434 ModelingCmd::from(
435 mcmd::SetObjectTransform::builder()
436 .object_id(object_id)
437 .transforms(vec![shared::ComponentTransform {
438 rotate_rpy: Some(transform_by(
439 shared::Point3d {
440 x: roll.unwrap_or(0.0),
441 y: pitch.unwrap_or(0.0),
442 z: yaw.unwrap_or(0.0),
443 },
444 false,
445 !global.unwrap_or(false),
446 origin,
447 )),
448 scale: None,
449 rotate_angle_axis: None,
450 translate: None,
451 }])
452 .build(),
453 ),
454 )
455 .await?;
456 }
457 }
458
459 Ok(objects)
460}
461
462#[cfg(test)]
463mod tests {
464 use pretty_assertions::assert_eq;
465
466 use crate::execution::parse_execute;
467
468 const PIPE: &str = r#"sweepPath = startSketchOn(XZ)
469 |> startProfile(at = [0.05, 0.05])
470 |> line(end = [0, 7])
471 |> tangentialArc(angle = 90, radius = 5)
472 |> line(end = [-3, 0])
473 |> tangentialArc(angle = -90, radius = 5)
474 |> line(end = [0, 7])
475
476// Create a hole for the pipe.
477pipeHole = startSketchOn(XY)
478 |> circle(
479 center = [0, 0],
480 radius = 1.5,
481 )
482sweepSketch = startSketchOn(XY)
483 |> circle(
484 center = [0, 0],
485 radius = 2,
486 )
487 |> subtract2d(tool = pipeHole)
488 |> sweep(
489 path = sweepPath,
490 )"#;
491
492 #[tokio::test(flavor = "multi_thread")]
493 async fn test_rotate_empty() {
494 let ast = PIPE.to_string()
495 + r#"
496 |> rotate()
497"#;
498 let result = parse_execute(&ast).await;
499 assert!(result.is_err());
500 assert_eq!(
501 result.unwrap_err().message(),
502 r#"Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided."#.to_string()
503 );
504 }
505
506 #[tokio::test(flavor = "multi_thread")]
507 async fn test_rotate_axis_no_angle() {
508 let ast = PIPE.to_string()
509 + r#"
510 |> rotate(
511 axis = [0, 0, 1.0],
512 )
513"#;
514 let result = parse_execute(&ast).await;
515 assert!(result.is_err());
516 assert_eq!(
517 result.unwrap_err().message(),
518 r#"Expected `angle` to be provided when `axis` is provided."#.to_string()
519 );
520 }
521
522 #[tokio::test(flavor = "multi_thread")]
523 async fn test_rotate_angle_no_axis() {
524 let ast = PIPE.to_string()
525 + r#"
526 |> rotate(
527 angle = 90,
528 )
529"#;
530 let result = parse_execute(&ast).await;
531 assert!(result.is_err());
532 assert_eq!(
533 result.unwrap_err().message(),
534 r#"Expected `axis` to be provided when `angle` is provided."#.to_string()
535 );
536 }
537
538 #[tokio::test(flavor = "multi_thread")]
539 async fn test_rotate_angle_out_of_range() {
540 let ast = PIPE.to_string()
541 + r#"
542 |> rotate(
543 axis = [0, 0, 1.0],
544 angle = 900,
545 )
546"#;
547 let result = parse_execute(&ast).await;
548 assert!(result.is_err());
549 assert_eq!(
550 result.unwrap_err().message(),
551 r#"Expected angle to be between -360 and 360, found `900`"#.to_string()
552 );
553 }
554
555 #[tokio::test(flavor = "multi_thread")]
556 async fn test_rotate_angle_axis_yaw() {
557 let ast = PIPE.to_string()
558 + r#"
559 |> rotate(
560 axis = [0, 0, 1.0],
561 angle = 90,
562 yaw = 90,
563 )
564"#;
565 let result = parse_execute(&ast).await;
566 assert!(result.is_err());
567 assert_eq!(
568 result.unwrap_err().message(),
569 r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
570 .to_string()
571 );
572 }
573
574 #[tokio::test(flavor = "multi_thread")]
575 async fn test_rotate_yaw_only() {
576 let ast = PIPE.to_string()
577 + r#"
578 |> rotate(
579 yaw = 90,
580 )
581"#;
582 parse_execute(&ast).await.unwrap();
583 }
584
585 #[tokio::test(flavor = "multi_thread")]
586 async fn test_rotate_pitch_only() {
587 let ast = PIPE.to_string()
588 + r#"
589 |> rotate(
590 pitch = 90,
591 )
592"#;
593 parse_execute(&ast).await.unwrap();
594 }
595
596 #[tokio::test(flavor = "multi_thread")]
597 async fn test_rotate_roll_only() {
598 let ast = PIPE.to_string()
599 + r#"
600 |> rotate(
601 pitch = 90,
602 )
603"#;
604 parse_execute(&ast).await.unwrap();
605 }
606
607 #[tokio::test(flavor = "multi_thread")]
608 async fn test_rotate_yaw_out_of_range() {
609 let ast = PIPE.to_string()
610 + r#"
611 |> rotate(
612 yaw = 900,
613 pitch = 90,
614 roll = 90,
615 )
616"#;
617 let result = parse_execute(&ast).await;
618 assert!(result.is_err());
619 assert_eq!(
620 result.unwrap_err().message(),
621 r#"Expected yaw to be between -360 and 360, found `900`"#.to_string()
622 );
623 }
624
625 #[tokio::test(flavor = "multi_thread")]
626 async fn test_rotate_roll_out_of_range() {
627 let ast = PIPE.to_string()
628 + r#"
629 |> rotate(
630 yaw = 90,
631 pitch = 90,
632 roll = 900,
633 )
634"#;
635 let result = parse_execute(&ast).await;
636 assert!(result.is_err());
637 assert_eq!(
638 result.unwrap_err().message(),
639 r#"Expected roll to be between -360 and 360, found `900`"#.to_string()
640 );
641 }
642
643 #[tokio::test(flavor = "multi_thread")]
644 async fn test_rotate_pitch_out_of_range() {
645 let ast = PIPE.to_string()
646 + r#"
647 |> rotate(
648 yaw = 90,
649 pitch = 900,
650 roll = 90,
651 )
652"#;
653 let result = parse_execute(&ast).await;
654 assert!(result.is_err());
655 assert_eq!(
656 result.unwrap_err().message(),
657 r#"Expected pitch to be between -360 and 360, found `900`"#.to_string()
658 );
659 }
660
661 #[tokio::test(flavor = "multi_thread")]
662 async fn test_rotate_roll_pitch_yaw_with_angle() {
663 let ast = PIPE.to_string()
664 + r#"
665 |> rotate(
666 yaw = 90,
667 pitch = 90,
668 roll = 90,
669 angle = 90,
670 )
671"#;
672 let result = parse_execute(&ast).await;
673 assert!(result.is_err());
674 assert_eq!(
675 result.unwrap_err().message(),
676 r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
677 .to_string()
678 );
679 }
680
681 #[tokio::test(flavor = "multi_thread")]
682 async fn test_translate_no_args() {
683 let ast = PIPE.to_string()
684 + r#"
685 |> translate(
686 )
687"#;
688 let result = parse_execute(&ast).await;
689 assert!(result.is_err());
690 assert_eq!(
691 result.unwrap_err().message(),
692 r#"Expected `x`, `y`, or `z` to be provided."#.to_string()
693 );
694 }
695
696 #[tokio::test(flavor = "multi_thread")]
697 async fn test_scale_no_args() {
698 let ast = PIPE.to_string()
699 + r#"
700 |> scale(
701 )
702"#;
703 let result = parse_execute(&ast).await;
704 assert!(result.is_err());
705 assert_eq!(
706 result.unwrap_err().message(),
707 r#"Expected `x`, `y`, `z` or `factor` to be provided."#.to_string()
708 );
709 }
710}