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