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
189                            .as_ref()
190                            .map(|t| t.unwrap_to_mm())
191                            .unwrap_or(DEFAULT_TOLERANCE_MM),
192                    ))
193                    .v_degree(v_degree)
194                    .body_type(body_type)
195                    .base_curve_index(base_curve_index)
196                    .build()
197            } else {
198                mcmd::Loft::builder()
199                    .section_ids(sketches.iter().map(|group| group.id).collect())
200                    .bez_approximate_rational(bez_approximate_rational)
201                    .tolerance(LengthUnit(
202                        tolerance
203                            .as_ref()
204                            .map(|t| t.unwrap_to_mm())
205                            .unwrap_or(DEFAULT_TOLERANCE_MM),
206                    ))
207                    .v_degree(v_degree)
208                    .body_type(body_type)
209                    .build()
210            }),
211        )
212        .await?;
213
214    // Using the first sketch as the base curve, idk we might want to change this later.
215    let mut sketch = sketches[0].clone();
216    // Record the source region before replacing its topology IDs with the loft's.
217    // Caller-memory tag updates must use its artifact identity: pattern copies
218    // have their own artifact IDs while retaining their source topology IDs.
219    let source_artifact_id = sketch.artifact_id;
220    // A loft creates a new engine body rather than reusing its first section.
221    // Keep both ID fields on the new body so follow-up operations query the
222    // loft instead of the first section's path object.
223    sketch.id = id;
224    sketch.original_id = id;
225    let mut solid = do_post_extrude(
226        &sketch,
227        id.into(),
228        false,
229        &super::extrude::NamedCapTags {
230            start: tag_start.as_ref(),
231            end: tag_end.as_ref(),
232        },
233        kittycad_modeling_cmds::shared::ExtrudeMethod::New,
234        exec_state,
235        &args,
236        None,
237        None,
238        body_type,
239        crate::std::extrude::BeingExtruded::Sketch,
240    )
241    .await?;
242    solid.tag_update_source_artifact_id = Some(source_artifact_id);
243    Ok(Box::new(solid))
244}
245
246#[cfg(test)]
247mod tests {
248    use kcl_api::Expr;
249    use kcl_api::LineCtor;
250    use kcl_api::Number;
251    use kcl_api::ObjectId;
252    use kcl_api::Point2d;
253    use kcl_api::UnitLength;
254
255    use super::*;
256    use crate::execution::AbstractSegment;
257    use crate::execution::ExecTestResults;
258    use crate::execution::Geometry;
259    use crate::execution::KclValue;
260    use crate::execution::Plane;
261    use crate::execution::Segment;
262    use crate::execution::SegmentKind;
263    use crate::execution::SegmentRepr;
264    use crate::execution::SketchSurface;
265    use crate::execution::parse_execute;
266    use crate::execution::types::NumericType;
267    use crate::execution::types::NumericTypeExt;
268    use crate::parsing::ast::types::TagDeclarator;
269    use crate::std::sketch::PlaneData;
270
271    fn point_expr(x: f64, y: f64) -> Point2d<Expr> {
272        Point2d {
273            x: Expr::Var(Number::from((x, UnitLength::Millimeters))),
274            y: Expr::Var(Number::from((y, UnitLength::Millimeters))),
275        }
276    }
277
278    fn line_segment_value(exec_state: &mut ExecState, plane_data: PlaneData, object_id_seed: usize) -> KclValue {
279        let plane = Plane::from_plane_data_skipping_engine(plane_data, exec_state).unwrap();
280        let start = [TyF64::new(-2.0, NumericType::mm()), TyF64::new(0.0, NumericType::mm())];
281        let end = [TyF64::new(2.0, NumericType::mm()), TyF64::new(0.0, NumericType::mm())];
282        let segment = Segment {
283            id: exec_state.next_uuid(),
284            object_id: ObjectId(object_id_seed),
285            kind: SegmentKind::Line {
286                start,
287                end,
288                ctor: Box::new(LineCtor {
289                    start: point_expr(-2.0, 0.0),
290                    end: point_expr(2.0, 0.0),
291                    construction: None,
292                }),
293                start_object_id: ObjectId(object_id_seed + 1),
294                end_object_id: ObjectId(object_id_seed + 2),
295                start_freedom: None,
296                end_freedom: None,
297                construction: false,
298            },
299            surface: SketchSurface::Plane(Box::new(plane)),
300            sketch_id: exec_state.next_uuid(),
301            sketch: None,
302            tag: None,
303            meta: vec![],
304            node_path: None,
305        };
306        KclValue::Segment {
307            value: Box::new(AbstractSegment {
308                repr: SegmentRepr::Solved {
309                    segment: Box::new(segment),
310                },
311                meta: vec![],
312            }),
313        }
314    }
315
316    #[tokio::test(flavor = "multi_thread")]
317    async fn segment_loft_supports_sections_from_different_sketches() {
318        let ctx = ExecutorContext::new_mock(None).await;
319        let mut exec_state = ExecState::new(&ctx);
320        let sketches = coerce_loft_targets(
321            vec![
322                line_segment_value(&mut exec_state, PlaneData::XY, 1),
323                line_segment_value(&mut exec_state, PlaneData::NegXY, 10),
324                line_segment_value(&mut exec_state, PlaneData::XZ, 20),
325            ],
326            BodyType::Surface,
327            None,
328            None,
329            &mut exec_state,
330            &ctx,
331            crate::SourceRange::default(),
332        )
333        .await
334        .unwrap();
335
336        assert_eq!(sketches.len(), 3);
337        assert!(sketches.iter().all(|sketch| sketch.paths.len() == 1));
338        assert_ne!(sketches[0].id, sketches[1].id);
339        assert_ne!(sketches[1].id, sketches[2].id);
340        ctx.close().await;
341    }
342
343    #[tokio::test(flavor = "multi_thread")]
344    async fn segment_loft_rejects_cap_tags() {
345        let ctx = ExecutorContext::new_mock(None).await;
346        let mut exec_state = ExecState::new(&ctx);
347        let err = coerce_loft_targets(
348            vec![line_segment_value(&mut exec_state, PlaneData::XY, 1)],
349            BodyType::Surface,
350            Some(&TagDeclarator::new("cap_start")),
351            None,
352            &mut exec_state,
353            &ctx,
354            crate::SourceRange::default(),
355        )
356        .await
357        .unwrap_err();
358
359        assert!(
360            err.message()
361                .contains("`tagStart` and `tagEnd` are not supported when lofting sketch segments"),
362            "{err:?}"
363        );
364        ctx.close().await;
365    }
366
367    #[tokio::test(flavor = "multi_thread")]
368    async fn loft_uses_its_body_id_for_topology_queries() {
369        let result = parse_execute(
370            r#"@settings(kclVersion = 2.0)
371
372firstSketch = sketch(on = XY) {
373  circle1 = circle(start = [var 10, var 0], center = [var 0, var 0])
374}
375secondSketch = sketch(on = offsetPlane(XY, offset = 10)) {
376  circle1 = circle(start = [var 5, var 0], center = [var 0, var 0])
377}
378
379lofted = loft([
380  region(segments = [firstSketch.circle1]),
381  region(segments = [secondSketch.circle1]),
382])
383"#,
384        )
385        .await
386        .expect("loft executes");
387
388        let KclValue::Solid { value: solid } = result.variable("lofted") else {
389            panic!("`lofted` is not a solid");
390        };
391        assert_eq!(solid.id, solid.topology_id());
392        assert_eq!(
393            solid.sketch().expect("loft retains its base sketch").original_id,
394            solid.id
395        );
396    }
397
398    #[tokio::test(flavor = "multi_thread")]
399    async fn loft_updates_region_edge_tags_to_faces() {
400        for upper_profile in [
401            r#"line1 = line(start = [var 0mm, var 0mm], end = [var 8mm, var 0mm])
402  line2 = line(start = [var 8mm, var 0mm], end = [var 4mm, var 8mm])
403  line3 = line(start = [var 4mm, var 8mm], end = [var 0mm, var 0mm])"#,
404            r#"line1 = line(start = [var 0mm, var 0mm], end = [var 8mm, var 0mm])
405  line2 = line(start = [var 8mm, var 0mm], end = [var 8mm, var 8mm])
406  line3 = line(start = [var 8mm, var 8mm], end = [var 0mm, var 8mm])
407  line4 = line(start = [var 0mm, var 8mm], end = [var 0mm, var 0mm])"#,
408        ] {
409            let upper_constraints = if upper_profile.contains("line4") {
410                "coincident([line3.end, line4.start])\n  coincident([line4.end, line1.start])"
411            } else {
412                "coincident([line3.end, line1.start])"
413            };
414            let program = format!(
415                r#"@settings(kclVersion = 3.0)
416lower = sketch(on = XY) {{
417  line1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
418  line2 = line(start = [var 10mm, var 0mm], end = [var 5mm, var 10mm])
419  line3 = line(start = [var 5mm, var 10mm], end = [var 0mm, var 0mm])
420  coincident([line1.end, line2.start])
421  coincident([line2.end, line3.start])
422  coincident([line3.end, line1.start])
423}}
424upper = sketch(on = offsetPlane(XY, offset = 10mm)) {{
425  {upper_profile}
426  coincident([line1.end, line2.start])
427  coincident([line2.end, line3.start])
428  {upper_constraints}
429}}
430lowerRegion = region(segments = [lower.line1, lower.line2], direction = CW)
431upperRegion = region(segments = [upper.line1, upper.line2], direction = CW)
432body = loft([lowerRegion, upperRegion])
433gdt::annotation(faces = [lowerRegion.tags.line1], annotation = "EDGE", fontSize = 2mm)
434"#
435            );
436            let result = parse_execute(&program).await.expect("loft edge tag resolves to a face");
437            let KclValue::Sketch { value: region } = result.variable("lowerRegion") else {
438                panic!("lowerRegion is not a sketch");
439            };
440            assert!(region.tags["line1"].get_cur_info().unwrap().surface.is_some());
441        }
442    }
443
444    #[tokio::test(flavor = "multi_thread")]
445    async fn loft_updates_linearly_patterned_region_tags_to_faces() {
446        let code = r#"@settings(kclVersion = 3.0, experimentalFeatures = allow)
447lower = sketch(on = XY) {
448  edge = circle(start = [var 10mm, var 0mm], center = [var 0mm, var 0mm])
449}
450upper = sketch(on = offsetPlane(XY, offset = 10mm)) {
451  edge = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
452}
453lowerRegion = region(segments = [lower.edge])
454upperRegion = region(segments = [upper.edge])
455patterned = patternLinear2d(lowerRegion, instances = 2, distance = 20mm, axis = X)
456lowerCopy = patterned[1]
457lowerCopyAlias = lowerCopy
458body = loft([lowerCopy, upperRegion])
459"#;
460        let result = parse_execute(code).await.expect("loft of patterned region executes");
461        let KclValue::Solid { value: body } = result.variable("body") else {
462            panic!("body is not a solid");
463        };
464        let KclValue::Sketch { value: original } = result.variable("lowerRegion") else {
465            panic!("lowerRegion is not a sketch");
466        };
467
468        for name in ["lowerCopy", "lowerCopyAlias"] {
469            let KclValue::Sketch { value: copy } = result.variable(name) else {
470                panic!("{name} is not a sketch");
471            };
472            assert_eq!(copy.original_id, original.original_id);
473            assert_ne!(copy.artifact_id, original.artifact_id);
474            let info = copy.tags["edge"].get_cur_info().expect("pattern copy has a tag");
475            assert!(info.surface.is_some(), "{name} should identify a loft face");
476            let Geometry::Solid(tagged_body) = &info.geometry else {
477                panic!("{name} does not have a solid face tag");
478            };
479            assert_eq!(tagged_body.id, body.id);
480        }
481
482        assert!(
483            original.tags["edge"]
484                .get_cur_info()
485                .expect("original region has a tag")
486                .surface
487                .is_none(),
488            "lofting a pattern copy should preserve the original region's sketch tag"
489        );
490    }
491
492    fn loft_region_tag_program(operation: &str) -> String {
493        format!(
494            r#"@settings(kclVersion = 3.0)
495lower = sketch(on = XY) {{
496  edge = circle(start = [var 10mm, var 0mm], center = [var 0mm, var 0mm])
497}}
498upper = sketch(on = offsetPlane(XY, offset = 10mm)) {{
499  edge = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
500}}
501lowerRegion = region(segments = [lower.edge])
502upperRegion = region(segments = [upper.edge])
503body = loft([lowerRegion, upperRegion])
504beforeOperation = lowerRegion.tags.edge
505{operation}
506afterOperation = lowerRegion.tags.edge
507"#
508        )
509    }
510
511    fn body_id_for_region_tag(result: &ExecTestResults, name: &str) -> uuid::Uuid {
512        let KclValue::TagIdentifier(tag) = result.variable(name) else {
513            panic!("{name} is not a tag");
514        };
515        let info = tag.get_cur_info().expect("region tag has geometry info");
516        assert!(info.surface.is_some(), "{name} should identify a face");
517        let Geometry::Solid(body) = &info.geometry else {
518            panic!("{name} is not a solid face tag");
519        };
520        body.id
521    }
522
523    #[tokio::test(flavor = "multi_thread")]
524    async fn loft_region_tags_follow_subsequent_boolean_operations() {
525        let code = loft_region_tag_program(
526            "tool = extrude(upperRegion, length = 5mm)\nmodified = subtract(body, tools = tool)",
527        );
528        let result = parse_execute(&code).await.expect("modified loft executes");
529        let body_id = result.variable("body").as_solid().expect("body is a solid").id;
530        let outputs = result.variable("modified").into_array();
531        assert_eq!(outputs.len(), 1);
532        let modified = outputs[0].as_solid().expect("modified is a solid");
533        assert_ne!(body_id, modified.id);
534        assert_eq!(body_id_for_region_tag(&result, "beforeOperation"), body_id);
535        assert_eq!(body_id_for_region_tag(&result, "afterOperation"), modified.id);
536    }
537
538    #[tokio::test(flavor = "multi_thread")]
539    async fn loft_independent_copies_do_not_retarget_region_tags() {
540        for expression in [
541            "mirror3d(body, across = YZ)",
542            "patternLinear3d(body, instances = 2, distance = 20mm, axis = X)",
543            "patternCircular3d(body, instances = 2, axis = Z)",
544        ] {
545            let code = loft_region_tag_program(&format!("independent = {expression}"));
546            let result = parse_execute(&code).await.expect("independent loft copies execute");
547            let body_id = result.variable("body").as_solid().expect("body is a solid").id;
548            let copies = result.variable("independent").into_array();
549            let last_copy = copies.last().and_then(KclValue::as_solid).expect("copy is a solid");
550            assert_ne!(body_id, last_copy.id, "{expression} must produce an independent copy");
551            assert_eq!(body_id_for_region_tag(&result, "beforeOperation"), body_id);
552            assert_eq!(
553                body_id_for_region_tag(&result, "afterOperation"),
554                body_id,
555                "{expression} must preserve the original region's face tag"
556            );
557        }
558    }
559
560    #[tokio::test(flavor = "multi_thread")]
561    async fn loft_face_and_edge_annotations_resolve() {
562        parse_execute(
563            r#"@settings(kclVersion = 3.0)
564
565sketch001 = sketch(on = XY) {
566  line1 = line(start = [var -16.5mm, var -9.16mm], end = [var -12.95mm, var 21.02mm])
567  line2 = line(start = [var -12.95mm, var 21.02mm], end = [var 28.04mm, var 7.59mm])
568  coincident([line1.end, line2.start])
569  line3 = line(start = [var 28.04mm, var 7.59mm], end = [var -16.5mm, var -9.16mm])
570  coincident([line2.end, line3.start])
571  coincident([line3.end, line1.start])
572}
573plane001 = offsetPlane(XY, offset = 15)
574sketch002 = sketch(on = plane001) {
575  line1 = line(start = [var -21.29mm, var 13.79mm], end = [var 12.63mm, var 22.51mm])
576  line2 = line(start = [var 12.63mm, var 22.51mm], end = [var 24.51mm, var 0mm])
577  coincident([line1.end, line2.start])
578  horizontal([line2.end, ORIGIN])
579  line3 = line(start = [var 24.51mm, var 0mm], end = [var -6.03mm, var -12.36mm])
580  coincident([line2.end, line3.start])
581  line4 = line(start = [var -6.03mm, var -12.36mm], end = [var -21.29mm, var 13.79mm])
582  coincident([line3.end, line4.start])
583  coincident([line4.end, line1.start])
584}
585region001 = region(segments = [sketch001.line1, sketch001.line2], direction = CW)
586region002 = region(segments = [sketch002.line1, sketch002.line2], direction = CW)
587loft001 = loft([region001, region002], tagStart = $capStart001, tagEnd = $capEnd001)
588
589gdt::annotation(faces = [region001.tags.line3], annotation = "loft face", fontSize = 2.8572mm)
590gdt::annotation(edges = [{ sideFaces = [region001.tags.line3, capStart001] }], annotation = "loft bottom edge", fontSize = 2.8572mm)
591gdt::annotation(edges = [{ sideFaces = [region001.tags.line3, capEnd001] }], annotation = "loft top edge", fontSize = 2.8572mm)
592gdt::annotation(edges = [{ sideFaces = [region001.tags.line1, region001.tags.line3] }], annotation = "loft vertical edge", fontSize = 2.8572mm)
593"#,
594        )
595        .await
596        .expect("loft face and edge annotations resolve");
597    }
598}