1use anyhow::Result;
4use kcmc::ModelingCmd;
5use kcmc::each_cmd as mcmd;
6use kcmc::length_unit::LengthUnit;
7use kcmc::shared;
8use kcmc::shared::OriginType;
9use kcmc::shared::Point3d;
10use kittycad_modeling_cmds as kcmc;
11
12use crate::errors::KclError;
13use crate::errors::KclErrorDetails;
14use crate::execution::ExecState;
15use crate::execution::HideableGeometry;
16use crate::execution::KclValue;
17use crate::execution::ModelingCmdMeta;
18use crate::execution::SolidOrSketchOrImportedGeometry;
19use crate::execution::types::PrimitiveType;
20use crate::execution::types::RuntimeType;
21use crate::std::Args;
22use crate::std::args::TyF64;
23use crate::std::axis_or_reference::Axis3dOrPoint3d;
24
25fn transform_by<T>(property: T, set: bool, origin: OriginType) -> shared::TransformBy<T> {
26 shared::TransformBy::builder()
27 .property(property)
28 .set(set)
29 .origin(origin)
30 .build()
31}
32
33fn validate_rotation_angle(angle: &Option<TyF64>, argument_name: &str, args: &Args) -> Result<(), KclError> {
34 let Some(angle) = angle else {
35 return Ok(());
36 };
37
38 if !angle.n.is_finite() {
39 return Err(KclError::new_semantic(KclErrorDetails::new(
40 format!("`{argument_name}` must be a finite number."),
41 vec![args.source_range],
42 )));
43 }
44
45 Ok(())
46}
47
48pub async fn scale(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
50 let objects = args.get_unlabeled_kw_arg(
51 "objects",
52 &RuntimeType::Union(vec![
53 RuntimeType::sketches(),
54 RuntimeType::solids(),
55 RuntimeType::helices(),
56 RuntimeType::imported(),
57 ]),
58 exec_state,
59 )?;
60 let scale_x: Option<TyF64> = args.get_kw_arg_opt("x", &RuntimeType::count(), exec_state)?;
61 let scale_y: Option<TyF64> = args.get_kw_arg_opt("y", &RuntimeType::count(), exec_state)?;
62 let scale_z: Option<TyF64> = args.get_kw_arg_opt("z", &RuntimeType::count(), exec_state)?;
63 let factor: Option<TyF64> = args.get_kw_arg_opt("factor", &RuntimeType::count(), exec_state)?;
64 for scale_dim in [&scale_x, &scale_y, &scale_z, &factor] {
65 if let Some(num) = scale_dim
66 && num.n == 0.0
67 {
68 return Err(KclError::new_semantic(KclErrorDetails::new(
69 "Cannot scale by 0".to_string(),
70 vec![args.source_range],
71 )));
72 }
73 }
74 let (scale_x, scale_y, scale_z) = match (scale_x, scale_y, scale_z, factor) {
75 (None, None, None, Some(factor)) => (Some(factor.clone()), Some(factor.clone()), Some(factor)),
76 (None, None, None, None) => {
78 return Err(KclError::new_semantic(KclErrorDetails::new(
79 "Expected `x`, `y`, `z` or `factor` to be provided.".to_string(),
80 vec![args.source_range],
81 )));
82 }
83 (x, y, z, None) => (x, y, z),
84 _ => {
85 return Err(KclError::new_semantic(KclErrorDetails::new(
86 "If you give `factor` then you cannot use `x`, `y`, or `z`".to_string(),
87 vec![args.source_range],
88 )));
89 }
90 };
91 let global = args.get_kw_arg_opt("global", &RuntimeType::bool(), exec_state)?;
92
93 let objects = inner_scale(
94 objects,
95 scale_x.map(|t| t.n),
96 scale_y.map(|t| t.n),
97 scale_z.map(|t| t.n),
98 global,
99 exec_state,
100 args,
101 )
102 .await?;
103 Ok(objects.into())
104}
105
106async fn inner_scale(
107 objects: SolidOrSketchOrImportedGeometry,
108 x: Option<f64>,
109 y: Option<f64>,
110 z: Option<f64>,
111 global: Option<bool>,
112 exec_state: &mut ExecState,
113 args: Args,
114) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
115 if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
118 exec_state
119 .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), solids)
120 .await?;
121 }
122
123 let is_global = global.unwrap_or(false);
124 let origin = if is_global {
125 OriginType::Global
126 } else {
127 OriginType::Local
128 };
129
130 let mut objects = objects.clone();
131 for object_id in objects.ids(&args.ctx).await? {
132 let transform = shared::ComponentTransform::builder()
133 .scale(transform_by(
134 Point3d {
135 x: x.unwrap_or(1.0),
136 y: y.unwrap_or(1.0),
137 z: z.unwrap_or(1.0),
138 },
139 false,
140 origin,
141 ))
142 .build();
143 let transforms = vec![transform];
144 exec_state
145 .send_modeling_cmd(
146 ModelingCmdMeta::from_args(exec_state, &args),
147 ModelingCmd::from(
148 mcmd::SetObjectTransform::builder()
149 .object_id(object_id)
150 .transforms(transforms)
151 .build(),
152 ),
153 )
154 .await?;
155 }
156
157 Ok(objects)
158}
159
160pub async fn translate(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
162 let objects = args.get_unlabeled_kw_arg(
163 "objects",
164 &RuntimeType::Union(vec![
165 RuntimeType::sketches(),
166 RuntimeType::solids(),
167 RuntimeType::helices(),
168 RuntimeType::imported(),
169 ]),
170 exec_state,
171 )?;
172 let translate_x: Option<TyF64> = args.get_kw_arg_opt("x", &RuntimeType::length(), exec_state)?;
173 let translate_y: Option<TyF64> = args.get_kw_arg_opt("y", &RuntimeType::length(), exec_state)?;
174 let translate_z: Option<TyF64> = args.get_kw_arg_opt("z", &RuntimeType::length(), exec_state)?;
175 let xyz: Option<[TyF64; 3]> = args.get_kw_arg_opt("xyz", &RuntimeType::point3d(), exec_state)?;
176 let global = args.get_kw_arg_opt("global", &RuntimeType::bool(), exec_state)?;
177
178 let objects = inner_translate(
179 objects,
180 xyz,
181 translate_x,
182 translate_y,
183 translate_z,
184 global,
185 exec_state,
186 args,
187 )
188 .await?;
189 Ok(objects.into())
190}
191
192#[allow(clippy::too_many_arguments)]
193async fn inner_translate(
194 objects: SolidOrSketchOrImportedGeometry,
195 xyz: Option<[TyF64; 3]>,
196 x: Option<TyF64>,
197 y: Option<TyF64>,
198 z: Option<TyF64>,
199 global: Option<bool>,
200 exec_state: &mut ExecState,
201 args: Args,
202) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
203 let (x, y, z) = match (xyz, x, y, z) {
204 (None, None, None, None) => {
205 return Err(KclError::new_semantic(KclErrorDetails::new(
206 "Expected `x`, `y`, or `z` to be provided.".to_string(),
207 vec![args.source_range],
208 )));
209 }
210 (Some(xyz), None, None, None) => {
211 let [x, y, z] = xyz;
212 (Some(x), Some(y), Some(z))
213 }
214 (None, x, y, z) => (x, y, z),
215 (Some(_), _, _, _) => {
216 return Err(KclError::new_semantic(KclErrorDetails::new(
217 "If you provide all 3 distances via the `xyz` arg, you cannot provide them separately via the `x`, `y` or `z` args."
218 .to_string(),
219 vec![args.source_range],
220 )));
221 }
222 };
223 if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
226 exec_state
227 .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), solids)
228 .await?;
229 }
230
231 let is_global = global.unwrap_or(false);
232 let origin = if is_global {
233 OriginType::Global
234 } else {
235 OriginType::Local
236 };
237
238 let translation = shared::Point3d {
239 x: LengthUnit(x.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
240 y: LengthUnit(y.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
241 z: LengthUnit(z.as_ref().map(|t| t.to_mm()).unwrap_or_default()),
242 };
243 let mut objects = objects.clone();
244 for object_id in objects.ids(&args.ctx).await? {
245 let transform = shared::ComponentTransform::builder()
246 .translate(transform_by(translation, false, origin))
247 .build();
248 let transforms = vec![transform];
249 exec_state
250 .batch_modeling_cmd(
251 ModelingCmdMeta::from_args(exec_state, &args),
252 ModelingCmd::from(
253 mcmd::SetObjectTransform::builder()
254 .object_id(object_id)
255 .transforms(transforms)
256 .build(),
257 ),
258 )
259 .await?;
260 }
261
262 Ok(objects)
263}
264
265pub async fn rotate(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
267 let objects = args.get_unlabeled_kw_arg(
268 "objects",
269 &RuntimeType::Union(vec![
270 RuntimeType::sketches(),
271 RuntimeType::solids(),
272 RuntimeType::helices(),
273 RuntimeType::imported(),
274 ]),
275 exec_state,
276 )?;
277 let roll: Option<TyF64> = args.get_kw_arg_opt("roll", &RuntimeType::degrees(), exec_state)?;
278 let pitch: Option<TyF64> = args.get_kw_arg_opt("pitch", &RuntimeType::degrees(), exec_state)?;
279 let yaw: Option<TyF64> = args.get_kw_arg_opt("yaw", &RuntimeType::degrees(), exec_state)?;
280 let axis: Option<Axis3dOrPoint3d> = args.get_kw_arg_opt(
281 "axis",
282 &RuntimeType::Union(vec![
283 RuntimeType::Primitive(PrimitiveType::Axis3d),
284 RuntimeType::point3d(),
285 ]),
286 exec_state,
287 )?;
288 let origin = axis.clone().map(|a| a.axis_origin()).unwrap_or_default();
289 let axis = axis.map(|a| a.to_point3d());
290 let angle: Option<TyF64> = args.get_kw_arg_opt("angle", &RuntimeType::degrees(), exec_state)?;
291 let global = args.get_kw_arg_opt("global", &RuntimeType::bool(), exec_state)?;
292
293 if roll.is_none() && pitch.is_none() && yaw.is_none() && axis.is_none() && angle.is_none() {
295 return Err(KclError::new_semantic(KclErrorDetails::new(
296 "Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided.".to_string(),
297 vec![args.source_range],
298 )));
299 }
300
301 if roll.is_some() || pitch.is_some() || yaw.is_some() {
303 if axis.is_some() || angle.is_some() {
305 return Err(KclError::new_semantic(KclErrorDetails::new(
306 "Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."
307 .to_owned(),
308 vec![args.source_range],
309 )));
310 }
311 }
312
313 if axis.is_some() || angle.is_some() {
315 if axis.is_none() {
316 return Err(KclError::new_semantic(KclErrorDetails::new(
317 "Expected `axis` to be provided when `angle` is provided.".to_string(),
318 vec![args.source_range],
319 )));
320 }
321 if angle.is_none() {
322 return Err(KclError::new_semantic(KclErrorDetails::new(
323 "Expected `angle` to be provided when `axis` is provided.".to_string(),
324 vec![args.source_range],
325 )));
326 }
327
328 if roll.is_some() || pitch.is_some() || yaw.is_some() {
330 return Err(KclError::new_semantic(KclErrorDetails::new(
331 "Expected `roll`, `pitch`, and `yaw` to not be provided when `axis` and `angle` are provided."
332 .to_owned(),
333 vec![args.source_range],
334 )));
335 }
336 }
337
338 validate_rotation_angle(&roll, "roll", &args)?;
339 validate_rotation_angle(&pitch, "pitch", &args)?;
340 validate_rotation_angle(&yaw, "yaw", &args)?;
341 validate_rotation_angle(&angle, "angle", &args)?;
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].to_mm(), a[1].to_mm(), a[2].to_mm()]),
353 angle.map(|t| t.n),
354 global,
355 exec_state,
356 args,
357 )
358 .await?;
359 Ok(objects.into())
360}
361
362#[allow(clippy::too_many_arguments)]
363async fn inner_rotate(
364 objects: SolidOrSketchOrImportedGeometry,
365 roll: Option<f64>,
366 pitch: Option<f64>,
367 yaw: Option<f64>,
368 axis: Option<[f64; 3]>,
369 origin: Option<[f64; 3]>,
370 angle: Option<f64>,
371 global: Option<bool>,
372 exec_state: &mut ExecState,
373 args: Args,
374) -> Result<SolidOrSketchOrImportedGeometry, KclError> {
375 if let SolidOrSketchOrImportedGeometry::SolidSet(solids) = &objects {
378 exec_state
379 .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), solids)
380 .await?;
381 }
382
383 let origin = if let Some(origin) = origin {
384 OriginType::Custom {
385 origin: shared::Point3d {
386 x: origin[0],
387 y: origin[1],
388 z: origin[2],
389 },
390 }
391 } else if global.unwrap_or(false) {
392 OriginType::Global
393 } else {
394 OriginType::Local
395 };
396
397 let mut objects = objects.clone();
398 for object_id in objects.ids(&args.ctx).await? {
399 if let (Some(axis), Some(angle)) = (&axis, angle) {
400 let transform = shared::ComponentTransform::builder()
401 .rotate_angle_axis(transform_by(
402 shared::Point4d {
403 x: axis[0],
404 y: axis[1],
405 z: axis[2],
406 w: angle,
407 },
408 false,
409 origin,
410 ))
411 .build();
412 let transforms = vec![transform];
413 exec_state
414 .batch_modeling_cmd(
415 ModelingCmdMeta::from_args(exec_state, &args),
416 ModelingCmd::from(
417 mcmd::SetObjectTransform::builder()
418 .object_id(object_id)
419 .transforms(transforms)
420 .build(),
421 ),
422 )
423 .await?;
424 } else {
425 let transform = shared::ComponentTransform::builder()
427 .rotate_rpy(transform_by(
428 shared::Point3d {
429 x: roll.unwrap_or(0.0),
430 y: pitch.unwrap_or(0.0),
431 z: yaw.unwrap_or(0.0),
432 },
433 false,
434 origin,
435 ))
436 .build();
437 let transforms = vec![transform];
438 exec_state
439 .batch_modeling_cmd(
440 ModelingCmdMeta::from_args(exec_state, &args),
441 ModelingCmd::from(
442 mcmd::SetObjectTransform::builder()
443 .object_id(object_id)
444 .transforms(transforms)
445 .build(),
446 ),
447 )
448 .await?;
449 }
450 }
451
452 Ok(objects)
453}
454
455pub async fn hide(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
457 let objects = args.get_unlabeled_kw_arg(
458 "objects",
459 &RuntimeType::Union(vec![
460 RuntimeType::sketches(),
461 RuntimeType::solids(),
462 RuntimeType::planes(),
463 RuntimeType::helices(),
464 RuntimeType::imported(),
465 RuntimeType::gdts(),
466 ]),
467 exec_state,
468 )?;
469
470 let objects = hide_inner(objects, true, exec_state, args).await?;
471 Ok(objects.into())
472}
473
474async fn hide_inner(
475 mut objects: HideableGeometry,
476 hidden: bool,
477 exec_state: &mut ExecState,
478 args: Args,
479) -> Result<HideableGeometry, KclError> {
480 for object_id in objects.ids(&args.ctx).await? {
481 exec_state
482 .batch_modeling_cmd(
483 ModelingCmdMeta::from_args(exec_state, &args),
484 ModelingCmd::from(
485 mcmd::ObjectVisible::builder()
486 .object_id(object_id)
487 .hidden(hidden)
488 .build(),
489 ),
490 )
491 .await?;
492 }
493
494 Ok(objects)
495}
496
497pub async fn delete(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
499 let objects = args.get_unlabeled_kw_arg(
500 "objects",
501 &RuntimeType::Union(vec![
502 RuntimeType::sketches(),
503 RuntimeType::solids(),
504 RuntimeType::helices(),
505 RuntimeType::imported(),
506 RuntimeType::gdts(),
507 ]),
508 exec_state,
509 )?;
510
511 delete_inner(objects, exec_state, args).await.map(|()| KclValue::none())
512}
513
514async fn delete_inner(mut objects: HideableGeometry, exec_state: &mut ExecState, args: Args) -> Result<(), KclError> {
515 let ids = objects.ids(&args.ctx).await?.into_iter().collect();
516 exec_state
517 .batch_modeling_cmd(
518 ModelingCmdMeta::from_args(exec_state, &args),
519 ModelingCmd::from(mcmd::RemoveSceneObjects::builder().object_ids(ids).build()),
520 )
521 .await
522}
523
524#[cfg(test)]
525mod tests {
526 use kittycad_modeling_cmds::ModelingCmd;
527 use kittycad_modeling_cmds::shared::ComponentTransform;
528 use kittycad_modeling_cmds::shared::OriginType;
529 use pretty_assertions::assert_eq;
530
531 use crate::errors::Severity;
532 use crate::errors::Tag;
533 use crate::execution::Artifact;
534 use crate::execution::ExecutorSettings;
535 use crate::execution::MockConfig;
536 use crate::execution::parse_execute;
537
538 const PIPE: &str = r#"sweepPath = startSketchOn(XZ)
539 |> startProfile(at = [0.05, 0.05])
540 |> line(end = [0, 7])
541 |> tangentialArc(angle = 90, radius = 5)
542 |> line(end = [-3, 0])
543 |> tangentialArc(angle = -90, radius = 5)
544 |> line(end = [0, 7])
545
546// Create a hole for the pipe.
547pipeHole = startSketchOn(XY)
548 |> circle(
549 center = [0, 0],
550 radius = 1.5,
551 )
552sweepSketch = startSketchOn(XY)
553 |> circle(
554 center = [0, 0],
555 radius = 2,
556 )
557 |> subtract2d(tool = pipeHole)
558 |> sweep(
559 path = sweepPath,
560 )"#;
561
562 async fn rotate_transform(arguments: &str) -> ComponentTransform {
563 rotate_transform_with_settings("", arguments).await
564 }
565
566 async fn rotate_transform_with_settings(settings: &str, arguments: &str) -> ComponentTransform {
569 let ast = format!("{settings}{PIPE}\n |> rotate({arguments})");
570 let result = parse_execute(&ast).await.unwrap();
571
572 result
573 .root_module_artifact_commands()
574 .iter()
575 .find_map(|artifact_command| match &artifact_command.command {
576 ModelingCmd::SetObjectTransform(command) => command
577 .transforms
578 .iter()
579 .find(|transform| transform.rotate_angle_axis.is_some() || transform.rotate_rpy.is_some()),
580 _ => None,
581 })
582 .cloned()
583 .expect("expected rotate() to dispatch a rotation transform")
584 }
585
586 #[tokio::test(flavor = "multi_thread")]
587 async fn test_rotate_empty() {
588 let ast = PIPE.to_string()
589 + r#"
590 |> rotate()
591"#;
592 let result = parse_execute(&ast).await;
593 assert!(result.is_err());
594 assert_eq!(
595 result.unwrap_err().message(),
596 r#"Expected `roll`, `pitch`, and `yaw` or `axis` and `angle` to be provided."#.to_string()
597 );
598 }
599
600 #[tokio::test(flavor = "multi_thread")]
601 async fn test_rotate_axis_no_angle() {
602 let ast = PIPE.to_string()
603 + r#"
604 |> rotate(
605 axis = [0, 0, 1.0],
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 `angle` to be provided when `axis` is provided."#.to_string()
613 );
614 }
615
616 #[tokio::test(flavor = "multi_thread")]
617 async fn test_rotate_angle_no_axis() {
618 let ast = PIPE.to_string()
619 + r#"
620 |> rotate(
621 angle = 90,
622 )
623"#;
624 let result = parse_execute(&ast).await;
625 assert!(result.is_err());
626 assert_eq!(
627 result.unwrap_err().message(),
628 r#"Expected `axis` to be provided when `angle` is provided."#.to_string()
629 );
630 }
631
632 #[tokio::test(flavor = "multi_thread")]
633 async fn test_rotate_accepts_axis_angles_outside_one_turn() {
634 for (input, expected) in [("371.5", 371.5), ("-371.5", -371.5), ("720", 720.0)] {
635 let transform = rotate_transform(&format!("axis = [0, 0, 1], angle = {input}")).await;
636 let rotation = transform
637 .rotate_angle_axis
638 .expect("expected an axis-angle rotation transform");
639
640 assert_eq!(rotation.property.w, expected, "input angle: {input}");
641 }
642 }
643
644 #[tokio::test(flavor = "multi_thread")]
645 async fn test_rotate_converts_custom_axis_origin_to_mm() {
646 let mm_file = "@settings(defaultLengthUnit = mm)\n";
651 let in_file = "@settings(defaultLengthUnit = in)\n";
652 for (settings, origin, expected) in [
653 (mm_file, "[1in, 2in, -3in]", [25.4, 50.8, -76.2]),
654 (mm_file, "[1cm, 2, 0]", [10.0, 2.0, 0.0]),
655 (mm_file, "[25.4, 50.8, -76.2]", [25.4, 50.8, -76.2]),
656 (in_file, "[1, 2, -3]", [25.4, 50.8, -76.2]),
657 (in_file, "[25.4mm, 50.8mm, -76.2mm]", [25.4, 50.8, -76.2]),
658 ] {
659 let arguments = format!("axis = {{ direction = [0, 0, 2], origin = {origin} }}, angle = 90deg");
660 let transform = rotate_transform_with_settings(settings, &arguments).await;
661 let rotation = transform
662 .rotate_angle_axis
663 .expect("expected an axis-angle rotation transform");
664
665 let OriginType::Custom { origin: actual } = rotation.origin else {
666 panic!("expected a custom origin, got {:?}", rotation.origin);
667 };
668 for (actual, expected) in [actual.x, actual.y, actual.z].into_iter().zip(expected) {
669 assert!(
670 (actual - expected).abs() < 1e-9,
671 "{settings}origin = {origin}: expected {expected:?} mm, got {actual:?}"
672 );
673 }
674 assert_eq!(
675 [rotation.property.x, rotation.property.y, rotation.property.z],
676 [0.0, 0.0, 2.0],
677 "{settings}origin = {origin}"
678 );
679 }
680 }
681
682 #[tokio::test(flavor = "multi_thread")]
683 async fn test_rotate_angle_axis_yaw() {
684 let ast = PIPE.to_string()
685 + r#"
686 |> rotate(
687 axis = [0, 0, 1.0],
688 angle = 90,
689 yaw = 90,
690 )
691"#;
692 let result = parse_execute(&ast).await;
693 assert!(result.is_err());
694 assert_eq!(
695 result.unwrap_err().message(),
696 r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
697 .to_string()
698 );
699 }
700
701 #[tokio::test(flavor = "multi_thread")]
702 async fn test_rotate_yaw_only() {
703 let ast = PIPE.to_string()
704 + r#"
705 |> rotate(
706 yaw = 90,
707 )
708"#;
709 parse_execute(&ast).await.unwrap();
710 }
711
712 #[tokio::test(flavor = "multi_thread")]
713 async fn test_rotate_pitch_only() {
714 let ast = PIPE.to_string()
715 + r#"
716 |> rotate(
717 pitch = 90,
718 )
719"#;
720 parse_execute(&ast).await.unwrap();
721 }
722
723 #[tokio::test(flavor = "multi_thread")]
724 async fn test_rotate_roll_only() {
725 let ast = PIPE.to_string()
726 + r#"
727 |> rotate(
728 pitch = 90,
729 )
730"#;
731 parse_execute(&ast).await.unwrap();
732 }
733
734 #[tokio::test(flavor = "multi_thread")]
735 async fn test_rotate_accepts_roll_pitch_and_yaw_outside_one_turn() {
736 let transform = rotate_transform("roll = 371.5, pitch = -371.5, yaw = 720").await;
737 let rotation = transform.rotate_rpy.expect("expected an RPY rotation transform");
738
739 assert_eq!(rotation.property.x, 371.5);
740 assert_eq!(rotation.property.y, -371.5);
741 assert_eq!(rotation.property.z, 720.0);
742 }
743
744 #[tokio::test(flavor = "multi_thread")]
745 async fn test_rotate_rejects_non_finite_angles() {
746 for (arguments, argument_name) in [
747 ("axis = [0, 0, 1], angle = 1 / 0", "angle"),
748 ("roll = 1 / 0", "roll"),
749 ("pitch = 0 / 0", "pitch"),
750 ("yaw = -1 / 0", "yaw"),
751 ] {
752 let ast = format!("{PIPE}\n |> rotate({arguments})");
753 let err = parse_execute(&ast).await.unwrap_err();
754
755 assert!(matches!(err, crate::errors::KclError::Semantic { .. }));
756 assert_eq!(err.message(), format!("`{argument_name}` must be a finite number."));
757 }
758 }
759
760 #[tokio::test(flavor = "multi_thread")]
761 async fn test_rotate_roll_pitch_yaw_with_angle() {
762 let ast = PIPE.to_string()
763 + r#"
764 |> rotate(
765 yaw = 90,
766 pitch = 90,
767 roll = 90,
768 angle = 90,
769 )
770"#;
771 let result = parse_execute(&ast).await;
772 assert!(result.is_err());
773 assert_eq!(
774 result.unwrap_err().message(),
775 r#"Expected `axis` and `angle` to not be provided when `roll`, `pitch`, and `yaw` are provided."#
776 .to_string()
777 );
778 }
779
780 #[tokio::test(flavor = "multi_thread")]
781 async fn test_translate_no_args() {
782 let ast = PIPE.to_string()
783 + r#"
784 |> translate(
785 )
786"#;
787 let result = parse_execute(&ast).await;
788 assert!(result.is_err());
789 assert_eq!(
790 result.unwrap_err().message(),
791 r#"Expected `x`, `y`, or `z` to be provided."#.to_string()
792 );
793 }
794
795 #[tokio::test(flavor = "multi_thread")]
796 async fn test_scale_no_args() {
797 let ast = PIPE.to_string()
798 + r#"
799 |> scale(
800 )
801"#;
802 let result = parse_execute(&ast).await;
803 assert!(result.is_err());
804 assert_eq!(
805 result.unwrap_err().message(),
806 r#"Expected `x`, `y`, `z` or `factor` to be provided."#.to_string()
807 );
808 }
809
810 #[tokio::test(flavor = "multi_thread")]
811 async fn delete_marks_gdt_annotation_artifact_consumed() {
812 let program = crate::Program::parse_no_errs(
813 r#"@settings(kclVersion = 2.0)
814
815annotation = gdt::note(note = "Inspect this surface")
816delete(annotation)
817"#,
818 )
819 .unwrap();
820 let ctx = crate::ExecutorContext::new_mock(None).await;
821 let outcome = ctx.run_mock(&program, &MockConfig::default()).await.unwrap();
822 ctx.close().await;
823
824 let annotations = outcome
825 .artifact_graph
826 .values()
827 .filter_map(|artifact| match artifact {
828 Artifact::GdtAnnotation(annotation) => Some(annotation),
829 _ => None,
830 })
831 .collect::<Vec<_>>();
832 assert_eq!(annotations.len(), 1);
833 assert!(annotations[0].consumed);
834 }
835
836 #[tokio::test(flavor = "multi_thread")]
837 async fn delete_marks_imported_geometry_artifact_consumed() {
838 let tmpdir = tempfile::TempDir::with_prefix("delete_imported_geometry").unwrap();
839 tokio::fs::write(tmpdir.path().join("model.obj"), "o model\n")
840 .await
841 .unwrap();
842 let program = crate::Program::parse_no_errs(
843 r#"@settings(kclVersion = 2.0)
844
845import "model.obj" as model
846delete(model)
847"#,
848 )
849 .unwrap();
850 let ctx = crate::ExecutorContext::new_mock(Some(ExecutorSettings {
851 project_directory: Some(crate::TypedPath(tmpdir.path().into())),
852 ..Default::default()
853 }))
854 .await;
855 let outcome = ctx.run_mock(&program, &MockConfig::default()).await.unwrap();
856 ctx.close().await;
857
858 let imported_geometry = outcome
859 .artifact_graph
860 .values()
861 .filter_map(|artifact| match artifact {
862 Artifact::ImportedGeometry(imported_geometry) => Some(imported_geometry),
863 _ => None,
864 })
865 .collect::<Vec<_>>();
866 assert_eq!(imported_geometry.len(), 1);
867 assert!(imported_geometry[0].consumed);
868 }
869
870 #[tokio::test(flavor = "multi_thread")]
871 async fn test_hide_pipe_solid_ok() {
872 let ast = PIPE.to_string()
873 + r#"
874 |> hide()
875"#;
876 parse_execute(&ast).await.unwrap();
877 }
878
879 #[tokio::test(flavor = "multi_thread")]
880 async fn hide_consumed_solid_reports_deprecation_warning() {
881 let code = r#"
882targetSketch = sketch(on = XY) {
883 line1 = line(start = [var -10, var -10], end = [var 10, var -10])
884 line2 = line(start = [var 10, var -10], end = [var 10, var 10])
885 line3 = line(start = [var 10, var 10], end = [var -10, var 10])
886 line4 = line(start = [var -10, var 10], end = [var -10, var -10])
887 coincident([line1.end, line2.start])
888 coincident([line2.end, line3.start])
889 coincident([line3.end, line4.start])
890 coincident([line4.end, line1.start])
891 equalLength([line1, line2, line3, line4])
892}
893
894target = extrude(region(point = [0, 0], sketch = targetSketch), length = 20)
895
896toolSketch = sketch(on = XY) {
897 line1 = line(start = [var -2, var -2], end = [var 2, var -2])
898 line2 = line(start = [var 2, var -2], end = [var 2, var 2])
899 line3 = line(start = [var 2, var 2], end = [var -2, var 2])
900 line4 = line(start = [var -2, var 2], end = [var -2, var -2])
901 coincident([line1.end, line2.start])
902 coincident([line2.end, line3.start])
903 coincident([line3.end, line4.start])
904 coincident([line4.end, line1.start])
905 equalLength([line1, line2, line3, line4])
906}
907
908tool = extrude(region(point = [0, 0], sketch = toolSketch), length = 4)
909
910result = subtract(target, tools = [tool])
911hidden = hide(target)
912"#;
913
914 let program = crate::Program::parse_no_errs(code).unwrap();
915 let ctx = crate::ExecutorContext::new_mock(None).await;
916 let outcome = ctx.run_mock(&program, &MockConfig::default()).await;
917 ctx.close().await;
918 let outcome = outcome.unwrap();
919
920 assert!(
921 outcome.issues.iter().any(|issue| {
922 issue.severity == Severity::Warning
923 && issue.tag == Tag::Deprecated
924 && issue
925 .message
926 .contains("Calling `hide` with a consumed solid is deprecated")
927 && issue
928 .message
929 .contains("`target` was already consumed by a `subtract` operation")
930 }),
931 "expected hide consumed-solid deprecation warning, got: {:#?}",
932 outcome.issues
933 );
934 }
935
936 #[tokio::test(flavor = "multi_thread")]
937 async fn test_hide_helix() {
938 let ast = r#"helixPath = helix(
939 axis = Z,
940 radius = 5,
941 length = 10,
942 revolutions = 3,
943 angleStart = 360,
944 ccw = false,
945)
946
947hide(helixPath)
948"#;
949 parse_execute(ast).await.unwrap();
950 }
951
952 #[tokio::test(flavor = "multi_thread")]
953 async fn test_hide_sketch_block() {
954 let ast = r#"sketch001 = sketch(on = XY) {
955 circle001 = circle(start = [var 1.16mm, var 4.24mm], center = [var -1.81mm, var -0.5mm])
956}
957
958hide(sketch001)
959"#;
960 parse_execute(ast).await.unwrap();
961 }
962
963 #[tokio::test(flavor = "multi_thread")]
964 async fn test_hide_plane() {
965 let ast = r#"plane001 = offsetPlane(YZ, offset = 500)
966
967hide(plane001)
968"#;
969 let result = parse_execute(ast).await.unwrap();
970 let object_visible_commands = result
971 .root_module_artifact_commands()
972 .iter()
973 .filter_map(|artifact_command| match &artifact_command.command {
974 ModelingCmd::ObjectVisible(object_visible) => Some(object_visible),
975 _ => None,
976 })
977 .collect::<Vec<_>>();
978
979 assert_eq!(
980 object_visible_commands.len(),
981 1,
982 "expected exactly one ObjectVisible command, got: {:#?}",
983 result.root_module_artifact_commands()
984 );
985 assert!(
986 object_visible_commands[0].hidden,
987 "expected ObjectVisible command to hide the plane"
988 );
989 }
990
991 #[tokio::test(flavor = "multi_thread")]
992 async fn test_hide_no_objects() {
993 let ast = r#"hidden = hide()"#;
994 let result = parse_execute(ast).await;
995 assert!(result.is_err());
996 assert_eq!(
997 result.unwrap_err().message(),
998 r#"This function expects an unlabeled first parameter, but you haven't passed it one."#.to_string()
999 );
1000 }
1001}