1use anyhow::Result;
4use kcmc::{
5 each_cmd as mcmd,
6 length_unit::LengthUnit,
7 shared,
8 shared::{Point3d, Point4d},
9 ModelingCmd,
10};
11use kittycad_modeling_cmds as kcmc;
12
13use crate::{
14 errors::{KclError, KclErrorDetails},
15 execution::{
16 types::{PrimitiveType, RuntimeType},
17 ExecState, KclValue, SolidOrSketchOrImportedGeometry,
18 },
19 std::{args::TyF64, axis_or_reference::Axis3dOrPoint3d, Args},
20};
21
22pub async fn scale(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
24 let objects = args.get_unlabeled_kw_arg_typed(
25 "objects",
26 &RuntimeType::Union(vec![
27 RuntimeType::sketches(),
28 RuntimeType::solids(),
29 RuntimeType::imported(),
30 ]),
31 exec_state,
32 )?;
33 let scale_x: Option<TyF64> = args.get_kw_arg_opt_typed("x", &RuntimeType::count(), exec_state)?;
34 let scale_y: Option<TyF64> = args.get_kw_arg_opt_typed("y", &RuntimeType::count(), exec_state)?;
35 let scale_z: Option<TyF64> = args.get_kw_arg_opt_typed("z", &RuntimeType::count(), exec_state)?;
36 let global = args.get_kw_arg_opt_typed("global", &RuntimeType::bool(), exec_state)?;
37
38 if scale_x.is_none() && scale_y.is_none() && scale_z.is_none() {
40 return Err(KclError::Semantic(KclErrorDetails::new(
41 "Expected `x`, `y`, or `z` to be provided.".to_string(),
42 vec![args.source_range],
43 )));
44 }
45
46 let objects = inner_scale(
47 objects,
48 scale_x.map(|t| t.n),
49 scale_y.map(|t| t.n),
50 scale_z.map(|t| t.n),
51 global,
52 exec_state,
53 args,
54 )
55 .await?;
56 Ok(objects.into())
57}
58
59async fn inner_scale(
60 objects: SolidOrSketchOrImportedGeometry,
61 x: Option<f64>,
62 y: Option<f64>,
63 z: Option<f64>,
64 global: Option<bool>,
65 exec_state: &mut ExecState,
66 args: Args,
67) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
68 if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
71 args.flush_batch_for_solids(exec_state, solids).await?;
72 }
73
74 let mut objects = objects.clone();
75 for object_id in objects.ids(&args.ctx).await? {
76 let id = exec_state.next_uuid();
77
78 args.batch_modeling_cmd(
79 id,
80 ModelingCmd::from(mcmd::SetObjectTransform {
81 object_id,
82 transforms: vec![shared::ComponentTransform {
83 scale: Some(shared::TransformBy::<Point3d<f64>> {
84 property: Point3d {
85 x: x.unwrap_or(1.0),
86 y: y.unwrap_or(1.0),
87 z: z.unwrap_or(1.0),
88 },
89 set: false,
90 is_local: !global.unwrap_or(false),
91 }),
92 translate: None,
93 rotate_rpy: None,
94 rotate_angle_axis: None,
95 }],
96 }),
97 )
98 .await?;
99 }
100
101 Ok(objects)
102}
103
104pub async fn translate(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
106 let objects = args.get_unlabeled_kw_arg_typed(
107 "objects",
108 &RuntimeType::Union(vec![
109 RuntimeType::sketches(),
110 RuntimeType::solids(),
111 RuntimeType::imported(),
112 ]),
113 exec_state,
114 )?;
115 let translate_x: Option<TyF64> = args.get_kw_arg_opt_typed("x", &RuntimeType::length(), exec_state)?;
116 let translate_y: Option<TyF64> = args.get_kw_arg_opt_typed("y", &RuntimeType::length(), exec_state)?;
117 let translate_z: Option<TyF64> = args.get_kw_arg_opt_typed("z", &RuntimeType::length(), exec_state)?;
118 let global = args.get_kw_arg_opt_typed("global", &RuntimeType::bool(), exec_state)?;
119
120 if translate_x.is_none() && translate_y.is_none() && translate_z.is_none() {
122 return Err(KclError::Semantic(KclErrorDetails::new(
123 "Expected `x`, `y`, or `z` to be provided.".to_string(),
124 vec![args.source_range],
125 )));
126 }
127
128 let objects = inner_translate(objects, translate_x, translate_y, translate_z, global, exec_state, args).await?;
129 Ok(objects.into())
130}
131
132async fn inner_translate(
133 objects: SolidOrSketchOrImportedGeometry,
134 x: Option<TyF64>,
135 y: Option<TyF64>,
136 z: Option<TyF64>,
137 global: Option<bool>,
138 exec_state: &mut ExecState,
139 args: Args,
140) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
141 if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
144 args.flush_batch_for_solids(exec_state, solids).await?;
145 }
146
147 let mut objects = objects.clone();
148 for object_id in objects.ids(&args.ctx).await? {
149 let id = exec_state.next_uuid();
150
151 args.batch_modeling_cmd(
152 id,
153 ModelingCmd::from(mcmd::SetObjectTransform {
154 object_id,
155 transforms: vec![shared::ComponentTransform {
156 translate: Some(shared::TransformBy::<Point3d<LengthUnit>> {
157 property: shared::Point3d {
158 x: LengthUnit(x.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
159 y: LengthUnit(y.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
160 z: LengthUnit(z.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
161 },
162 set: false,
163 is_local: !global.unwrap_or(false),
164 }),
165 scale: None,
166 rotate_rpy: None,
167 rotate_angle_axis: None,
168 }],
169 }),
170 )
171 .await?;
172 }
173
174 Ok(objects)
175}
176
177pub async fn rotate(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
179 let objects = args.get_unlabeled_kw_arg_typed(
180 "objects",
181 &RuntimeType::Union(vec![
182 RuntimeType::sketches(),
183 RuntimeType::solids(),
184 RuntimeType::imported(),
185 ]),
186 exec_state,
187 )?;
188 let roll: Option<TyF64> = args.get_kw_arg_opt_typed("roll", &RuntimeType::degrees(), exec_state)?;
189 let pitch: Option<TyF64> = args.get_kw_arg_opt_typed("pitch", &RuntimeType::degrees(), exec_state)?;
190 let yaw: Option<TyF64> = args.get_kw_arg_opt_typed("yaw", &RuntimeType::degrees(), exec_state)?;
191 let axis: Option<Axis3dOrPoint3d> = args.get_kw_arg_opt_typed(
192 "axis",
193 &RuntimeType::Union(vec![
194 RuntimeType::Primitive(PrimitiveType::Axis3d),
195 RuntimeType::point3d(),
196 ]),
197 exec_state,
198 )?;
199 let axis = axis.map(|a| a.to_point3d());
200 let angle: Option<TyF64> = args.get_kw_arg_opt_typed("angle", &RuntimeType::degrees(), exec_state)?;
201 let global = args.get_kw_arg_opt_typed("global", &RuntimeType::bool(), exec_state)?;
202
203 if roll.is_none() && pitch.is_none() && yaw.is_none() && axis.is_none() && angle.is_none() {
205 return Err(KclError::Semantic(KclErrorDetails::new(
206 "Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided.".to_string(),
207 vec![args.source_range],
208 )));
209 }
210
211 if roll.is_some() || pitch.is_some() || yaw.is_some() {
213 if axis.is_some() || angle.is_some() {
215 return Err(KclError::Semantic(KclErrorDetails::new(
216 "Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."
217 .to_owned(),
218 vec![args.source_range],
219 )));
220 }
221 }
222
223 if axis.is_some() || angle.is_some() {
225 if axis.is_none() {
226 return Err(KclError::Semantic(KclErrorDetails::new(
227 "Expected `axis` to be provided when `angle` is provided.".to_string(),
228 vec![args.source_range],
229 )));
230 }
231 if angle.is_none() {
232 return Err(KclError::Semantic(KclErrorDetails::new(
233 "Expected `angle` to be provided when `axis` is provided.".to_string(),
234 vec![args.source_range],
235 )));
236 }
237
238 if roll.is_some() || pitch.is_some() || yaw.is_some() {
240 return Err(KclError::Semantic(KclErrorDetails::new(
241 "Expected `roll`, `pitch`, and `yaw` to not be provided when `axis` and `angle` are provided."
242 .to_owned(),
243 vec![args.source_range],
244 )));
245 }
246 }
247
248 if let Some(roll) = &roll {
250 if !(-360.0..=360.0).contains(&roll.n) {
251 return Err(KclError::Semantic(KclErrorDetails::new(
252 format!("Expected roll to be between -360 and 360, found `{}`", roll.n),
253 vec![args.source_range],
254 )));
255 }
256 }
257 if let Some(pitch) = &pitch {
258 if !(-360.0..=360.0).contains(&pitch.n) {
259 return Err(KclError::Semantic(KclErrorDetails::new(
260 format!("Expected pitch to be between -360 and 360, found `{}`", pitch.n),
261 vec![args.source_range],
262 )));
263 }
264 }
265 if let Some(yaw) = &yaw {
266 if !(-360.0..=360.0).contains(&yaw.n) {
267 return Err(KclError::Semantic(KclErrorDetails::new(
268 format!("Expected yaw to be between -360 and 360, found `{}`", yaw.n),
269 vec![args.source_range],
270 )));
271 }
272 }
273
274 if let Some(angle) = &angle {
276 if !(-360.0..=360.0).contains(&angle.n) {
277 return Err(KclError::Semantic(KclErrorDetails::new(
278 format!("Expected angle to be between -360 and 360, found `{}`", angle.n),
279 vec![args.source_range],
280 )));
281 }
282 }
283
284 let objects = inner_rotate(
285 objects,
286 roll.map(|t| t.n),
287 pitch.map(|t| t.n),
288 yaw.map(|t| t.n),
289 axis.map(|a| [a[0].n, a[1].n, a[2].n]),
292 angle.map(|t| t.n),
293 global,
294 exec_state,
295 args,
296 )
297 .await?;
298 Ok(objects.into())
299}
300
301#[allow(clippy::too_many_arguments)]
302async fn inner_rotate(
303 objects: SolidOrSketchOrImportedGeometry,
304 roll: Option<f64>,
305 pitch: Option<f64>,
306 yaw: Option<f64>,
307 axis: Option<[f64; 3]>,
308 angle: Option<f64>,
309 global: Option<bool>,
310 exec_state: &mut ExecState,
311 args: Args,
312) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
313 if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
316 args.flush_batch_for_solids(exec_state, solids).await?;
317 }
318
319 let mut objects = objects.clone();
320 for object_id in objects.ids(&args.ctx).await? {
321 let id = exec_state.next_uuid();
322
323 if let (Some(axis), Some(angle)) = (&axis, angle) {
324 args.batch_modeling_cmd(
325 id,
326 ModelingCmd::from(mcmd::SetObjectTransform {
327 object_id,
328 transforms: vec![shared::ComponentTransform {
329 rotate_angle_axis: Some(shared::TransformBy::<Point4d<f64>> {
330 property: shared::Point4d {
331 x: axis[0],
332 y: axis[1],
333 z: axis[2],
334 w: angle,
335 },
336 set: false,
337 is_local: !global.unwrap_or(false),
338 }),
339 scale: None,
340 rotate_rpy: None,
341 translate: None,
342 }],
343 }),
344 )
345 .await?;
346 } else {
347 args.batch_modeling_cmd(
349 id,
350 ModelingCmd::from(mcmd::SetObjectTransform {
351 object_id,
352 transforms: vec![shared::ComponentTransform {
353 rotate_rpy: Some(shared::TransformBy::<Point3d<f64>> {
354 property: shared::Point3d {
355 x: roll.unwrap_or(0.0),
356 y: pitch.unwrap_or(0.0),
357 z: yaw.unwrap_or(0.0),
358 },
359 set: false,
360 is_local: !global.unwrap_or(false),
361 }),
362 scale: None,
363 rotate_angle_axis: None,
364 translate: None,
365 }],
366 }),
367 )
368 .await?;
369 }
370 }
371
372 Ok(objects)
373}
374
375#[cfg(test)]
376mod tests {
377 use pretty_assertions::assert_eq;
378
379 use crate::execution::parse_execute;
380
381 const PIPE: &str = r#"sweepPath = startSketchOn(XZ)
382 |> startProfile(at = [0.05, 0.05])
383 |> line(end = [0, 7])
384 |> tangentialArc(angle = 90, radius = 5)
385 |> line(end = [-3, 0])
386 |> tangentialArc(angle = -90, radius = 5)
387 |> line(end = [0, 7])
388
389// Create a hole for the pipe.
390pipeHole = startSketchOn(XY)
391 |> circle(
392 center = [0, 0],
393 radius = 1.5,
394 )
395sweepSketch = startSketchOn(XY)
396 |> circle(
397 center = [0, 0],
398 radius = 2,
399 )
400 |> subtract2d(tool = pipeHole)
401 |> sweep(
402 path = sweepPath,
403 )"#;
404
405 #[tokio::test(flavor = "multi_thread")]
406 async fn test_rotate_empty() {
407 let ast = PIPE.to_string()
408 + r#"
409 |> rotate()
410"#;
411 let result = parse_execute(&ast).await;
412 assert!(result.is_err());
413 assert_eq!(
414 result.unwrap_err().message(),
415 r#"Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided."#.to_string()
416 );
417 }
418
419 #[tokio::test(flavor = "multi_thread")]
420 async fn test_rotate_axis_no_angle() {
421 let ast = PIPE.to_string()
422 + r#"
423 |> rotate(
424 axis = [0, 0, 1.0],
425 )
426"#;
427 let result = parse_execute(&ast).await;
428 assert!(result.is_err());
429 assert_eq!(
430 result.unwrap_err().message(),
431 r#"Expected `angle` to be provided when `axis` is provided."#.to_string()
432 );
433 }
434
435 #[tokio::test(flavor = "multi_thread")]
436 async fn test_rotate_angle_no_axis() {
437 let ast = PIPE.to_string()
438 + r#"
439 |> rotate(
440 angle = 90,
441 )
442"#;
443 let result = parse_execute(&ast).await;
444 assert!(result.is_err());
445 assert_eq!(
446 result.unwrap_err().message(),
447 r#"Expected `axis` to be provided when `angle` is provided."#.to_string()
448 );
449 }
450
451 #[tokio::test(flavor = "multi_thread")]
452 async fn test_rotate_angle_out_of_range() {
453 let ast = PIPE.to_string()
454 + r#"
455 |> rotate(
456 axis = [0, 0, 1.0],
457 angle = 900,
458 )
459"#;
460 let result = parse_execute(&ast).await;
461 assert!(result.is_err());
462 assert_eq!(
463 result.unwrap_err().message(),
464 r#"Expected angle to be between -360 and 360, found `900`"#.to_string()
465 );
466 }
467
468 #[tokio::test(flavor = "multi_thread")]
469 async fn test_rotate_angle_axis_yaw() {
470 let ast = PIPE.to_string()
471 + r#"
472 |> rotate(
473 axis = [0, 0, 1.0],
474 angle = 90,
475 yaw = 90,
476 )
477"#;
478 let result = parse_execute(&ast).await;
479 assert!(result.is_err());
480 assert_eq!(
481 result.unwrap_err().message(),
482 r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
483 .to_string()
484 );
485 }
486
487 #[tokio::test(flavor = "multi_thread")]
488 async fn test_rotate_yaw_only() {
489 let ast = PIPE.to_string()
490 + r#"
491 |> rotate(
492 yaw = 90,
493 )
494"#;
495 parse_execute(&ast).await.unwrap();
496 }
497
498 #[tokio::test(flavor = "multi_thread")]
499 async fn test_rotate_pitch_only() {
500 let ast = PIPE.to_string()
501 + r#"
502 |> rotate(
503 pitch = 90,
504 )
505"#;
506 parse_execute(&ast).await.unwrap();
507 }
508
509 #[tokio::test(flavor = "multi_thread")]
510 async fn test_rotate_roll_only() {
511 let ast = PIPE.to_string()
512 + r#"
513 |> rotate(
514 pitch = 90,
515 )
516"#;
517 parse_execute(&ast).await.unwrap();
518 }
519
520 #[tokio::test(flavor = "multi_thread")]
521 async fn test_rotate_yaw_out_of_range() {
522 let ast = PIPE.to_string()
523 + r#"
524 |> rotate(
525 yaw = 900,
526 pitch = 90,
527 roll = 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 yaw to be between -360 and 360, found `900`"#.to_string()
535 );
536 }
537
538 #[tokio::test(flavor = "multi_thread")]
539 async fn test_rotate_roll_out_of_range() {
540 let ast = PIPE.to_string()
541 + r#"
542 |> rotate(
543 yaw = 90,
544 pitch = 90,
545 roll = 900,
546 )
547"#;
548 let result = parse_execute(&ast).await;
549 assert!(result.is_err());
550 assert_eq!(
551 result.unwrap_err().message(),
552 r#"Expected roll to be between -360 and 360, found `900`"#.to_string()
553 );
554 }
555
556 #[tokio::test(flavor = "multi_thread")]
557 async fn test_rotate_pitch_out_of_range() {
558 let ast = PIPE.to_string()
559 + r#"
560 |> rotate(
561 yaw = 90,
562 pitch = 900,
563 roll = 90,
564 )
565"#;
566 let result = parse_execute(&ast).await;
567 assert!(result.is_err());
568 assert_eq!(
569 result.unwrap_err().message(),
570 r#"Expected pitch to be between -360 and 360, found `900`"#.to_string()
571 );
572 }
573
574 #[tokio::test(flavor = "multi_thread")]
575 async fn test_rotate_roll_pitch_yaw_with_angle() {
576 let ast = PIPE.to_string()
577 + r#"
578 |> rotate(
579 yaw = 90,
580 pitch = 90,
581 roll = 90,
582 angle = 90,
583 )
584"#;
585 let result = parse_execute(&ast).await;
586 assert!(result.is_err());
587 assert_eq!(
588 result.unwrap_err().message(),
589 r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
590 .to_string()
591 );
592 }
593
594 #[tokio::test(flavor = "multi_thread")]
595 async fn test_translate_no_args() {
596 let ast = PIPE.to_string()
597 + r#"
598 |> translate(
599 )
600"#;
601 let result = parse_execute(&ast).await;
602 assert!(result.is_err());
603 assert_eq!(
604 result.unwrap_err().message(),
605 r#"Expected `x`, `y`, or `z` to be provided."#.to_string()
606 );
607 }
608
609 #[tokio::test(flavor = "multi_thread")]
610 async fn test_scale_no_args() {
611 let ast = PIPE.to_string()
612 + r#"
613 |> scale(
614 )
615"#;
616 let result = parse_execute(&ast).await;
617 assert!(result.is_err());
618 assert_eq!(
619 result.unwrap_err().message(),
620 r#"Expected `x`, `y`, or `z` to be provided."#.to_string()
621 );
622 }
623}