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