Skip to main content

kcl_lib/std/
gdt.rs

1use kcl_error::SourceRange;
2use kcmc::ModelingCmd;
3use kcmc::each_cmd as mcmd;
4use kittycad_modeling_cmds::shared::AnnotationBasicDimension;
5use kittycad_modeling_cmds::shared::AnnotationFeatureControl;
6use kittycad_modeling_cmds::shared::AnnotationLineEnd;
7use kittycad_modeling_cmds::shared::AnnotationMbdBasicDimension;
8use kittycad_modeling_cmds::shared::AnnotationMbdControlFrame;
9use kittycad_modeling_cmds::shared::AnnotationOptions;
10use kittycad_modeling_cmds::shared::AnnotationType;
11use kittycad_modeling_cmds::shared::MbdSymbol;
12use kittycad_modeling_cmds::shared::Point2d as KPoint2d;
13use kittycad_modeling_cmds::{self as kcmc};
14
15use crate::ExecState;
16use crate::KclError;
17use crate::errors::KclErrorDetails;
18use crate::exec::KclValue;
19use crate::execution::Artifact;
20use crate::execution::ArtifactId;
21use crate::execution::CodeRef;
22use crate::execution::ControlFlowKind;
23use crate::execution::Face;
24use crate::execution::GdtAnnotation;
25use crate::execution::GdtAnnotationArtifact;
26use crate::execution::Metadata;
27use crate::execution::ModelingCmdMeta;
28use crate::execution::Plane;
29use crate::execution::StatementKind;
30use crate::execution::TagIdentifier;
31use crate::execution::types::ArrayLen;
32use crate::execution::types::RuntimeType;
33use crate::parsing::ast::types as ast;
34use crate::std::Args;
35use crate::std::args::FromKclValue;
36use crate::std::args::TyF64;
37use crate::std::fillet::EdgeReference;
38use crate::std::sketch::ensure_sketch_plane_in_engine;
39
40// The engine exposes two text knobs:
41// - font_point_size controls the FreeType raster/bitmap texture resolution in pixels/points.
42// - font_scale is the unitless model-space multiplier applied to that texture.
43// KCL exposes only fontSize as a Length. Keep the raster quality fixed so changing
44// quality does not resize the text, and map the requested length into font_scale.
45const GDT_FONT_TEXTURE_POINT_SIZE: u32 = 36;
46const DEFAULT_GDT_FONT_SIZE_MM: f64 = 10.0;
47const DEFAULT_GDT_DOT_LEADER_SCALE: f64 = 1.0;
48const DEFAULT_GDT_DIMENSION_LEADER_SCALE: f64 = 1.0;
49const GDT_DOT_LEADER_REFERENCE_FONT_SIZE_MM: f64 = 100.0;
50const GDT_DOT_LEADER_REFERENCE_ENGINE_SCALE: f64 = 0.5;
51
52// Calibration target: measured annotation text/frame height in millimeters when
53// font_scale is 1.0 and GDT_FONT_TEXTURE_POINT_SIZE is fixed. Tune this value from
54// scene measurements, not by exposing engine font_point_size to users.
55const GDT_FONT_SCALE_1_HEIGHT_MM: f64 = 8.0;
56
57fn gdt_font_scale(font_size: Option<&TyF64>, args: &Args) -> Result<f32, KclError> {
58    let requested_height_mm = font_size.map(TyF64::to_mm).unwrap_or(DEFAULT_GDT_FONT_SIZE_MM);
59    if requested_height_mm <= 0.0 {
60        return Err(KclError::new_semantic(KclErrorDetails::new(
61            "fontSize must be greater than 0.".to_owned(),
62            vec![args.source_range],
63        )));
64    }
65    Ok(gdt_font_scale_for_height_mm(requested_height_mm))
66}
67
68fn gdt_font_scale_for_height_mm(requested_height_mm: f64) -> f32 {
69    (requested_height_mm / GDT_FONT_SCALE_1_HEIGHT_MM) as f32
70}
71
72fn gdt_user_leader_scale(leader_scale: Option<&TyF64>, default_scale: f64, args: &Args) -> Result<f32, KclError> {
73    let scale = leader_scale.map(|scale| scale.n).unwrap_or(default_scale);
74    if scale <= 0.0 {
75        return Err(KclError::new_semantic(KclErrorDetails::new(
76            "leaderScale must be greater than 0.".to_owned(),
77            vec![args.source_range],
78        )));
79    }
80    Ok(scale as f32)
81}
82
83fn gdt_dot_leader_scale(leader_scale: Option<&TyF64>, font_size: Option<&TyF64>, args: &Args) -> Result<f32, KclError> {
84    let user_scale = gdt_user_leader_scale(leader_scale, DEFAULT_GDT_DOT_LEADER_SCALE, args)?;
85    // Engine dot leaders are screen-space point sprites after an internal font_scale
86    // multiplier. Divide that out so KCL leaderScale stays stable across fontSize.
87    Ok(user_scale * gdt_dot_leader_normal_size() / gdt_font_scale(font_size, args)?)
88}
89
90fn gdt_dot_leader_normal_size() -> f32 {
91    gdt_font_scale_for_height_mm(GDT_DOT_LEADER_REFERENCE_FONT_SIZE_MM) * GDT_DOT_LEADER_REFERENCE_ENGINE_SCALE as f32
92}
93
94fn gdt_dimension_leader_scale(leader_scale: Option<&TyF64>, args: &Args) -> Result<f32, KclError> {
95    gdt_user_leader_scale(leader_scale, DEFAULT_GDT_DIMENSION_LEADER_SCALE, args)
96}
97
98#[derive(Debug, Clone)]
99enum DistanceEntity {
100    Face(Box<Face>),
101    TaggedFace(Box<TagIdentifier>),
102    Edge(EdgeReference),
103}
104
105#[derive(Debug, Clone, Copy)]
106struct DistanceEndpoint {
107    entity_id: uuid::Uuid,
108    entity_pos: KPoint2d<f64>,
109}
110
111fn add_gdt_annotation_artifact(exec_state: &mut ExecState, args: &Args, annotation_id: uuid::Uuid) {
112    exec_state.add_artifact(Artifact::GdtAnnotation(GdtAnnotationArtifact {
113        id: ArtifactId::new(annotation_id),
114        code_ref: CodeRef::placeholder(args.source_range),
115    }));
116}
117
118impl DistanceEntity {
119    async fn to_endpoint(&self, exec_state: &mut ExecState, args: &Args) -> Result<DistanceEndpoint, KclError> {
120        match self {
121            DistanceEntity::Face(face) => Ok(DistanceEndpoint {
122                entity_id: face.id,
123                entity_pos: KPoint2d { x: 0.5, y: 0.5 },
124            }),
125            DistanceEntity::TaggedFace(face) => Ok(DistanceEndpoint {
126                entity_id: args.get_adjacent_face_to_tag(exec_state, face, false).await?,
127                entity_pos: KPoint2d { x: 0.5, y: 0.5 },
128            }),
129            DistanceEntity::Edge(edge) => Ok(DistanceEndpoint {
130                entity_id: edge.get_engine_id(exec_state, args)?,
131                entity_pos: KPoint2d { x: 0.5, y: 0.0 },
132            }),
133        }
134    }
135}
136
137impl<'a> FromKclValue<'a> for DistanceEntity {
138    fn from_kcl_val(arg: &'a KclValue) -> Option<Self> {
139        match arg {
140            KclValue::Face { value } => Some(Self::Face(value.to_owned())),
141            KclValue::Uuid { value, .. } => Some(Self::Edge(EdgeReference::Uuid(*value))),
142            KclValue::TagIdentifier(value) => Some(Self::TaggedFace(value.to_owned())),
143            _ => None,
144        }
145    }
146}
147
148fn distance_entity_type() -> RuntimeType {
149    RuntimeType::Union(vec![
150        RuntimeType::face(),
151        RuntimeType::tagged_face(),
152        RuntimeType::edge(),
153    ])
154}
155
156pub async fn datum(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
157    let face: TagIdentifier = args.get_kw_arg("face", &RuntimeType::tagged_face(), exec_state)?;
158    let name: String = args.get_kw_arg("name", &RuntimeType::string(), exec_state)?;
159    let frame_position: Option<[TyF64; 2]> =
160        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
161    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
162    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
163    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
164
165    let annotation = inner_datum(
166        face,
167        name,
168        frame_position,
169        frame_plane,
170        leader_scale,
171        font_size,
172        exec_state,
173        &args,
174    )
175    .await?;
176    Ok(KclValue::GdtAnnotation {
177        value: Box::new(annotation),
178    })
179}
180
181#[allow(clippy::too_many_arguments)]
182async fn inner_datum(
183    face: TagIdentifier,
184    name: String,
185    frame_position: Option<[TyF64; 2]>,
186    frame_plane: Option<Plane>,
187    leader_scale: Option<TyF64>,
188    font_size: Option<TyF64>,
189    exec_state: &mut ExecState,
190    args: &Args,
191) -> Result<GdtAnnotation, KclError> {
192    const DATUM_LENGTH_ERROR: &str = "Datum name must be a single character.";
193    if name.len() > 1 {
194        return Err(KclError::new_semantic(KclErrorDetails::new(
195            DATUM_LENGTH_ERROR.to_owned(),
196            vec![args.source_range],
197        )));
198    }
199    let name_char = name.chars().next().ok_or_else(|| {
200        KclError::new_semantic(KclErrorDetails::new(
201            DATUM_LENGTH_ERROR.to_owned(),
202            vec![args.source_range],
203        ))
204    })?;
205    let mut frame_plane = if let Some(plane) = frame_plane {
206        plane
207    } else {
208        // No plane given. Use one of the standard planes.
209        xy_plane(exec_state, args).await?
210    };
211    ensure_sketch_plane_in_engine(
212        &mut frame_plane,
213        exec_state,
214        &args.ctx,
215        args.source_range,
216        args.node_path.clone(),
217    )
218    .await?;
219    let face_id = args.get_adjacent_face_to_tag(exec_state, &face, false).await?;
220    let meta = vec![Metadata::from(args.source_range)];
221    let annotation_id = exec_state.next_uuid();
222    let feature_control = AnnotationFeatureControl::builder()
223        .entity_id(face_id)
224        // Point to the center of the face.
225        .entity_pos(KPoint2d { x: 0.5, y: 0.5 })
226        .leader_type(AnnotationLineEnd::Dot)
227        .defined_datum(name_char)
228        .plane_id(frame_plane.id)
229        .offset(if let Some(offset) = &frame_position {
230            KPoint2d {
231                x: offset[0].to_mm(),
232                y: offset[1].to_mm(),
233            }
234        } else {
235            KPoint2d { x: 100.0, y: 100.0 }
236        })
237        .precision(0)
238        .font_scale(gdt_font_scale(font_size.as_ref(), args)?)
239        .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
240        .leader_scale(gdt_dot_leader_scale(leader_scale.as_ref(), font_size.as_ref(), args)?)
241        .build();
242    exec_state
243        .batch_modeling_cmd(
244            ModelingCmdMeta::from_args_id(exec_state, args, annotation_id),
245            ModelingCmd::from(
246                mcmd::NewAnnotation::builder()
247                    .options(AnnotationOptions::builder().feature_control(feature_control).build())
248                    .clobber(false)
249                    .annotation_type(AnnotationType::T3D)
250                    .build(),
251            ),
252        )
253        .await?;
254    add_gdt_annotation_artifact(exec_state, args, annotation_id);
255    Ok(GdtAnnotation {
256        id: annotation_id,
257        meta,
258    })
259}
260
261pub async fn flatness(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
262    let faces: Vec<TagIdentifier> = args.get_kw_arg(
263        "faces",
264        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
265        exec_state,
266    )?;
267    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
268    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
269    let frame_position: Option<[TyF64; 2]> =
270        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
271    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
272    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
273    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
274
275    let annotations = inner_flatness(
276        faces,
277        tolerance,
278        precision,
279        frame_position,
280        frame_plane,
281        leader_scale,
282        font_size,
283        exec_state,
284        &args,
285    )
286    .await?;
287    Ok(annotations.into())
288}
289
290#[allow(clippy::too_many_arguments)]
291async fn inner_flatness(
292    faces: Vec<TagIdentifier>,
293    tolerance: TyF64,
294    precision: Option<TyF64>,
295    frame_position: Option<[TyF64; 2]>,
296    frame_plane: Option<Plane>,
297    leader_scale: Option<TyF64>,
298    font_size: Option<TyF64>,
299    exec_state: &mut ExecState,
300    args: &Args,
301) -> Result<Vec<GdtAnnotation>, KclError> {
302    let precision = resolve_precision(precision, args)?;
303    let mut frame_plane = if let Some(plane) = frame_plane {
304        plane
305    } else {
306        // No plane given. Use one of the standard planes.
307        xy_plane(exec_state, args).await?
308    };
309    ensure_sketch_plane_in_engine(
310        &mut frame_plane,
311        exec_state,
312        &args.ctx,
313        args.source_range,
314        args.node_path.clone(),
315    )
316    .await?;
317    let mut annotations = Vec::with_capacity(faces.len());
318    let display_units = exec_state.length_unit();
319    for face in &faces {
320        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
321        let meta = vec![Metadata::from(args.source_range)];
322        let annotation_id = exec_state.next_uuid();
323        let feature_control = AnnotationFeatureControl::builder()
324            .entity_id(face_id)
325            // Point to the center of the face.
326            .entity_pos(KPoint2d { x: 0.5, y: 0.5 })
327            .leader_type(AnnotationLineEnd::Dot)
328            .control_frame(
329                AnnotationMbdControlFrame::builder()
330                    .symbol(MbdSymbol::Flatness)
331                    .tolerance(tolerance.to_length_units(display_units))
332                    .build(),
333            )
334            .plane_id(frame_plane.id)
335            .offset(if let Some(offset) = &frame_position {
336                KPoint2d {
337                    x: offset[0].to_mm(),
338                    y: offset[1].to_mm(),
339                }
340            } else {
341                KPoint2d { x: 100.0, y: 100.0 }
342            })
343            .precision(precision)
344            .font_scale(gdt_font_scale(font_size.as_ref(), args)?)
345            .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
346            .leader_scale(gdt_dot_leader_scale(leader_scale.as_ref(), font_size.as_ref(), args)?)
347            .build();
348        let options = AnnotationOptions::builder().feature_control(feature_control).build();
349        exec_state
350            .batch_modeling_cmd(
351                ModelingCmdMeta::from_args_id(exec_state, args, annotation_id),
352                ModelingCmd::from(
353                    mcmd::NewAnnotation::builder()
354                        .options(options)
355                        .clobber(false)
356                        .annotation_type(AnnotationType::T3D)
357                        .build(),
358                ),
359            )
360            .await?;
361        add_gdt_annotation_artifact(exec_state, args, annotation_id);
362        annotations.push(GdtAnnotation {
363            id: annotation_id,
364            meta,
365        });
366    }
367    Ok(annotations)
368}
369
370pub async fn straightness(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
371    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
372        "faces",
373        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
374        exec_state,
375    )?;
376    let edges: Option<Vec<EdgeReference>> = args.get_kw_arg_opt(
377        "edges",
378        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
379        exec_state,
380    )?;
381    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
382    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
383    let frame_position: Option<[TyF64; 2]> =
384        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
385    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
386    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
387    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
388
389    let annotations = inner_straightness(
390        faces.unwrap_or_default(),
391        edges.unwrap_or_default(),
392        tolerance,
393        precision,
394        frame_position,
395        frame_plane,
396        leader_scale,
397        font_size,
398        exec_state,
399        &args,
400    )
401    .await?;
402    Ok(annotations.into())
403}
404
405#[expect(clippy::too_many_arguments)]
406async fn inner_straightness(
407    faces: Vec<TagIdentifier>,
408    edges: Vec<EdgeReference>,
409    tolerance: TyF64,
410    precision: Option<TyF64>,
411    frame_position: Option<[TyF64; 2]>,
412    frame_plane: Option<Plane>,
413    leader_scale: Option<TyF64>,
414    font_size: Option<TyF64>,
415    exec_state: &mut ExecState,
416    args: &Args,
417) -> Result<Vec<GdtAnnotation>, KclError> {
418    if faces.is_empty() && edges.is_empty() {
419        return Err(KclError::new_semantic(KclErrorDetails::new(
420            "Straightness requires at least one face or edge.".to_owned(),
421            vec![args.source_range],
422        )));
423    }
424
425    let precision = resolve_precision(precision, args)?;
426    let mut frame_plane = if let Some(plane) = frame_plane {
427        plane
428    } else {
429        // No plane given. Use one of the standard planes.
430        xy_plane(exec_state, args).await?
431    };
432    ensure_sketch_plane_in_engine(
433        &mut frame_plane,
434        exec_state,
435        &args.ctx,
436        args.source_range,
437        args.node_path.clone(),
438    )
439    .await?;
440
441    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
442    for face in &faces {
443        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
444        create_feature_control_annotation(
445            face_id,
446            MbdSymbol::Straightness,
447            &tolerance,
448            &[],
449            precision,
450            frame_position.as_ref(),
451            frame_plane.id,
452            leader_scale.as_ref(),
453            font_size.as_ref(),
454            exec_state,
455            args,
456            &mut annotations,
457        )
458        .await?;
459    }
460    for edge in &edges {
461        let edge_id = edge.get_engine_id(exec_state, args)?;
462        create_feature_control_annotation(
463            edge_id,
464            MbdSymbol::Straightness,
465            &tolerance,
466            &[],
467            precision,
468            frame_position.as_ref(),
469            frame_plane.id,
470            leader_scale.as_ref(),
471            font_size.as_ref(),
472            exec_state,
473            args,
474            &mut annotations,
475        )
476        .await?;
477    }
478    Ok(annotations)
479}
480
481pub async fn circularity(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
482    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
483        "faces",
484        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
485        exec_state,
486    )?;
487    let edges: Option<Vec<EdgeReference>> = args.get_kw_arg_opt(
488        "edges",
489        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
490        exec_state,
491    )?;
492    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
493    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
494    let frame_position: Option<[TyF64; 2]> =
495        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
496    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
497    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
498    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
499
500    let annotations = inner_circularity(
501        faces.unwrap_or_default(),
502        edges.unwrap_or_default(),
503        tolerance,
504        precision,
505        frame_position,
506        frame_plane,
507        leader_scale,
508        font_size,
509        exec_state,
510        &args,
511    )
512    .await?;
513    Ok(annotations.into())
514}
515
516#[expect(clippy::too_many_arguments)]
517async fn inner_circularity(
518    faces: Vec<TagIdentifier>,
519    edges: Vec<EdgeReference>,
520    tolerance: TyF64,
521    precision: Option<TyF64>,
522    frame_position: Option<[TyF64; 2]>,
523    frame_plane: Option<Plane>,
524    leader_scale: Option<TyF64>,
525    font_size: Option<TyF64>,
526    exec_state: &mut ExecState,
527    args: &Args,
528) -> Result<Vec<GdtAnnotation>, KclError> {
529    if faces.is_empty() && edges.is_empty() {
530        return Err(KclError::new_semantic(KclErrorDetails::new(
531            "Circularity requires at least one face or edge.".to_owned(),
532            vec![args.source_range],
533        )));
534    }
535
536    let precision = resolve_precision(precision, args)?;
537    let mut frame_plane = if let Some(plane) = frame_plane {
538        plane
539    } else {
540        // No plane given. Use one of the standard planes.
541        xy_plane(exec_state, args).await?
542    };
543    ensure_sketch_plane_in_engine(
544        &mut frame_plane,
545        exec_state,
546        &args.ctx,
547        args.source_range,
548        args.node_path.clone(),
549    )
550    .await?;
551
552    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
553    for face in &faces {
554        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
555        create_feature_control_annotation(
556            face_id,
557            MbdSymbol::Roundness,
558            &tolerance,
559            &[],
560            precision,
561            frame_position.as_ref(),
562            frame_plane.id,
563            leader_scale.as_ref(),
564            font_size.as_ref(),
565            exec_state,
566            args,
567            &mut annotations,
568        )
569        .await?;
570    }
571    for edge in &edges {
572        let edge_id = edge.get_engine_id(exec_state, args)?;
573        create_feature_control_annotation(
574            edge_id,
575            MbdSymbol::Roundness,
576            &tolerance,
577            &[],
578            precision,
579            frame_position.as_ref(),
580            frame_plane.id,
581            leader_scale.as_ref(),
582            font_size.as_ref(),
583            exec_state,
584            args,
585            &mut annotations,
586        )
587        .await?;
588    }
589    Ok(annotations)
590}
591
592pub async fn profile(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
593    let edges: Vec<EdgeReference> = args.get_kw_arg(
594        "edges",
595        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
596        exec_state,
597    )?;
598    let datums: Option<Vec<String>> = args.get_kw_arg_opt(
599        "datums",
600        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
601        exec_state,
602    )?;
603    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
604    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
605    let frame_position: Option<[TyF64; 2]> =
606        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
607    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
608    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
609    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
610
611    let annotations = inner_profile(
612        edges,
613        datums,
614        tolerance,
615        precision,
616        frame_position,
617        frame_plane,
618        leader_scale,
619        font_size,
620        exec_state,
621        &args,
622    )
623    .await?;
624    Ok(annotations.into())
625}
626
627#[allow(clippy::too_many_arguments)]
628async fn inner_profile(
629    edges: Vec<EdgeReference>,
630    datums: Option<Vec<String>>,
631    tolerance: TyF64,
632    precision: Option<TyF64>,
633    frame_position: Option<[TyF64; 2]>,
634    frame_plane: Option<Plane>,
635    leader_scale: Option<TyF64>,
636    font_size: Option<TyF64>,
637    exec_state: &mut ExecState,
638    args: &Args,
639) -> Result<Vec<GdtAnnotation>, KclError> {
640    let precision = resolve_precision(precision, args)?;
641    let datums = resolve_datums(datums, args, "Profile")?;
642    let mut frame_plane = if let Some(plane) = frame_plane {
643        plane
644    } else {
645        xy_plane(exec_state, args).await?
646    };
647    ensure_sketch_plane_in_engine(
648        &mut frame_plane,
649        exec_state,
650        &args.ctx,
651        args.source_range,
652        args.node_path.clone(),
653    )
654    .await?;
655
656    let mut annotations = Vec::with_capacity(edges.len());
657    for edge in &edges {
658        let edge_id = edge.get_engine_id(exec_state, args)?;
659        create_feature_control_annotation(
660            edge_id,
661            MbdSymbol::ProfileOfLine,
662            &tolerance,
663            &datums,
664            precision,
665            frame_position.as_ref(),
666            frame_plane.id,
667            leader_scale.as_ref(),
668            font_size.as_ref(),
669            exec_state,
670            args,
671            &mut annotations,
672        )
673        .await?;
674    }
675    Ok(annotations)
676}
677
678pub async fn position(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
679    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
680        "faces",
681        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
682        exec_state,
683    )?;
684    let edges: Option<Vec<EdgeReference>> = args.get_kw_arg_opt(
685        "edges",
686        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
687        exec_state,
688    )?;
689    let datums: Option<Vec<String>> = args.get_kw_arg_opt(
690        "datums",
691        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
692        exec_state,
693    )?;
694    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
695    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
696    let frame_position: Option<[TyF64; 2]> =
697        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
698    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
699    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
700    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
701
702    let annotations = inner_position(
703        faces.unwrap_or_default(),
704        edges.unwrap_or_default(),
705        tolerance,
706        datums,
707        precision,
708        frame_position,
709        frame_plane,
710        leader_scale,
711        font_size,
712        exec_state,
713        &args,
714    )
715    .await?;
716    Ok(annotations.into())
717}
718
719#[allow(clippy::too_many_arguments)]
720async fn inner_position(
721    faces: Vec<TagIdentifier>,
722    edges: Vec<EdgeReference>,
723    tolerance: TyF64,
724    datums: Option<Vec<String>>,
725    precision: Option<TyF64>,
726    frame_position: Option<[TyF64; 2]>,
727    frame_plane: Option<Plane>,
728    leader_scale: Option<TyF64>,
729    font_size: Option<TyF64>,
730    exec_state: &mut ExecState,
731    args: &Args,
732) -> Result<Vec<GdtAnnotation>, KclError> {
733    if faces.is_empty() && edges.is_empty() {
734        return Err(KclError::new_semantic(KclErrorDetails::new(
735            "Position requires at least one face or edge.".to_owned(),
736            vec![args.source_range],
737        )));
738    }
739
740    let precision = resolve_precision(precision, args)?;
741    let datums = resolve_datums(datums, args, "Position")?;
742    let mut frame_plane = if let Some(plane) = frame_plane {
743        plane
744    } else {
745        xy_plane(exec_state, args).await?
746    };
747    ensure_sketch_plane_in_engine(
748        &mut frame_plane,
749        exec_state,
750        &args.ctx,
751        args.source_range,
752        args.node_path.clone(),
753    )
754    .await?;
755
756    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
757    for face in &faces {
758        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
759        create_feature_control_annotation(
760            face_id,
761            MbdSymbol::Position,
762            &tolerance,
763            &datums,
764            precision,
765            frame_position.as_ref(),
766            frame_plane.id,
767            leader_scale.as_ref(),
768            font_size.as_ref(),
769            exec_state,
770            args,
771            &mut annotations,
772        )
773        .await?;
774    }
775    for edge in &edges {
776        let edge_id = edge.get_engine_id(exec_state, args)?;
777        create_feature_control_annotation(
778            edge_id,
779            MbdSymbol::Position,
780            &tolerance,
781            &datums,
782            precision,
783            frame_position.as_ref(),
784            frame_plane.id,
785            leader_scale.as_ref(),
786            font_size.as_ref(),
787            exec_state,
788            args,
789            &mut annotations,
790        )
791        .await?;
792    }
793    Ok(annotations)
794}
795
796pub async fn distance(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
797    let from: Option<DistanceEntity> = args.get_kw_arg_opt("from", &distance_entity_type(), exec_state)?;
798    let to: Option<DistanceEntity> = args.get_kw_arg_opt("to", &distance_entity_type(), exec_state)?;
799    let edges: Option<Vec<EdgeReference>> = args.get_kw_arg_opt(
800        "edges",
801        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
802        exec_state,
803    )?;
804    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
805    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
806    let frame_position: Option<[TyF64; 2]> =
807        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
808    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
809    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
810    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
811
812    let annotations = inner_distance(
813        from,
814        to,
815        edges.unwrap_or_default(),
816        tolerance,
817        precision,
818        frame_position,
819        frame_plane,
820        leader_scale,
821        font_size,
822        exec_state,
823        &args,
824    )
825    .await?;
826    Ok(annotations.into())
827}
828
829#[allow(clippy::too_many_arguments)]
830async fn inner_distance(
831    from: Option<DistanceEntity>,
832    to: Option<DistanceEntity>,
833    edges: Vec<EdgeReference>,
834    tolerance: TyF64,
835    precision: Option<TyF64>,
836    frame_position: Option<[TyF64; 2]>,
837    frame_plane: Option<Plane>,
838    leader_scale: Option<TyF64>,
839    font_size: Option<TyF64>,
840    exec_state: &mut ExecState,
841    args: &Args,
842) -> Result<Vec<GdtAnnotation>, KclError> {
843    let precision = resolve_precision(precision, args)?;
844    let mut frame_plane = if let Some(plane) = frame_plane {
845        plane
846    } else {
847        xy_plane(exec_state, args).await?
848    };
849    ensure_sketch_plane_in_engine(
850        &mut frame_plane,
851        exec_state,
852        &args.ctx,
853        args.source_range,
854        args.node_path.clone(),
855    )
856    .await?;
857
858    if from.is_some() || to.is_some() {
859        if !edges.is_empty() {
860            return Err(KclError::new_semantic(KclErrorDetails::new(
861                "Distance cannot combine `from`/`to` with `edges`.".to_owned(),
862                vec![args.source_range],
863            )));
864        }
865
866        let (Some(from), Some(to)) = (from, to) else {
867            return Err(KclError::new_semantic(KclErrorDetails::new(
868                "Distance requires both `from` and `to` when measuring between entities.".to_owned(),
869                vec![args.source_range],
870            )));
871        };
872
873        let from = from.to_endpoint(exec_state, args).await?;
874        let to = to.to_endpoint(exec_state, args).await?;
875        let mut annotations = Vec::with_capacity(1);
876        create_basic_distance_annotation(
877            from,
878            to,
879            &tolerance,
880            precision,
881            frame_position.as_ref(),
882            frame_plane.id,
883            leader_scale.as_ref(),
884            font_size.as_ref(),
885            exec_state,
886            args,
887            &mut annotations,
888        )
889        .await?;
890        return Ok(annotations);
891    }
892
893    if edges.is_empty() {
894        return Err(KclError::new_semantic(KclErrorDetails::new(
895            "Distance requires either `edges` or both `from` and `to`.".to_owned(),
896            vec![args.source_range],
897        )));
898    }
899
900    let mut annotations = Vec::with_capacity(edges.len());
901    for edge in &edges {
902        let edge_id = edge.get_engine_id(exec_state, args)?;
903        create_basic_distance_annotation(
904            DistanceEndpoint {
905                entity_id: edge_id,
906                entity_pos: KPoint2d { x: 0.0, y: 0.0 },
907            },
908            DistanceEndpoint {
909                entity_id: edge_id,
910                entity_pos: KPoint2d { x: 1.0, y: 0.0 },
911            },
912            &tolerance,
913            precision,
914            frame_position.as_ref(),
915            frame_plane.id,
916            leader_scale.as_ref(),
917            font_size.as_ref(),
918            exec_state,
919            args,
920            &mut annotations,
921        )
922        .await?;
923    }
924    Ok(annotations)
925}
926
927#[allow(clippy::too_many_arguments)]
928async fn create_basic_distance_annotation(
929    from: DistanceEndpoint,
930    to: DistanceEndpoint,
931    tolerance: &TyF64,
932    precision: u32,
933    frame_position: Option<&[TyF64; 2]>,
934    frame_plane_id: uuid::Uuid,
935    leader_scale: Option<&TyF64>,
936    font_size: Option<&TyF64>,
937    exec_state: &mut ExecState,
938    args: &Args,
939    annotations: &mut Vec<GdtAnnotation>,
940) -> Result<(), KclError> {
941    let meta = vec![Metadata::from(args.source_range)];
942    let annotation_id = exec_state.next_uuid();
943    let display_units = exec_state.length_unit();
944    let dimension = AnnotationBasicDimension::builder()
945        .from_entity_id(from.entity_id)
946        .from_entity_pos(from.entity_pos)
947        .to_entity_id(to.entity_id)
948        .to_entity_pos(to.entity_pos)
949        .dimension(
950            AnnotationMbdBasicDimension::builder()
951                .tolerance(tolerance.to_length_units(display_units))
952                .build(),
953        )
954        .plane_id(frame_plane_id)
955        .offset(if let Some(offset) = frame_position {
956            KPoint2d {
957                x: offset[0].to_mm(),
958                y: offset[1].to_mm(),
959            }
960        } else {
961            KPoint2d { x: 100.0, y: 100.0 }
962        })
963        .precision(precision)
964        .font_scale(gdt_font_scale(font_size, args)?)
965        .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
966        .arrow_scale(gdt_dimension_leader_scale(leader_scale, args)?)
967        .build();
968    let options = AnnotationOptions::builder()
969        .dimension(dimension)
970        .units(display_units)
971        .build();
972    let annotation_cmd = ModelingCmd::from(
973        mcmd::NewAnnotation::builder()
974            .options(options)
975            .clobber(false)
976            .annotation_type(AnnotationType::T3D)
977            .build(),
978    );
979    let cmd_meta = ModelingCmdMeta::from_args_id(exec_state, args, annotation_id);
980    exec_state.batch_modeling_cmd(cmd_meta, annotation_cmd).await?;
981    add_gdt_annotation_artifact(exec_state, args, annotation_id);
982    annotations.push(GdtAnnotation {
983        id: annotation_id,
984        meta,
985    });
986    Ok(())
987}
988
989pub async fn perpendicularity(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
990    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
991        "faces",
992        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
993        exec_state,
994    )?;
995    let edges: Option<Vec<EdgeReference>> = args.get_kw_arg_opt(
996        "edges",
997        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
998        exec_state,
999    )?;
1000    let datums: Option<Vec<String>> = args.get_kw_arg_opt(
1001        "datums",
1002        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
1003        exec_state,
1004    )?;
1005    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
1006    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
1007    let frame_position: Option<[TyF64; 2]> =
1008        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
1009    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
1010    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
1011    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
1012
1013    let annotations = inner_perpendicularity(
1014        faces.unwrap_or_default(),
1015        edges.unwrap_or_default(),
1016        datums,
1017        tolerance,
1018        precision,
1019        frame_position,
1020        frame_plane,
1021        leader_scale,
1022        font_size,
1023        exec_state,
1024        &args,
1025    )
1026    .await?;
1027    Ok(annotations.into())
1028}
1029
1030#[allow(clippy::too_many_arguments)]
1031async fn inner_perpendicularity(
1032    faces: Vec<TagIdentifier>,
1033    edges: Vec<EdgeReference>,
1034    datums: Option<Vec<String>>,
1035    tolerance: TyF64,
1036    precision: Option<TyF64>,
1037    frame_position: Option<[TyF64; 2]>,
1038    frame_plane: Option<Plane>,
1039    leader_scale: Option<TyF64>,
1040    font_size: Option<TyF64>,
1041    exec_state: &mut ExecState,
1042    args: &Args,
1043) -> Result<Vec<GdtAnnotation>, KclError> {
1044    if faces.is_empty() && edges.is_empty() {
1045        return Err(KclError::new_semantic(KclErrorDetails::new(
1046            "Perpendicularity requires at least one face or edge.".to_owned(),
1047            vec![args.source_range],
1048        )));
1049    }
1050
1051    let precision = resolve_precision(precision, args)?;
1052    let datums = resolve_datums(datums, args, "Perpendicularity")?;
1053    let mut frame_plane = if let Some(plane) = frame_plane {
1054        plane
1055    } else {
1056        xy_plane(exec_state, args).await?
1057    };
1058    ensure_sketch_plane_in_engine(
1059        &mut frame_plane,
1060        exec_state,
1061        &args.ctx,
1062        args.source_range,
1063        args.node_path.clone(),
1064    )
1065    .await?;
1066
1067    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
1068    for face in &faces {
1069        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
1070        create_feature_control_annotation(
1071            face_id,
1072            MbdSymbol::Perpendicularity,
1073            &tolerance,
1074            &datums,
1075            precision,
1076            frame_position.as_ref(),
1077            frame_plane.id,
1078            leader_scale.as_ref(),
1079            font_size.as_ref(),
1080            exec_state,
1081            args,
1082            &mut annotations,
1083        )
1084        .await?;
1085    }
1086    for edge in &edges {
1087        let edge_id = edge.get_engine_id(exec_state, args)?;
1088        create_feature_control_annotation(
1089            edge_id,
1090            MbdSymbol::Perpendicularity,
1091            &tolerance,
1092            &datums,
1093            precision,
1094            frame_position.as_ref(),
1095            frame_plane.id,
1096            leader_scale.as_ref(),
1097            font_size.as_ref(),
1098            exec_state,
1099            args,
1100            &mut annotations,
1101        )
1102        .await?;
1103    }
1104
1105    Ok(annotations)
1106}
1107
1108pub async fn parallelism(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
1109    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
1110        "faces",
1111        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
1112        exec_state,
1113    )?;
1114    let edges: Option<Vec<EdgeReference>> = args.get_kw_arg_opt(
1115        "edges",
1116        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
1117        exec_state,
1118    )?;
1119    let datums: Option<Vec<String>> = args.get_kw_arg_opt(
1120        "datums",
1121        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
1122        exec_state,
1123    )?;
1124    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
1125    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
1126    let frame_position: Option<[TyF64; 2]> =
1127        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
1128    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
1129    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
1130    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
1131
1132    let annotations = inner_parallelism(
1133        faces.unwrap_or_default(),
1134        edges.unwrap_or_default(),
1135        datums,
1136        tolerance,
1137        precision,
1138        frame_position,
1139        frame_plane,
1140        leader_scale,
1141        font_size,
1142        exec_state,
1143        &args,
1144    )
1145    .await?;
1146    Ok(annotations.into())
1147}
1148
1149#[allow(clippy::too_many_arguments)]
1150async fn inner_parallelism(
1151    faces: Vec<TagIdentifier>,
1152    edges: Vec<EdgeReference>,
1153    datums: Option<Vec<String>>,
1154    tolerance: TyF64,
1155    precision: Option<TyF64>,
1156    frame_position: Option<[TyF64; 2]>,
1157    frame_plane: Option<Plane>,
1158    leader_scale: Option<TyF64>,
1159    font_size: Option<TyF64>,
1160    exec_state: &mut ExecState,
1161    args: &Args,
1162) -> Result<Vec<GdtAnnotation>, KclError> {
1163    if faces.is_empty() && edges.is_empty() {
1164        return Err(KclError::new_semantic(KclErrorDetails::new(
1165            "Parallelism requires at least one face or edge.".to_owned(),
1166            vec![args.source_range],
1167        )));
1168    }
1169
1170    let precision = resolve_precision(precision, args)?;
1171    let datums = resolve_datums(datums, args, "Parallelism")?;
1172    let mut frame_plane = if let Some(plane) = frame_plane {
1173        plane
1174    } else {
1175        xy_plane(exec_state, args).await?
1176    };
1177    ensure_sketch_plane_in_engine(
1178        &mut frame_plane,
1179        exec_state,
1180        &args.ctx,
1181        args.source_range,
1182        args.node_path.clone(),
1183    )
1184    .await?;
1185
1186    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
1187    for face in &faces {
1188        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
1189        create_feature_control_annotation(
1190            face_id,
1191            MbdSymbol::Parallelism,
1192            &tolerance,
1193            &datums,
1194            precision,
1195            frame_position.as_ref(),
1196            frame_plane.id,
1197            leader_scale.as_ref(),
1198            font_size.as_ref(),
1199            exec_state,
1200            args,
1201            &mut annotations,
1202        )
1203        .await?;
1204    }
1205    for edge in &edges {
1206        let edge_id = edge.get_engine_id(exec_state, args)?;
1207        create_feature_control_annotation(
1208            edge_id,
1209            MbdSymbol::Parallelism,
1210            &tolerance,
1211            &datums,
1212            precision,
1213            frame_position.as_ref(),
1214            frame_plane.id,
1215            leader_scale.as_ref(),
1216            font_size.as_ref(),
1217            exec_state,
1218            args,
1219            &mut annotations,
1220        )
1221        .await?;
1222    }
1223
1224    Ok(annotations)
1225}
1226
1227pub async fn annotation(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
1228    let annotation: String = args.get_kw_arg("annotation", &RuntimeType::string(), exec_state)?;
1229    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
1230        "faces",
1231        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
1232        exec_state,
1233    )?;
1234    let edges: Option<Vec<EdgeReference>> = args.get_kw_arg_opt(
1235        "edges",
1236        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
1237        exec_state,
1238    )?;
1239    let frame_position: Option<[TyF64; 2]> =
1240        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
1241    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
1242    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
1243    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
1244
1245    let annotations = inner_annotation(
1246        annotation,
1247        faces.unwrap_or_default(),
1248        edges.unwrap_or_default(),
1249        frame_position,
1250        frame_plane,
1251        leader_scale,
1252        font_size,
1253        exec_state,
1254        &args,
1255    )
1256    .await?;
1257    Ok(annotations.into())
1258}
1259
1260#[allow(clippy::too_many_arguments)]
1261async fn inner_annotation(
1262    annotation: String,
1263    faces: Vec<TagIdentifier>,
1264    edges: Vec<EdgeReference>,
1265    frame_position: Option<[TyF64; 2]>,
1266    frame_plane: Option<Plane>,
1267    leader_scale: Option<TyF64>,
1268    font_size: Option<TyF64>,
1269    exec_state: &mut ExecState,
1270    args: &Args,
1271) -> Result<Vec<GdtAnnotation>, KclError> {
1272    if annotation.is_empty() {
1273        return Err(KclError::new_semantic(KclErrorDetails::new(
1274            "Annotation text must not be empty.".to_owned(),
1275            vec![args.source_range],
1276        )));
1277    }
1278    if faces.is_empty() && edges.is_empty() {
1279        return Err(KclError::new_semantic(KclErrorDetails::new(
1280            "Annotation requires at least one face or edge.".to_owned(),
1281            vec![args.source_range],
1282        )));
1283    }
1284
1285    let mut frame_plane = if let Some(plane) = frame_plane {
1286        plane
1287    } else {
1288        xy_plane(exec_state, args).await?
1289    };
1290    ensure_sketch_plane_in_engine(
1291        &mut frame_plane,
1292        exec_state,
1293        &args.ctx,
1294        args.source_range,
1295        args.node_path.clone(),
1296    )
1297    .await?;
1298
1299    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
1300    for face in &faces {
1301        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
1302        create_annotation(
1303            face_id,
1304            &annotation,
1305            frame_position.as_ref(),
1306            frame_plane.id,
1307            leader_scale.as_ref(),
1308            font_size.as_ref(),
1309            exec_state,
1310            args,
1311            &mut annotations,
1312        )
1313        .await?;
1314    }
1315    for edge in &edges {
1316        let edge_id = edge.get_engine_id(exec_state, args)?;
1317        create_annotation(
1318            edge_id,
1319            &annotation,
1320            frame_position.as_ref(),
1321            frame_plane.id,
1322            leader_scale.as_ref(),
1323            font_size.as_ref(),
1324            exec_state,
1325            args,
1326            &mut annotations,
1327        )
1328        .await?;
1329    }
1330
1331    Ok(annotations)
1332}
1333
1334fn resolve_precision(precision: Option<TyF64>, args: &Args) -> Result<u32, KclError> {
1335    if let Some(precision) = precision {
1336        let rounded = precision.n.round();
1337        if !(0.0..=9.0).contains(&rounded) {
1338            return Err(KclError::new_semantic(KclErrorDetails::new(
1339                "Precision must be between 0 and 9".to_owned(),
1340                vec![args.source_range],
1341            )));
1342        }
1343        Ok(rounded as u32)
1344    } else {
1345        Ok(3)
1346    }
1347}
1348
1349#[allow(clippy::too_many_arguments)]
1350async fn create_feature_control_annotation(
1351    entity_id: uuid::Uuid,
1352    symbol: MbdSymbol,
1353    tolerance: &TyF64,
1354    datums: &[char],
1355    precision: u32,
1356    frame_position: Option<&[TyF64; 2]>,
1357    frame_plane_id: uuid::Uuid,
1358    leader_scale: Option<&TyF64>,
1359    font_size: Option<&TyF64>,
1360    exec_state: &mut ExecState,
1361    args: &Args,
1362    annotations: &mut Vec<GdtAnnotation>,
1363) -> Result<(), KclError> {
1364    let meta = vec![Metadata::from(args.source_range)];
1365    let annotation_id = exec_state.next_uuid();
1366    let display_units = exec_state.length_unit();
1367    let control_frame = gdt_control_frame(symbol, tolerance.to_length_units(display_units), datums);
1368    let feature_control = AnnotationFeatureControl::builder()
1369        .entity_id(entity_id)
1370        .entity_pos(KPoint2d { x: 0.5, y: 0.5 })
1371        .leader_type(AnnotationLineEnd::Dot)
1372        .control_frame(control_frame)
1373        .plane_id(frame_plane_id)
1374        .offset(if let Some(offset) = frame_position {
1375            KPoint2d {
1376                x: offset[0].to_mm(),
1377                y: offset[1].to_mm(),
1378            }
1379        } else {
1380            KPoint2d { x: 100.0, y: 100.0 }
1381        })
1382        .precision(precision)
1383        .font_scale(gdt_font_scale(font_size, args)?)
1384        .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
1385        .leader_scale(gdt_dot_leader_scale(leader_scale, font_size, args)?)
1386        .build();
1387    let options = AnnotationOptions::builder().feature_control(feature_control).build();
1388    exec_state
1389        .batch_modeling_cmd(
1390            ModelingCmdMeta::from_args_id(exec_state, args, annotation_id),
1391            ModelingCmd::from(
1392                mcmd::NewAnnotation::builder()
1393                    .options(options)
1394                    .clobber(false)
1395                    .annotation_type(AnnotationType::T3D)
1396                    .build(),
1397            ),
1398        )
1399        .await?;
1400    add_gdt_annotation_artifact(exec_state, args, annotation_id);
1401    annotations.push(GdtAnnotation {
1402        id: annotation_id,
1403        meta,
1404    });
1405    Ok(())
1406}
1407
1408fn gdt_control_frame(symbol: MbdSymbol, tolerance: f64, datums: &[char]) -> AnnotationMbdControlFrame {
1409    match datums {
1410        [] => AnnotationMbdControlFrame::builder()
1411            .symbol(symbol)
1412            .tolerance(tolerance)
1413            .build(),
1414        [primary] => AnnotationMbdControlFrame::builder()
1415            .symbol(symbol)
1416            .tolerance(tolerance)
1417            .primary_datum(*primary)
1418            .build(),
1419        [primary, secondary] => AnnotationMbdControlFrame::builder()
1420            .symbol(symbol)
1421            .tolerance(tolerance)
1422            .primary_datum(*primary)
1423            .secondary_datum(*secondary)
1424            .build(),
1425        [primary, secondary, tertiary] => AnnotationMbdControlFrame::builder()
1426            .symbol(symbol)
1427            .tolerance(tolerance)
1428            .primary_datum(*primary)
1429            .secondary_datum(*secondary)
1430            .tertiary_datum(*tertiary)
1431            .build(),
1432        _ => unreachable!("resolve_datums rejects more than three datums"),
1433    }
1434}
1435
1436#[allow(clippy::too_many_arguments)]
1437async fn create_annotation(
1438    entity_id: uuid::Uuid,
1439    annotation: &str,
1440    frame_position: Option<&[TyF64; 2]>,
1441    frame_plane_id: uuid::Uuid,
1442    leader_scale: Option<&TyF64>,
1443    font_size: Option<&TyF64>,
1444    exec_state: &mut ExecState,
1445    args: &Args,
1446    annotations: &mut Vec<GdtAnnotation>,
1447) -> Result<(), KclError> {
1448    let meta = vec![Metadata::from(args.source_range)];
1449    let annotation_id = exec_state.next_uuid();
1450    let feature_control = AnnotationFeatureControl::builder()
1451        .entity_id(entity_id)
1452        .entity_pos(KPoint2d { x: 0.5, y: 0.5 })
1453        .leader_type(AnnotationLineEnd::Dot)
1454        .prefix(annotation.to_owned())
1455        .plane_id(frame_plane_id)
1456        .offset(if let Some(offset) = frame_position {
1457            KPoint2d {
1458                x: offset[0].to_mm(),
1459                y: offset[1].to_mm(),
1460            }
1461        } else {
1462            KPoint2d { x: 100.0, y: 100.0 }
1463        })
1464        .precision(0)
1465        .font_scale(gdt_font_scale(font_size, args)?)
1466        .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
1467        .leader_scale(gdt_dot_leader_scale(leader_scale, font_size, args)?)
1468        .build();
1469    let options = AnnotationOptions::builder().feature_control(feature_control).build();
1470    exec_state
1471        .batch_modeling_cmd(
1472            ModelingCmdMeta::from_args_id(exec_state, args, annotation_id),
1473            ModelingCmd::from(
1474                mcmd::NewAnnotation::builder()
1475                    .options(options)
1476                    .clobber(false)
1477                    .annotation_type(AnnotationType::T3D)
1478                    .build(),
1479            ),
1480        )
1481        .await?;
1482    add_gdt_annotation_artifact(exec_state, args, annotation_id);
1483    annotations.push(GdtAnnotation {
1484        id: annotation_id,
1485        meta,
1486    });
1487    Ok(())
1488}
1489
1490fn resolve_datums(datums: Option<Vec<String>>, args: &Args, annotation_name: &str) -> Result<Vec<char>, KclError> {
1491    let datums = datums.unwrap_or_default();
1492    if datums.len() > 3 {
1493        return Err(KclError::new_semantic(KclErrorDetails::new(
1494            format!("{annotation_name} datums must include at most three names."),
1495            vec![args.source_range],
1496        )));
1497    }
1498
1499    let mut resolved = Vec::with_capacity(datums.len());
1500    for datum in &datums {
1501        let mut chars = datum.chars();
1502        let Some(name) = chars.next() else {
1503            return Err(KclError::new_semantic(KclErrorDetails::new(
1504                format!("{annotation_name} datum names must be a single character."),
1505                vec![args.source_range],
1506            )));
1507        };
1508        if chars.next().is_some() {
1509            return Err(KclError::new_semantic(KclErrorDetails::new(
1510                format!("{annotation_name} datum names must be a single character."),
1511                vec![args.source_range],
1512            )));
1513        }
1514        resolved.push(name);
1515    }
1516
1517    Ok(resolved)
1518}
1519
1520/// Get the XY plane by evaluating the `XY` expression so that it's the same as
1521/// if the user specified `XY`.
1522async fn xy_plane(exec_state: &mut ExecState, args: &Args) -> Result<Plane, KclError> {
1523    let plane_ast = plane_ast("XY", args.source_range);
1524    let metadata = Metadata::from(args.source_range);
1525    let plane_value = args
1526        .ctx
1527        .execute_expr(&plane_ast, exec_state, &metadata, &[], StatementKind::Expression)
1528        .await?;
1529    let plane_value = match plane_value.control {
1530        ControlFlowKind::Continue => plane_value.into_value(),
1531        ControlFlowKind::Exit => {
1532            let message = "Early return inside plane value is currently not supported".to_owned();
1533            debug_assert!(false, "{}", &message);
1534            return Err(KclError::new_internal(KclErrorDetails::new(
1535                message,
1536                vec![args.source_range],
1537            )));
1538        }
1539    };
1540    Ok(plane_value
1541        .as_plane()
1542        .ok_or_else(|| {
1543            KclError::new_internal(KclErrorDetails::new(
1544                "Expected XY plane to be defined".to_owned(),
1545                vec![args.source_range],
1546            ))
1547        })?
1548        .clone())
1549}
1550
1551/// An AST node for a plane with the given name.
1552fn plane_ast(plane_name: &str, range: SourceRange) -> ast::Node<ast::Expr> {
1553    ast::Node::new(
1554        ast::Expr::Name(Box::new(ast::Node::new(
1555            ast::Name {
1556                name: ast::Identifier::new(plane_name),
1557                path: Vec::new(),
1558                // TODO: We may want to set this to true once we implement it to
1559                // prevent it breaking if users redefine the identifier.
1560                abs_path: false,
1561                digest: None,
1562            },
1563            range.start(),
1564            range.end(),
1565            range.module_id(),
1566        ))),
1567        range.start(),
1568        range.end(),
1569        range.module_id(),
1570    )
1571}
1572
1573#[cfg(test)]
1574mod tests {
1575    use super::*;
1576    use crate::ExecutorContext;
1577    use crate::execution::Artifact;
1578    use crate::execution::ExecutorSettings;
1579    use crate::execution::MockConfig;
1580    use crate::execution::parse_execute;
1581
1582    const GDT_DISTANCE_KCL_TEMPLATE: &str = r#"
1583@settings(defaultLengthUnit = __UNIT__, kclVersion = 2)
1584
1585sketch001 = sketch(on = XY) {
1586  line1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
1587  line2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
1588  line3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
1589  line4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
1590  coincident([line1.end, line2.start])
1591  coincident([line2.end, line3.start])
1592  coincident([line3.end, line4.start])
1593  coincident([line4.end, line1.start])
1594  parallel([line2, line4])
1595  parallel([line3, line1])
1596  perpendicular([line1, line2])
1597  horizontal(line3)
1598}
1599
1600region001 = region(point = [5mm, 5mm], sketch = sketch001)
1601extrude001 = extrude(region001, length = 10mm)
1602gdt::distance(
1603  edges = [
1604    getCommonEdge(faces = [
1605      region001.tags.line4,
1606      region001.tags.line1
1607    ])
1608  ],
1609  tolerance = __TOLERANCE__,
1610  framePosition = __FRAME_POSITION__,
1611  fontSize = 2in,
1612)
1613"#;
1614
1615    const GDT_FLATNESS_KCL_TEMPLATE: &str = r#"
1616@settings(defaultLengthUnit = __UNIT__, kclVersion = 2)
1617
1618sketch001 = sketch(on = XY) {
1619  line1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
1620  line2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
1621  line3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
1622  line4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
1623  coincident([line1.end, line2.start])
1624  coincident([line2.end, line3.start])
1625  coincident([line3.end, line4.start])
1626  coincident([line4.end, line1.start])
1627  parallel([line2, line4])
1628  parallel([line3, line1])
1629  perpendicular([line1, line2])
1630  horizontal(line3)
1631}
1632
1633region001 = region(point = [5mm, 5mm], sketch = sketch001)
1634extrude001 = extrude(region001, length = 10mm, tagEnd = $capEnd001)
1635gdt::flatness(
1636  faces = [capEnd001],
1637  tolerance = __TOLERANCE__,
1638  framePosition = __FRAME_POSITION__,
1639  framePlane = XZ,
1640  fontSize = 2in,
1641)
1642"#;
1643
1644    fn gdt_distance_kcl(unit: &str, tolerance: &str, frame_position: &str) -> String {
1645        GDT_DISTANCE_KCL_TEMPLATE
1646            .replace("__UNIT__", unit)
1647            .replace("__TOLERANCE__", tolerance)
1648            .replace("__FRAME_POSITION__", frame_position)
1649    }
1650
1651    fn gdt_flatness_kcl(unit: &str, tolerance: &str, frame_position: &str) -> String {
1652        GDT_FLATNESS_KCL_TEMPLATE
1653            .replace("__UNIT__", unit)
1654            .replace("__TOLERANCE__", tolerance)
1655            .replace("__FRAME_POSITION__", frame_position)
1656    }
1657
1658    async fn gdt_commands(code: &str) -> Vec<ModelingCmd> {
1659        let result = parse_execute(code).await.unwrap();
1660        result
1661            .root_module_artifact_commands()
1662            .iter()
1663            .map(|artifact_command| artifact_command.command.clone())
1664            .collect()
1665    }
1666
1667    fn annotation_options(command: &ModelingCmd) -> Result<&AnnotationOptions, KclError> {
1668        let ModelingCmd::NewAnnotation(new_annotation) = command else {
1669            return Err(KclError::new_internal(KclErrorDetails::new(
1670                format!("expected new_annotation command, got {command:?}"),
1671                vec![SourceRange::default()],
1672            )));
1673        };
1674        Ok(&new_annotation.options)
1675    }
1676
1677    fn feature_control(command: &ModelingCmd) -> Result<&AnnotationFeatureControl, KclError> {
1678        let ModelingCmd::NewAnnotation(new_annotation) = command else {
1679            return Err(KclError::new_internal(KclErrorDetails::new(
1680                format!("expected new_annotation command, got {command:?}"),
1681                vec![SourceRange::default()],
1682            )));
1683        };
1684        new_annotation.options.feature_control.as_ref().ok_or_else(|| {
1685            KclError::new_internal(KclErrorDetails::new(
1686                "expected new_annotation command to have a feature_control".to_owned(),
1687                vec![SourceRange::default()],
1688            ))
1689        })
1690    }
1691
1692    #[track_caller]
1693    fn assert_close(actual: f64, expected: f64) {
1694        assert!((actual - expected).abs() < 1e-6, "expected {expected}, got {actual}");
1695    }
1696
1697    fn new_annotation_command_index(commands: &[ModelingCmd]) -> Result<usize, KclError> {
1698        commands
1699            .iter()
1700            .position(|command| matches!(command, ModelingCmd::NewAnnotation(_)))
1701            .ok_or_else(|| {
1702                KclError::new_internal(KclErrorDetails::new(
1703                    "expected commands to contain a new_annotation command".to_owned(),
1704                    vec![SourceRange::default()],
1705                ))
1706            })
1707    }
1708
1709    #[test]
1710    fn gdt_font_scale_is_scene_height_divided_by_calibration_height() {
1711        let scale_at_calibrated_height = gdt_font_scale_for_height_mm(GDT_FONT_SCALE_1_HEIGHT_MM);
1712        assert!((scale_at_calibrated_height - 1.0).abs() < f32::EPSILON);
1713
1714        let double_height_scale = gdt_font_scale_for_height_mm(GDT_FONT_SCALE_1_HEIGHT_MM * 2.0);
1715        assert!((double_height_scale - 2.0).abs() < f32::EPSILON);
1716
1717        let inch_in_mm = 25.4;
1718        let inch_scale = gdt_font_scale_for_height_mm(inch_in_mm);
1719        assert!((inch_scale - (inch_in_mm / GDT_FONT_SCALE_1_HEIGHT_MM) as f32).abs() < f32::EPSILON);
1720    }
1721
1722    const GDT_FLATNESS_LEADER_KCL_TEMPLATE: &str = r#"
1723@settings(defaultLengthUnit = mm, kclVersion = 2)
1724
1725blockProfile = sketch(on = XY) {
1726  edge1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
1727  edge2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
1728  edge3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
1729  edge4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
1730  coincident([edge1.end, edge2.start])
1731  coincident([edge2.end, edge3.start])
1732  coincident([edge3.end, edge4.start])
1733  coincident([edge4.end, edge1.start])
1734  parallel([edge2, edge4])
1735  parallel([edge3, edge1])
1736  perpendicular([edge1, edge2])
1737  horizontal(edge3)
1738}
1739
1740region001 = region(point = [5mm, 5mm], sketch = blockProfile)
1741extrude001 = extrude(region001, length = 10mm, tagEnd = $top)
1742gdt::flatness(
1743  faces = [top],
1744  tolerance = 0.1mm,
1745  framePosition = [10mm, 0mm],
1746  framePlane = XZ,
1747  fontSize = __FONT_SIZE__
1748  __LEADER_SCALE__
1749)
1750"#;
1751
1752    fn gdt_flatness_leader_kcl(font_size: &str, leader_scale: Option<&str>) -> String {
1753        GDT_FLATNESS_LEADER_KCL_TEMPLATE
1754            .replace("__FONT_SIZE__", font_size)
1755            .replace(
1756                "__LEADER_SCALE__",
1757                leader_scale
1758                    .map(|scale| format!(",\n  leaderScale = {scale}"))
1759                    .unwrap_or_default()
1760                    .as_str(),
1761            )
1762    }
1763
1764    async fn gdt_flatness_feature_control(
1765        font_size: &str,
1766        leader_scale: Option<&str>,
1767    ) -> Result<AnnotationFeatureControl, KclError> {
1768        let code = gdt_flatness_leader_kcl(font_size, leader_scale);
1769        let commands = gdt_commands(&code).await;
1770        let annotation_index = new_annotation_command_index(&commands)?;
1771        Ok(feature_control(&commands[annotation_index])?.clone())
1772    }
1773
1774    #[tokio::test(flavor = "multi_thread")]
1775    async fn gdt_dot_leader_scale_is_normalized_against_font_scale() -> Result<(), KclError> {
1776        let tiny = gdt_flatness_feature_control("1mm", None).await?;
1777        let large = gdt_flatness_feature_control("100mm", None).await?;
1778
1779        assert_close(f64::from(tiny.font_scale), gdt_font_scale_for_height_mm(1.0).into());
1780        assert_close(f64::from(large.font_scale), gdt_font_scale_for_height_mm(100.0).into());
1781        assert_close(f64::from(tiny.leader_scale), 50.0);
1782        assert_close(f64::from(large.leader_scale), 0.5);
1783
1784        assert_close(
1785            f64::from(tiny.font_scale) * f64::from(tiny.leader_scale),
1786            f64::from(gdt_dot_leader_normal_size()),
1787        );
1788        assert_close(
1789            f64::from(large.font_scale) * f64::from(large.leader_scale),
1790            f64::from(gdt_dot_leader_normal_size()),
1791        );
1792        Ok(())
1793    }
1794
1795    #[tokio::test(flavor = "multi_thread")]
1796    async fn explicit_gdt_dot_leader_scale_multiplies_normal_size() -> Result<(), KclError> {
1797        let tiny = gdt_flatness_feature_control("1mm", Some("2")).await?;
1798        let large = gdt_flatness_feature_control("100mm", Some("2")).await?;
1799
1800        let expected_scaled_dot_size = f64::from(gdt_dot_leader_normal_size()) * 2.0;
1801        assert_close(
1802            f64::from(tiny.font_scale) * f64::from(tiny.leader_scale),
1803            expected_scaled_dot_size,
1804        );
1805        assert_close(
1806            f64::from(large.font_scale) * f64::from(large.leader_scale),
1807            expected_scaled_dot_size,
1808        );
1809        Ok(())
1810    }
1811
1812    #[tokio::test(flavor = "multi_thread")]
1813    async fn gdt_flatness_uses_scene_units_for_control_frame_tolerance() -> Result<(), KclError> {
1814        let cases = [
1815            ("in", "0.1in", "[10, -10]", 0.1, 254.0, -254.0),
1816            ("cm", "10mm", "[1, -1]", 1.0, 10.0, -10.0),
1817        ];
1818
1819        for (default_unit, tolerance, frame_position, expected_tolerance, expected_x, expected_y) in cases {
1820            let code = gdt_flatness_kcl(default_unit, tolerance, frame_position);
1821            let commands = gdt_commands(&code).await;
1822            let annotation_index = new_annotation_command_index(&commands)?;
1823            let feature_control = feature_control(&commands[annotation_index])?;
1824            let control_frame = feature_control.control_frame.as_ref().ok_or_else(|| {
1825                KclError::new_internal(KclErrorDetails::new(
1826                    "expected feature_control to have a control_frame".to_owned(),
1827                    vec![SourceRange::default()],
1828                ))
1829            })?;
1830
1831            assert_close(control_frame.tolerance, expected_tolerance);
1832            assert_close(feature_control.offset.x, expected_x);
1833            assert_close(feature_control.offset.y, expected_y);
1834            assert_close(
1835                f64::from(feature_control.font_scale),
1836                gdt_font_scale_for_height_mm(50.8).into(),
1837            );
1838        }
1839        Ok(())
1840    }
1841
1842    #[tokio::test(flavor = "multi_thread")]
1843    async fn gdt_distance_sets_units() -> Result<(), KclError> {
1844        let cases = [
1845            (
1846                "in",
1847                "2.54mm",
1848                "[10, -10]",
1849                kcmc::units::UnitLength::Inches,
1850                0.1,
1851                254.0,
1852                -254.0,
1853            ),
1854            (
1855                "cm",
1856                "10mm",
1857                "[1, -1]",
1858                kcmc::units::UnitLength::Centimeters,
1859                1.0,
1860                10.0,
1861                -10.0,
1862            ),
1863            (
1864                "mm",
1865                "2.54mm",
1866                "[10, -10]",
1867                kcmc::units::UnitLength::Millimeters,
1868                2.54,
1869                10.0,
1870                -10.0,
1871            ),
1872        ];
1873
1874        for (default_unit, tolerance, frame_position, scene_unit, expected_tolerance, expected_x, expected_y) in cases {
1875            let code = gdt_distance_kcl(default_unit, tolerance, frame_position);
1876            let commands = gdt_commands(&code).await;
1877            let annotation_index = new_annotation_command_index(&commands)?;
1878            let options = annotation_options(&commands[annotation_index])?;
1879
1880            assert_eq!(options.units, Some(scene_unit));
1881
1882            let dimension = options
1883                .dimension
1884                .as_ref()
1885                .expect("expected new_annotation command to have a dimension");
1886            assert_close(dimension.dimension.tolerance, expected_tolerance);
1887            assert_close(dimension.offset.x, expected_x);
1888            assert_close(dimension.offset.y, expected_y);
1889            assert_close(
1890                f64::from(dimension.font_scale),
1891                gdt_font_scale_for_height_mm(50.8).into(),
1892            );
1893        }
1894        Ok(())
1895    }
1896
1897    const GDT_DATUM_KCL: &str = r#"
1898blockProfile = sketch(on = XY) {
1899  edge1 = line(start = [var 0mm, var 0mm], end = [var 8mm, var 0mm])
1900  edge2 = line(start = [var 8mm, var 0mm], end = [var 8mm, var 5mm])
1901  edge3 = line(start = [var 8mm, var 5mm], end = [var 0mm, var 5mm])
1902  edge4 = line(start = [var 0mm, var 5mm], end = [var 0mm, var 0mm])
1903  coincident([edge1.end, edge2.start])
1904  coincident([edge2.end, edge3.start])
1905  coincident([edge3.end, edge4.start])
1906  coincident([edge4.end, edge1.start])
1907  horizontal(edge1)
1908  vertical(edge2)
1909  horizontal(edge3)
1910  vertical(edge4)
1911}
1912
1913block = extrude(region(point = [4mm, 2mm], sketch = blockProfile), length = 4mm, tagEnd = $top)
1914
1915gdt::datum(face = top, name = "A", framePosition = [10mm, 0mm], framePlane = XZ)
1916"#;
1917
1918    async fn gdt_artifact_count(skip_artifact_graph: bool) -> usize {
1919        let settings = ExecutorSettings {
1920            skip_artifact_graph,
1921            ..Default::default()
1922        };
1923        let ctx = ExecutorContext::new_mock(Some(settings)).await;
1924        let program = crate::Program::parse_no_errs(GDT_DATUM_KCL).unwrap();
1925        let mock_config = MockConfig {
1926            use_prev_memory: false,
1927            ..Default::default()
1928        };
1929        let outcome = ctx.run_mock(&program, &mock_config).await.unwrap();
1930        ctx.close().await;
1931
1932        outcome
1933            .artifact_graph
1934            .values()
1935            .filter(|artifact| matches!(artifact, Artifact::GdtAnnotation(_)))
1936            .count()
1937    }
1938
1939    #[tokio::test(flavor = "multi_thread")]
1940    async fn gdt_annotations_do_not_follow_runtime_artifact_graph_setting() {
1941        assert_eq!(gdt_artifact_count(false).await, 1);
1942        assert_eq!(gdt_artifact_count(true).await, 1);
1943    }
1944
1945    // Mirrors the gdt::circularity doc examples: annotate a cylinder's circular
1946    // edge and its curved wall. Runs in mock mode, so it validates parsing, name
1947    // resolution, and that the control frame uses the Roundness (circularity)
1948    // symbol without datums. The doc examples additionally render against the
1949    // engine in kcl_test_examples.
1950    const GDT_CIRCULARITY_EDGE_KCL: &str = r#"
1951@settings(defaultLengthUnit = mm, kclVersion = 2)
1952
1953cylinderSketch = sketch(on = XY) {
1954  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
1955}
1956
1957cylinderRegion = region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch)
1958hide(cylinderSketch)
1959cylinder = extrude(cylinderRegion, length = 10mm)
1960gdt::circularity(edges = [cylinderRegion.tags.perimeter], tolerance = 0.05mm)
1961"#;
1962
1963    const GDT_CIRCULARITY_WALL_KCL: &str = r#"
1964@settings(defaultLengthUnit = mm, kclVersion = 2)
1965
1966cylinderSketch = sketch(on = XY) {
1967  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
1968}
1969
1970cylinder = extrude(region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch), length = 10mm)
1971gdt::circularity(faces = [cylinder.sketch.tags.perimeter], tolerance = 0.02mm, framePosition = [12mm, 8mm], framePlane = XZ)
1972"#;
1973
1974    const GDT_CIRCULARITY_COMMON_EDGE_KCL: &str = r#"
1975@settings(defaultLengthUnit = mm, kclVersion = 2)
1976
1977cylinderSketch = sketch(on = XY) {
1978  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
1979}
1980
1981cylinder = extrude(region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch), length = 10mm, tagEnd = $top)
1982topEdge = getCommonEdge(faces = [cylinder.sketch.tags.perimeter, top])
1983gdt::circularity(edges = [topEdge], tolerance = 0.05mm, framePosition = [12mm, 8mm], framePlane = XZ)
1984"#;
1985
1986    #[tokio::test(flavor = "multi_thread")]
1987    async fn gdt_circularity_uses_roundness_symbol_without_datums() -> Result<(), KclError> {
1988        let cases = [
1989            ("circular edge", GDT_CIRCULARITY_EDGE_KCL, 0.05),
1990            ("cylinder wall", GDT_CIRCULARITY_WALL_KCL, 0.02),
1991            ("common edge", GDT_CIRCULARITY_COMMON_EDGE_KCL, 0.05),
1992        ];
1993
1994        for (label, code, expected_tolerance) in cases {
1995            let commands = gdt_commands(code).await;
1996            let annotation_index = new_annotation_command_index(&commands)?;
1997            let feature_control = feature_control(&commands[annotation_index])?;
1998            let control_frame = feature_control.control_frame.as_ref().ok_or_else(|| {
1999                KclError::new_internal(KclErrorDetails::new(
2000                    format!("expected {label} feature_control to have a control_frame"),
2001                    vec![SourceRange::default()],
2002                ))
2003            })?;
2004
2005            assert_eq!(control_frame.symbol, MbdSymbol::Roundness, "case: {label}");
2006            assert_close(control_frame.tolerance, expected_tolerance);
2007            // Circularity is a form tolerance and never references datums.
2008            assert!(control_frame.primary_datum.is_none(), "case: {label}");
2009            assert!(control_frame.secondary_datum.is_none(), "case: {label}");
2010            assert!(control_frame.tertiary_datum.is_none(), "case: {label}");
2011        }
2012        Ok(())
2013    }
2014}