Skip to main content

kcl_lib/std/
sweep.rs

1//! Standard library sweep.
2
3use anyhow::Result;
4use kcmc::ModelingCmd;
5use kcmc::each_cmd as mcmd;
6use kcmc::length_unit::LengthUnit;
7use kcmc::shared::BodyType;
8use kittycad_modeling_cmds::id::ModelingCmdId;
9use kittycad_modeling_cmds::shared::RelativeTo;
10use kittycad_modeling_cmds::websocket::ModelingCmdReq;
11use kittycad_modeling_cmds::{self as kcmc};
12use serde::Serialize;
13
14use super::DEFAULT_TOLERANCE_MM;
15use super::args::TyF64;
16use crate::CompilationIssue;
17use crate::KclVersion;
18use crate::errors::KclError;
19use crate::errors::KclErrorDetails;
20use crate::execution::ExecState;
21use crate::execution::Extrudable;
22use crate::execution::Helix;
23use crate::execution::KclValue;
24use crate::execution::ModelingCmdMeta;
25use crate::execution::ProfileClosed;
26use crate::execution::Segment;
27use crate::execution::Sketch;
28use crate::execution::SketchSurface;
29use crate::execution::Solid;
30use crate::execution::annotations;
31use crate::execution::types::ArrayLen;
32use crate::execution::types::RuntimeType;
33use crate::parsing::ast::types::TagNode;
34use crate::std::Args;
35use crate::std::extrude::BeingExtruded;
36use crate::std::extrude::build_segment_surface_sketch;
37use crate::std::extrude::coerce_extrude_targets;
38use crate::std::extrude::do_post_extrude;
39
40/// A path to sweep along.
41#[derive(Debug, Clone, Serialize, PartialEq, ts_rs::TS)]
42#[ts(export)]
43#[serde(untagged)]
44#[allow(clippy::large_enum_variant)]
45pub enum SweepPath {
46    Sketch(Sketch),
47    Helix(Box<Helix>),
48    Segments(Vec<Segment>),
49}
50
51/// The outer (typical) sweep path gets converted to this, losing some of its variants in the conversion.
52#[allow(clippy::large_enum_variant)]
53enum InnerSweepPath {
54    Sketch(Sketch),
55    Helix(Box<Helix>),
56}
57
58/// Create a 3D surface or solid by sweeping a sketch along a path.
59pub async fn sweep(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
60    let sketch_values = args.get_unlabeled_kw_arg(
61        "sketches",
62        &RuntimeType::Array(
63            Box::new(RuntimeType::Union(vec![
64                RuntimeType::sketch(),
65                RuntimeType::segment(),
66                RuntimeType::face(),
67                RuntimeType::tagged_face(),
68            ])),
69            ArrayLen::Minimum(1),
70        ),
71        exec_state,
72    )?;
73    let path: SweepPath = args.get_kw_arg(
74        "path",
75        &RuntimeType::Union(vec![
76            RuntimeType::sketch(),
77            RuntimeType::helix(),
78            RuntimeType::Array(Box::new(RuntimeType::segment()), ArrayLen::Minimum(1)),
79        ]),
80        exec_state,
81    )?;
82    let sectional = args.get_kw_arg_opt("sectional", &RuntimeType::bool(), exec_state)?;
83    if sectional.is_some()
84        && exec_state.kcl_version() >= KclVersion::V3Preview
85        && let Some(arg) = args.labeled.get("sectional")
86    {
87        exec_state.warn(
88            CompilationIssue::err(
89                arg.source_range,
90                "Sectional sweeps are not supported in KCL 3. The `sectional` argument has no effect; remove it."
91                    .to_owned(),
92            ),
93            annotations::WARN_NOT_YET_SUPPORTED,
94        );
95    }
96    let tolerance: Option<TyF64> = args.get_kw_arg_opt("tolerance", &RuntimeType::length(), exec_state)?;
97    let tag_start = args.get_kw_arg_opt("tagStart", &RuntimeType::tag_decl(), exec_state)?;
98    let tag_end = args.get_kw_arg_opt("tagEnd", &RuntimeType::tag_decl(), exec_state)?;
99    let body_type: Option<BodyType> = args.get_kw_arg_opt("bodyType", &RuntimeType::string(), exec_state)?;
100    // KCL 3.0 removes the version parameter (`removed_in` in sketch.kcl),
101    // so from 3.0 on this is always None, and the newest algorithm is used.
102    let version: Option<u32> = args.get_kw_arg_opt("version", &RuntimeType::count(), exec_state)?;
103    // Replaced by 2 args below.
104    let relative_to: Option<String> = args.get_kw_arg_opt("relativeTo", &RuntimeType::string(), exec_state)?;
105    // Replaces `relative_to`.
106    let translate_profile_to_path: Option<bool> =
107        args.get_kw_arg_opt("translateProfileToPath", &RuntimeType::bool(), exec_state)?;
108    let orient_profile_perpendicular: Option<bool> =
109        args.get_kw_arg_opt("orientProfilePerpendicular", &RuntimeType::bool(), exec_state)?;
110
111    let path = match path {
112        SweepPath::Segments(segments) => InnerSweepPath::Sketch(
113            build_segment_surface_sketch(segments, exec_state, &args.ctx, args.source_range).await?,
114        ),
115        SweepPath::Sketch(sketch) => InnerSweepPath::Sketch(sketch),
116        SweepPath::Helix(helix) => InnerSweepPath::Helix(helix),
117    };
118
119    let sketches = coerce_extrude_targets(
120        sketch_values,
121        body_type.unwrap_or_default(),
122        tag_start.as_ref(),
123        tag_end.as_ref(),
124        exec_state,
125        &args.ctx,
126        args.source_range,
127    )
128    .await?;
129
130    let value = inner_sweep(
131        sketches,
132        path,
133        sectional,
134        tolerance,
135        relative_to,
136        translate_profile_to_path,
137        orient_profile_perpendicular,
138        tag_start,
139        tag_end,
140        body_type,
141        version,
142        exec_state,
143        args,
144    )
145    .await?;
146    Ok(value.into())
147}
148
149enum ProfileTransform {
150    RelativeTo(RelativeTo),
151    SeparateFlags {
152        translate_profile_to_path: bool,
153        orient_profile_perpendicular: bool,
154    },
155}
156
157impl ProfileTransform {
158    fn relative_to(&self) -> Option<RelativeTo> {
159        match self {
160            ProfileTransform::RelativeTo(relative_to) => Some(*relative_to),
161            ProfileTransform::SeparateFlags { .. } => None,
162        }
163    }
164
165    fn translate_profile_to_path(&self) -> Option<bool> {
166        match self {
167            ProfileTransform::RelativeTo(..) => None,
168            ProfileTransform::SeparateFlags {
169                translate_profile_to_path,
170                ..
171            } => Some(*translate_profile_to_path),
172        }
173    }
174    fn orient_profile_perpendicular(&self) -> Option<bool> {
175        match self {
176            ProfileTransform::RelativeTo(..) => None,
177            ProfileTransform::SeparateFlags {
178                orient_profile_perpendicular,
179                ..
180            } => Some(*orient_profile_perpendicular),
181        }
182    }
183}
184
185/// The sweep algorithm version to send when the user does not set `version`.
186/// KCL 3.0 removes the version parameter (`removed_in` in sketch.kcl), so
187/// from 3.0 on this is always what is sent.
188fn default_sweep_version(kcl_version: KclVersion) -> Option<u8> {
189    if kcl_version <= KclVersion::V2 {
190        // Unspecified, so the engine chooses. It currently chooses version 1.
191        None
192    } else {
193        // KCL 3.0 and later always use the newest algorithm.
194        Some(2)
195    }
196}
197
198/// The value of `orientProfilePerpendicular` when the user does not set it.
199fn default_orient_profile_perpendicular(kcl_version: KclVersion, translate_profile_to_path: bool) -> bool {
200    if kcl_version <= KclVersion::V2 {
201        false
202    } else {
203        // From a mechanical engineer on what is intuitive:
204        // - `translateProfileToPath` should default to false.
205        // - `orientProfilePerpendicular` should default to false,
206        //   unless `translateProfileToPath` is true, in which case both should be true.
207        translate_profile_to_path
208    }
209}
210
211#[allow(clippy::too_many_arguments)]
212async fn inner_sweep(
213    sketches: Vec<Extrudable>,
214    path: InnerSweepPath,
215    sectional: Option<bool>,
216    tolerance: Option<TyF64>,
217    relative_to: Option<String>,
218    translate_profile_to_path: Option<bool>,
219    orient_profile_perpendicular: Option<bool>,
220    tag_start: Option<TagNode>,
221    tag_end: Option<TagNode>,
222    body_type: Option<BodyType>,
223    version: Option<u32>,
224    exec_state: &mut ExecState,
225    args: Args,
226) -> Result<Vec<Solid>, KclError> {
227    let body_type = body_type.unwrap_or_default();
228    if matches!(body_type, BodyType::Solid) && sketches.iter().any(|sk| matches!(sk.is_closed(), ProfileClosed::No)) {
229        return Err(KclError::new_semantic(KclErrorDetails::new(
230            "Cannot solid sweep an open profile. Either close the profile, or use a surface sweep.".to_owned(),
231            vec![args.source_range],
232        )));
233    }
234
235    let kcl_version = exec_state.kcl_version();
236    let version = version
237        .map(|v| {
238            u8::try_from(v).map_err(|_e| {
239                KclError::new_argument(KclErrorDetails::new(
240                    format!("Invalid version {}", v),
241                    vec![args.source_range],
242                ))
243            })
244        })
245        .transpose()?
246        .or_else(|| default_sweep_version(kcl_version));
247
248    let trajectory = ModelingCmdId::from(match path {
249        InnerSweepPath::Sketch(sketch) => sketch.id,
250        InnerSweepPath::Helix(helix) => helix.value,
251    });
252
253    let profile_transform = match (relative_to, translate_profile_to_path, orient_profile_perpendicular) {
254        // Before KCL 3.0, when the user doesn't give any flags at all, the
255        // legacy `relativeTo` behavior is implied by the algorithm version.
256        (None, None, None) if kcl_version <= KclVersion::V2 => ProfileTransform::RelativeTo(match version {
257            // We default to algorithm v1 if no choice was made.
258            None | Some(1) => RelativeTo::TrajectoryCurve,
259            // 0 means "let engine choose". Engine currently chooses version 1.
260            Some(0) => RelativeTo::TrajectoryCurve,
261            // Algorithm version 2 defaults to SketchPlane.
262            Some(2) => RelativeTo::SketchPlane,
263            // Error on unknown algorithm.
264            Some(other) => {
265                return Err(KclError::new_argument(KclErrorDetails::new(
266                    format!("Invalid version {}", other),
267                    vec![args.source_range],
268                )));
269            }
270        }),
271
272        // If the "new" profile transformation args are set. KCL 3.0 removed
273        // `relativeTo` (see `removed_in` in sketch.kcl), so from 3.0 on,
274        // this is also the case when no flags are set at all.
275        (None, translate, orient) => {
276            let translate_profile_to_path = translate.unwrap_or_default();
277            ProfileTransform::SeparateFlags {
278                translate_profile_to_path,
279                orient_profile_perpendicular: orient
280                    .unwrap_or_else(|| default_orient_profile_perpendicular(kcl_version, translate_profile_to_path)),
281            }
282        }
283
284        // RelativeTo was set, but none of its replacements were.
285        (Some(relative_to), None, None) => ProfileTransform::RelativeTo(match relative_to.as_str() {
286            "sketchPlane" => RelativeTo::SketchPlane,
287            "trajectoryCurve" => RelativeTo::TrajectoryCurve,
288            _ => {
289                return Err(KclError::new_syntax(crate::errors::KclErrorDetails::new(
290                    "If you provide relativeTo, it must either be 'sketchPlane' or 'trajectoryCurve'".to_owned(),
291                    vec![args.source_range],
292                )));
293            }
294        }),
295
296        // RelativeTo was set, but also one of its replacements was.
297        // This is an error.
298        (Some(_relative_to), _, _) => {
299            return Err(KclError::new_argument(crate::errors::KclErrorDetails::new(
300                    "If you provide 'relativeTo', you cannot provide 'translateProfileToPath' or 'orientProfilePerpendicular'. Those arguments replace 'relativeTo', please use them instead.".to_owned(),
301                    vec![args.source_range],
302                )));
303        }
304    };
305
306    let mut solids = Vec::new();
307    for sketch in &sketches {
308        let sweep_cmd_id = exec_state.next_uuid();
309        let sketch_or_face_id = sketch.id_to_extrude(exec_state, &args, false).await?;
310        let cmd = ModelingCmd::from(
311            mcmd::Sweep::builder()
312                .target(sketch_or_face_id.into())
313                .trajectory(trajectory)
314                .sectional(sectional.unwrap_or(false))
315                .tolerance(LengthUnit(
316                    tolerance
317                        .as_ref()
318                        .map(|t| t.unwrap_to_mm())
319                        .unwrap_or(DEFAULT_TOLERANCE_MM),
320                ))
321                .maybe_relative_to(profile_transform.relative_to())
322                .maybe_orient_profile_perpendicular(profile_transform.orient_profile_perpendicular())
323                .maybe_translate_profile_to_path(profile_transform.translate_profile_to_path())
324                .body_type(body_type)
325                .maybe_version(version)
326                .build(),
327        );
328
329        let being_extruded = match sketch {
330            Extrudable::Sketch(..) => BeingExtruded::Sketch,
331            Extrudable::FaceTag(face_tag) => {
332                let face_id = sketch_or_face_id;
333                let solid_id = match face_tag.geometry() {
334                    Some(crate::execution::Geometry::Solid(solid)) => solid.id,
335                    Some(crate::execution::Geometry::Sketch(sketch)) => match sketch.on {
336                        SketchSurface::Face(face) => face.parent_solid.solid_id,
337                        SketchSurface::Plane(_) => sketch.id,
338                    },
339                    None => face_id,
340                };
341                BeingExtruded::Face { face_id, solid_id }
342            }
343            Extrudable::Face(face) => BeingExtruded::Face {
344                face_id: face.id,
345                solid_id: face.parent_solid.solid_id,
346            },
347            Extrudable::EdgeTag(_) => BeingExtruded::Edge,
348            Extrudable::Edge(_) => BeingExtruded::Edge,
349            Extrudable::EdgeSpecifier(_) => BeingExtruded::Edge,
350        };
351
352        if let Some(post_extr_sketch) = sketch.as_sketch() {
353            let cmds = post_extr_sketch.build_sketch_mode_cmds(
354                exec_state,
355                ModelingCmdReq {
356                    cmd_id: sweep_cmd_id.into(),
357                    cmd,
358                },
359            );
360            exec_state
361                .batch_modeling_cmds(ModelingCmdMeta::from_args_id(exec_state, &args, sweep_cmd_id), &cmds)
362                .await?;
363            solids.push(
364                do_post_extrude(
365                    &post_extr_sketch,
366                    sweep_cmd_id.into(),
367                    sectional.unwrap_or(false),
368                    &super::extrude::NamedCapTags {
369                        start: tag_start.as_ref(),
370                        end: tag_end.as_ref(),
371                    },
372                    kittycad_modeling_cmds::shared::ExtrudeMethod::New,
373                    exec_state,
374                    &args,
375                    None,
376                    None,
377                    body_type,
378                    being_extruded,
379                )
380                .await?,
381            );
382        } else {
383            return Err(KclError::new_type(KclErrorDetails::new(
384                "Expected a sketch for sweeping".to_owned(),
385                vec![args.source_range],
386            )));
387        }
388    }
389
390    // Hide the artifact from the sketch or helix.
391    exec_state
392        .batch_modeling_cmd(
393            ModelingCmdMeta::from_args(exec_state, &args),
394            ModelingCmd::from(
395                mcmd::ObjectVisible::builder()
396                    .object_id(trajectory.into())
397                    .hidden(true)
398                    .build(),
399            ),
400        )
401        .await?;
402
403    Ok(solids)
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use crate::errors::Severity;
410    use crate::execution::ExecTestResults;
411    use crate::execution::parse_execute;
412
413    /// What each KCL version sends to the engine when the user leaves out
414    /// `translateProfileToPath`, `orientProfilePerpendicular`, and `version`.
415    #[tokio::test(flavor = "multi_thread")]
416    async fn sweep_defaults_depend_on_kcl_version() {
417        // Before KCL 3.0, the legacy `relative_to` field is sent instead of
418        // the two flags, and the algorithm version is left to the engine.
419        let cmd = emitted_sweep("2.0", "").await;
420        assert_eq!(cmd.relative_to, Some(RelativeTo::TrajectoryCurve));
421        assert_eq!(cmd.translate_profile_to_path, None);
422        assert_eq!(cmd.orient_profile_perpendicular, None);
423        assert_eq!(cmd.version, None);
424
425        // From KCL 3.0, the two flags are always sent, both false by default,
426        // and the algorithm is always version 2.
427        let cmd = emitted_sweep("\"3.0-preview\"", "").await;
428        assert_eq!(cmd.relative_to, None);
429        assert_eq!(cmd.translate_profile_to_path, Some(false));
430        assert_eq!(cmd.orient_profile_perpendicular, Some(false));
431        assert_eq!(cmd.version, Some(2));
432    }
433
434    /// From KCL 3.0, `orientProfilePerpendicular` defaults to the value of
435    /// `translateProfileToPath`. Before that, the two flags are independent.
436    #[tokio::test(flavor = "multi_thread")]
437    async fn orient_profile_perpendicular_follows_translate_in_kcl_3() {
438        let cmd = emitted_sweep("\"3.0-preview\"", ", translateProfileToPath = true").await;
439        assert_eq!(cmd.translate_profile_to_path, Some(true));
440        assert_eq!(cmd.orient_profile_perpendicular, Some(true));
441
442        let cmd = emitted_sweep("\"3.0-preview\"", ", translateProfileToPath = false").await;
443        assert_eq!(cmd.translate_profile_to_path, Some(false));
444        assert_eq!(cmd.orient_profile_perpendicular, Some(false));
445
446        let cmd = emitted_sweep("2.0", ", translateProfileToPath = true").await;
447        assert_eq!(cmd.translate_profile_to_path, Some(true));
448        assert_eq!(cmd.orient_profile_perpendicular, Some(false));
449    }
450
451    /// An explicit `orientProfilePerpendicular` wins over the default,
452    /// whatever `translateProfileToPath` is.
453    #[tokio::test(flavor = "multi_thread")]
454    async fn explicit_orient_profile_perpendicular_overrides_kcl_default() {
455        let cmd = emitted_sweep(
456            "\"3.0-preview\"",
457            ", translateProfileToPath = true, orientProfilePerpendicular = false",
458        )
459        .await;
460        assert_eq!(cmd.translate_profile_to_path, Some(true));
461        assert_eq!(cmd.orient_profile_perpendicular, Some(false));
462
463        let cmd = emitted_sweep("\"3.0-preview\"", ", orientProfilePerpendicular = true").await;
464        assert_eq!(cmd.translate_profile_to_path, Some(false));
465        assert_eq!(cmd.orient_profile_perpendicular, Some(true));
466    }
467
468    /// If the user chooses a sweep algorithm version, KCL should respect it,
469    /// and not use that KCL version's default sweep algorithm version.
470    #[tokio::test(flavor = "multi_thread")]
471    async fn explicit_sweep_version_overrides_kcl_default() {
472        assert_eq!(emitted_sweep("2.0", ", version = 2").await.version, Some(2));
473        assert_eq!(emitted_sweep("2.0", ", version = 0").await.version, Some(0));
474    }
475
476    /// KCL 3.0 removed `sweep(version = )`. Passing it is reported like any
477    /// other unknown argument, and the newest algorithm is used.
478    #[tokio::test(flavor = "multi_thread")]
479    async fn sweep_version_is_removed_in_kcl_3() {
480        let result = run_sweep("\"3.0-preview\"", ", version = 1").await;
481        assert!(
482            result
483                .issues()
484                .iter()
485                .any(|issue| {
486                    issue.message
487                        == "`version` is not an argument of `sweep`; it was removed in KCL 3.0, but this program uses KCL 3.0-preview"
488                }),
489            "issues: {:#?}",
490            result.issues()
491        );
492        assert_eq!(emitted_sweep_cmd(&result).version, Some(2));
493    }
494
495    #[tokio::test(flavor = "multi_thread")]
496    async fn sectional_warns_only_in_kcl_3() {
497        for version in ["\"3.0-preview\"", "3"] {
498            for value in ["true", "false"] {
499                let result = run_sweep(version, &format!(", sectional = {value}")).await;
500                let warnings: Vec<_> = result
501                    .issues()
502                    .iter()
503                    .filter(|issue| issue.message.contains("Sectional sweeps are not supported in KCL 3"))
504                    .collect();
505                assert_eq!(warnings.len(), 1, "issues: {:#?}", result.issues());
506                assert_eq!(warnings[0].severity, Severity::Warning);
507            }
508            let result = run_sweep(version, "").await;
509            assert!(
510                result
511                    .issues()
512                    .iter()
513                    .all(|issue| !issue.message.contains("Sectional sweeps are not supported"))
514            );
515        }
516
517        let result = run_sweep("2.0", ", sectional = true").await;
518        assert!(
519            result
520                .issues()
521                .iter()
522                .all(|issue| !issue.message.contains("Sectional sweeps are not supported"))
523        );
524    }
525
526    /// Sweep a circle along a line under the given KCL version, passing
527    /// `extra_args` to `sweep`, and return the `Sweep` command sent to the
528    /// engine.
529    async fn emitted_sweep(kcl_version: &str, extra_args: &str) -> mcmd::Sweep {
530        emitted_sweep_cmd(&run_sweep(kcl_version, extra_args).await)
531    }
532
533    /// Sweep a circle along a line under the given KCL version, passing
534    /// `extra_args` to `sweep`.
535    async fn run_sweep(kcl_version: &str, extra_args: &str) -> ExecTestResults {
536        let code = format!(
537            r#"@settings(defaultLengthUnit = mm, kclVersion = {kcl_version})
538
539profileSketch = sketch(on = XY) {{
540  c = circle(start = [var 10mm, var 0mm], center = [var 0mm, var 0mm])
541}}
542profile = region(segments = [profileSketch.c])
543
544pathSketch = sketch(on = XZ) {{
545  path = line(start = [var 0mm, var 0mm], end = [var 0mm, var 100mm])
546}}
547
548sweep(profile, path = pathSketch{extra_args})
549"#
550        );
551        parse_execute(&code).await.unwrap()
552    }
553
554    /// The `Sweep` command the run sent to the engine.
555    fn emitted_sweep_cmd(result: &ExecTestResults) -> mcmd::Sweep {
556        result
557            .root_module_artifact_commands()
558            .iter()
559            .find_map(|artifact_command| match &artifact_command.command {
560                ModelingCmd::Sweep(cmd) => Some(cmd.clone()),
561                _ => None,
562            })
563            .expect("sweep should emit a Sweep command")
564    }
565}