Skip to main content

kcl_lib/std/
loft.rs

1//! Standard library lofts.
2
3use std::num::NonZeroU32;
4
5use anyhow::Result;
6use kcmc::ModelingCmd;
7use kcmc::each_cmd as mcmd;
8use kcmc::length_unit::LengthUnit;
9use kcmc::shared::BodyType;
10use kittycad_modeling_cmds as kcmc;
11
12use super::DEFAULT_TOLERANCE_MM;
13use super::args::TyF64;
14use crate::errors::KclError;
15use crate::errors::KclErrorDetails;
16use crate::execution::ExecState;
17use crate::execution::ExecutorContext;
18use crate::execution::KclValue;
19use crate::execution::ModelingCmdMeta;
20use crate::execution::ProfileClosed;
21use crate::execution::Sketch;
22use crate::execution::Solid;
23use crate::execution::types::ArrayLen;
24use crate::execution::types::RuntimeType;
25use crate::parsing::ast::types::TagNode;
26use crate::std::Args;
27use crate::std::args::FromKclValue;
28use crate::std::extrude::build_segment_surface_sketch;
29use crate::std::extrude::do_post_extrude;
30
31const DEFAULT_V_DEGREE: u32 = 2;
32
33/// Create a 3D surface or solid by interpolating between two or more sketches.
34pub async fn loft(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
35    let sketch_values: Vec<KclValue> = args.get_unlabeled_kw_arg(
36        "sketches",
37        &RuntimeType::Array(
38            Box::new(RuntimeType::Union(vec![RuntimeType::sketch(), RuntimeType::segment()])),
39            ArrayLen::Minimum(2),
40        ),
41        exec_state,
42    )?;
43    let v_degree: NonZeroU32 = args
44        .get_kw_arg_opt("vDegree", &RuntimeType::count(), exec_state)?
45        .unwrap_or(NonZeroU32::new(DEFAULT_V_DEGREE).unwrap());
46    // Attempt to approximate rational curves (such as arcs) using a bezier.
47    // This will remove banding around interpolations between arcs and non-arcs.  It may produce errors in other scenarios
48    // Over time, this field won't be necessary.
49    let bez_approximate_rational = args
50        .get_kw_arg_opt("bezApproximateRational", &RuntimeType::bool(), exec_state)?
51        .unwrap_or(false);
52    // This can be set to override the automatically determined topological base curve, which is usually the first section encountered.
53    let base_curve_index: Option<u32> = args.get_kw_arg_opt("baseCurveIndex", &RuntimeType::count(), exec_state)?;
54    // Tolerance for the loft operation.
55    let tolerance: Option<TyF64> = args.get_kw_arg_opt("tolerance", &RuntimeType::length(), exec_state)?;
56    let tag_start = args.get_kw_arg_opt("tagStart", &RuntimeType::tag_decl(), exec_state)?;
57    let tag_end = args.get_kw_arg_opt("tagEnd", &RuntimeType::tag_decl(), exec_state)?;
58    let body_type: Option<BodyType> = args.get_kw_arg_opt("bodyType", &RuntimeType::string(), exec_state)?;
59
60    let sketches = coerce_loft_targets(
61        sketch_values,
62        body_type.unwrap_or_default(),
63        tag_start.as_ref(),
64        tag_end.as_ref(),
65        exec_state,
66        &args.ctx,
67        args.source_range,
68    )
69    .await?;
70    let value = inner_loft(
71        sketches,
72        v_degree,
73        bez_approximate_rational,
74        base_curve_index,
75        tolerance,
76        tag_start,
77        tag_end,
78        body_type,
79        exec_state,
80        args,
81    )
82    .await?;
83    Ok(KclValue::Solid { value })
84}
85
86async fn coerce_loft_targets(
87    sketch_values: Vec<KclValue>,
88    body_type: BodyType,
89    tag_start: Option<&TagNode>,
90    tag_end: Option<&TagNode>,
91    exec_state: &mut ExecState,
92    ctx: &ExecutorContext,
93    source_range: crate::SourceRange,
94) -> Result<Vec<Sketch>, KclError> {
95    let mut sketches = Vec::new();
96    let mut segments = Vec::new();
97
98    for value in sketch_values {
99        if let Some(segment) = value.clone().into_segment() {
100            segments.push(segment);
101            continue;
102        }
103
104        let Some(sketch) = Sketch::from_kcl_val(&value) else {
105            return Err(KclError::new_type(KclErrorDetails::new(
106                "Expected sketches or solved sketch segments for loft.".to_owned(),
107                vec![source_range],
108            )));
109        };
110        sketches.push(sketch);
111    }
112
113    if !segments.is_empty() && !sketches.is_empty() {
114        return Err(KclError::new_semantic(KclErrorDetails::new(
115            "Cannot loft sketch segments together with sketches in the same call. Use separate `loft()` calls."
116                .to_owned(),
117            vec![source_range],
118        )));
119    }
120
121    if !segments.is_empty() {
122        if !matches!(body_type, BodyType::Surface) {
123            return Err(KclError::new_semantic(KclErrorDetails::new(
124                "Lofting sketch segments is only supported for surface lofts. Set `bodyType = SURFACE`.".to_owned(),
125                vec![source_range],
126            )));
127        }
128
129        if tag_start.is_some() || tag_end.is_some() {
130            return Err(KclError::new_semantic(KclErrorDetails::new(
131                "`tagStart` and `tagEnd` are not supported when lofting sketch segments. Segment surface lofts do not create start or end caps."
132                    .to_owned(),
133                vec![source_range],
134            )));
135        }
136
137        let mut loft_sections = Vec::with_capacity(segments.len());
138        for segment in segments {
139            loft_sections.push(build_segment_surface_sketch(vec![segment], exec_state, ctx, source_range).await?);
140        }
141        return Ok(loft_sections);
142    }
143
144    Ok(sketches)
145}
146
147#[allow(clippy::too_many_arguments)]
148async fn inner_loft(
149    sketches: Vec<Sketch>,
150    v_degree: NonZeroU32,
151    bez_approximate_rational: bool,
152    base_curve_index: Option<u32>,
153    tolerance: Option<TyF64>,
154    tag_start: Option<TagNode>,
155    tag_end: Option<TagNode>,
156    body_type: Option<BodyType>,
157    exec_state: &mut ExecState,
158    args: Args,
159) -> Result<Box<Solid>, KclError> {
160    let body_type = body_type.unwrap_or_default();
161    if matches!(body_type, BodyType::Solid) && sketches.iter().any(|sk| matches!(sk.is_closed, ProfileClosed::No)) {
162        return Err(KclError::new_semantic(KclErrorDetails::new(
163            "Cannot solid loft an open profile. Either close the profile, or use a surface loft.".to_owned(),
164            vec![args.source_range],
165        )));
166    }
167
168    // Make sure we have at least two sketches.
169    if sketches.len() < 2 {
170        return Err(KclError::new_semantic(KclErrorDetails::new(
171            format!(
172                "Loft requires at least two sketches, but only {} were provided.",
173                sketches.len()
174            ),
175            vec![args.source_range],
176        )));
177    }
178
179    let id = exec_state.next_uuid();
180    exec_state
181        .batch_modeling_cmd(
182            ModelingCmdMeta::from_args_id(exec_state, &args, id),
183            ModelingCmd::from(if let Some(base_curve_index) = base_curve_index {
184                mcmd::Loft::builder()
185                    .section_ids(sketches.iter().map(|group| group.id).collect())
186                    .bez_approximate_rational(bez_approximate_rational)
187                    .tolerance(LengthUnit(
188                        tolerance.as_ref().map(|t| t.to_mm()).unwrap_or(DEFAULT_TOLERANCE_MM),
189                    ))
190                    .v_degree(v_degree)
191                    .body_type(body_type)
192                    .base_curve_index(base_curve_index)
193                    .build()
194            } else {
195                mcmd::Loft::builder()
196                    .section_ids(sketches.iter().map(|group| group.id).collect())
197                    .bez_approximate_rational(bez_approximate_rational)
198                    .tolerance(LengthUnit(
199                        tolerance.as_ref().map(|t| t.to_mm()).unwrap_or(DEFAULT_TOLERANCE_MM),
200                    ))
201                    .v_degree(v_degree)
202                    .body_type(body_type)
203                    .build()
204            }),
205        )
206        .await?;
207
208    // Using the first sketch as the base curve, idk we might want to change this later.
209    let mut sketch = sketches[0].clone();
210    // A loft creates a new engine body rather than reusing its first section.
211    // Keep both ID fields on the new body so follow-up operations query the
212    // loft instead of the first section's path object.
213    sketch.id = id;
214    sketch.original_id = id;
215    Ok(Box::new(
216        do_post_extrude(
217            &sketch,
218            id.into(),
219            false,
220            &super::extrude::NamedCapTags {
221                start: tag_start.as_ref(),
222                end: tag_end.as_ref(),
223            },
224            kittycad_modeling_cmds::shared::ExtrudeMethod::New,
225            exec_state,
226            &args,
227            None,
228            None,
229            body_type,
230            crate::std::extrude::BeingExtruded::Sketch,
231        )
232        .await?,
233    ))
234}
235
236#[cfg(test)]
237mod tests {
238    use kcl_api::UnitLength;
239
240    use super::*;
241    use crate::execution::AbstractSegment;
242    use crate::execution::KclValue;
243    use crate::execution::Plane;
244    use crate::execution::Segment;
245    use crate::execution::SegmentKind;
246    use crate::execution::SegmentRepr;
247    use crate::execution::SketchSurface;
248    use crate::execution::parse_execute;
249    use crate::execution::types::NumericType;
250    use crate::execution::types::NumericTypeExt;
251    use crate::front::Expr;
252    use crate::front::LineCtor;
253    use crate::front::Number;
254    use crate::front::ObjectId;
255    use crate::front::Point2d;
256    use crate::parsing::ast::types::TagDeclarator;
257    use crate::std::sketch::PlaneData;
258
259    fn point_expr(x: f64, y: f64) -> Point2d<Expr> {
260        Point2d {
261            x: Expr::Var(Number::from((x, UnitLength::Millimeters))),
262            y: Expr::Var(Number::from((y, UnitLength::Millimeters))),
263        }
264    }
265
266    fn line_segment_value(exec_state: &mut ExecState, plane_data: PlaneData, object_id_seed: usize) -> KclValue {
267        let plane = Plane::from_plane_data_skipping_engine(plane_data, exec_state).unwrap();
268        let start = [TyF64::new(-2.0, NumericType::mm()), TyF64::new(0.0, NumericType::mm())];
269        let end = [TyF64::new(2.0, NumericType::mm()), TyF64::new(0.0, NumericType::mm())];
270        let segment = Segment {
271            id: exec_state.next_uuid(),
272            object_id: ObjectId(object_id_seed),
273            kind: SegmentKind::Line {
274                start,
275                end,
276                ctor: Box::new(LineCtor {
277                    start: point_expr(-2.0, 0.0),
278                    end: point_expr(2.0, 0.0),
279                    construction: None,
280                }),
281                start_object_id: ObjectId(object_id_seed + 1),
282                end_object_id: ObjectId(object_id_seed + 2),
283                start_freedom: None,
284                end_freedom: None,
285                construction: false,
286            },
287            surface: SketchSurface::Plane(Box::new(plane)),
288            sketch_id: exec_state.next_uuid(),
289            sketch: None,
290            tag: None,
291            meta: vec![],
292            node_path: None,
293        };
294        KclValue::Segment {
295            value: Box::new(AbstractSegment {
296                repr: SegmentRepr::Solved {
297                    segment: Box::new(segment),
298                },
299                meta: vec![],
300            }),
301        }
302    }
303
304    #[tokio::test(flavor = "multi_thread")]
305    async fn segment_loft_supports_sections_from_different_sketches() {
306        let ctx = ExecutorContext::new_mock(None).await;
307        let mut exec_state = ExecState::new(&ctx);
308        let sketches = coerce_loft_targets(
309            vec![
310                line_segment_value(&mut exec_state, PlaneData::XY, 1),
311                line_segment_value(&mut exec_state, PlaneData::NegXY, 10),
312                line_segment_value(&mut exec_state, PlaneData::XZ, 20),
313            ],
314            BodyType::Surface,
315            None,
316            None,
317            &mut exec_state,
318            &ctx,
319            crate::SourceRange::default(),
320        )
321        .await
322        .unwrap();
323
324        assert_eq!(sketches.len(), 3);
325        assert!(sketches.iter().all(|sketch| sketch.paths.len() == 1));
326        assert_ne!(sketches[0].id, sketches[1].id);
327        assert_ne!(sketches[1].id, sketches[2].id);
328        ctx.close().await;
329    }
330
331    #[tokio::test(flavor = "multi_thread")]
332    async fn segment_loft_rejects_cap_tags() {
333        let ctx = ExecutorContext::new_mock(None).await;
334        let mut exec_state = ExecState::new(&ctx);
335        let err = coerce_loft_targets(
336            vec![line_segment_value(&mut exec_state, PlaneData::XY, 1)],
337            BodyType::Surface,
338            Some(&TagDeclarator::new("cap_start")),
339            None,
340            &mut exec_state,
341            &ctx,
342            crate::SourceRange::default(),
343        )
344        .await
345        .unwrap_err();
346
347        assert!(
348            err.message()
349                .contains("`tagStart` and `tagEnd` are not supported when lofting sketch segments"),
350            "{err:?}"
351        );
352        ctx.close().await;
353    }
354
355    #[tokio::test(flavor = "multi_thread")]
356    async fn loft_uses_its_body_id_for_topology_queries() {
357        let result = parse_execute(
358            r#"@settings(kclVersion = 2.0)
359
360firstSketch = sketch(on = XY) {
361  circle1 = circle(start = [var 10, var 0], center = [var 0, var 0])
362}
363secondSketch = sketch(on = offsetPlane(XY, offset = 10)) {
364  circle1 = circle(start = [var 5, var 0], center = [var 0, var 0])
365}
366
367lofted = loft([
368  region(segments = [firstSketch.circle1]),
369  region(segments = [secondSketch.circle1]),
370])
371"#,
372        )
373        .await
374        .expect("loft executes");
375
376        let KclValue::Solid { value: solid } = result.variable("lofted") else {
377            panic!("`lofted` is not a solid");
378        };
379        assert_eq!(solid.id, solid.topology_id());
380        assert_eq!(
381            solid.sketch().expect("loft retains its base sketch").original_id,
382            solid.id
383        );
384    }
385}