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::AnnotationFeatureTag;
7use kittycad_modeling_cmds::shared::AnnotationLineEnd;
8use kittycad_modeling_cmds::shared::AnnotationMbdBasicDimension;
9use kittycad_modeling_cmds::shared::AnnotationMbdControlFrame;
10use kittycad_modeling_cmds::shared::AnnotationMbdLeaderPosition;
11use kittycad_modeling_cmds::shared::AnnotationOptions;
12use kittycad_modeling_cmds::shared::AnnotationType;
13use kittycad_modeling_cmds::shared::MbdSymbol;
14use kittycad_modeling_cmds::shared::Point2d as KPoint2d;
15use kittycad_modeling_cmds::{self as kcmc};
16
17use crate::ExecState;
18use crate::KclError;
19use crate::errors::KclErrorDetails;
20use crate::exec::KclValue;
21use crate::execution::Artifact;
22use crate::execution::ArtifactId;
23use crate::execution::CodeRef;
24use crate::execution::Face;
25use crate::execution::GdtAnnotation;
26use crate::execution::GdtAnnotationArtifact;
27use crate::execution::Metadata;
28use crate::execution::ModelingCmdMeta;
29use crate::execution::Plane;
30use crate::execution::TagIdentifier;
31use crate::execution::types::ArrayLen;
32use crate::execution::types::RuntimeType;
33use crate::parsing::ast::types as ast;
34use crate::parsing::ast::types::BoxNode;
35use crate::std::Args;
36use crate::std::args::FromKclValue;
37use crate::std::args::TyF64;
38use crate::std::edge;
39use crate::std::fillet::EdgeReference;
40use crate::std::sketch::ensure_sketch_plane_in_engine;
41use crate::unit_conversion::ToKcmc;
42
43// The engine exposes two text knobs:
44// - font_point_size controls the FreeType raster/bitmap texture resolution in pixels/points.
45// - font_scale is the unitless model-space multiplier applied to that texture.
46// KCL exposes only fontSize as a Length. Keep the raster quality fixed so changing
47// quality does not resize the text, and map the requested length into font_scale.
48const GDT_FONT_TEXTURE_POINT_SIZE: u32 = 36;
49const DEFAULT_GDT_FONT_SIZE_MM: f64 = 10.0;
50const DEFAULT_GDT_DOT_LEADER_SCALE: f64 = 1.0;
51const DEFAULT_GDT_DIMENSION_LEADER_SCALE: f64 = 1.0;
52const GDT_DOT_LEADER_REFERENCE_FONT_SIZE_MM: f64 = 100.0;
53const GDT_DOT_LEADER_REFERENCE_ENGINE_SCALE: f64 = 0.5;
54
55// Calibration target: measured annotation text/frame height in millimeters when
56// font_scale is 1.0 and GDT_FONT_TEXTURE_POINT_SIZE is fixed. Tune this value from
57// scene measurements, not by exposing engine font_point_size to users.
58const GDT_FONT_SCALE_1_HEIGHT_MM: f64 = 8.0;
59
60fn gdt_font_scale(font_size: Option<&TyF64>, args: &Args) -> Result<f32, KclError> {
61    let requested_height_mm = font_size.map(TyF64::to_mm).unwrap_or(DEFAULT_GDT_FONT_SIZE_MM);
62    if requested_height_mm <= 0.0 {
63        return Err(KclError::new_semantic(KclErrorDetails::new(
64            "fontSize must be greater than 0.".to_owned(),
65            vec![args.source_range],
66        )));
67    }
68    Ok(gdt_font_scale_for_height_mm(requested_height_mm))
69}
70
71fn gdt_font_scale_for_height_mm(requested_height_mm: f64) -> f32 {
72    (requested_height_mm / GDT_FONT_SCALE_1_HEIGHT_MM) as f32
73}
74
75fn gdt_user_leader_scale(leader_scale: Option<&TyF64>, default_scale: f64, args: &Args) -> Result<f32, KclError> {
76    let scale = leader_scale.map(|scale| scale.n).unwrap_or(default_scale);
77    if scale <= 0.0 {
78        return Err(KclError::new_semantic(KclErrorDetails::new(
79            "leaderScale must be greater than 0.".to_owned(),
80            vec![args.source_range],
81        )));
82    }
83    Ok(scale as f32)
84}
85
86fn gdt_dot_leader_scale(leader_scale: Option<&TyF64>, font_size: Option<&TyF64>, args: &Args) -> Result<f32, KclError> {
87    let user_scale = gdt_user_leader_scale(leader_scale, DEFAULT_GDT_DOT_LEADER_SCALE, args)?;
88    // Engine dot leaders are screen-space point sprites after an internal font_scale
89    // multiplier. Divide that out so KCL leaderScale stays stable across fontSize.
90    Ok(user_scale * gdt_dot_leader_normal_size() / gdt_font_scale(font_size, args)?)
91}
92
93fn gdt_dot_leader_normal_size() -> f32 {
94    gdt_font_scale_for_height_mm(GDT_DOT_LEADER_REFERENCE_FONT_SIZE_MM) * GDT_DOT_LEADER_REFERENCE_ENGINE_SCALE as f32
95}
96
97fn gdt_dimension_leader_scale(leader_scale: Option<&TyF64>, args: &Args) -> Result<f32, KclError> {
98    gdt_user_leader_scale(leader_scale, DEFAULT_GDT_DIMENSION_LEADER_SCALE, args)
99}
100
101fn gdt_annotation_name(exec_state: &mut ExecState, args: &Args) -> Result<Option<String>, KclError> {
102    args.get_kw_arg_opt("annotationName", &RuntimeType::string(), exec_state)
103}
104
105#[derive(Debug, Clone)]
106enum DistanceEntity {
107    Face(Box<Face>),
108    TaggedFace(Box<TagIdentifier>),
109    Edge(EdgeReference),
110    Specifier(kcmc::shared::EdgeSpecifier),
111}
112
113#[derive(Debug, Clone)]
114enum GdtEdgeReference {
115    Entity(EdgeReference),
116    Specifier(kcmc::shared::EdgeSpecifier),
117}
118
119#[derive(Debug, Clone)]
120struct DistanceEndpoint {
121    entity_id: Option<uuid::Uuid>,
122    edge_reference: Option<kcmc::shared::EdgeSpecifier>,
123    entity_pos: AnnotationMbdLeaderPosition,
124}
125
126#[derive(Debug, Clone, Copy)]
127enum GdtFeatureControlKind {
128    Flatness,
129    Straightness,
130    Circularity,
131    Cylindricity,
132    Concentricity,
133    Symmetry,
134    Runout,
135    ProfileLine,
136    ProfileSurface,
137    Position,
138    Angularity,
139    Perpendicularity,
140    Parallelism,
141}
142
143struct GdtFeatureControlParams {
144    faces: Vec<TagIdentifier>,
145    edges: Vec<GdtEdgeReference>,
146    datums: Option<Vec<String>>,
147    tolerance: TyF64,
148    precision: Option<TyF64>,
149    frame_position: Option<[TyF64; 2]>,
150    frame_plane: Option<Plane>,
151    leader_scale: Option<TyF64>,
152    font_size: Option<TyF64>,
153}
154
155struct GdtProfileCommonParams {
156    datums: Option<Vec<String>>,
157    tolerance: TyF64,
158    precision: Option<TyF64>,
159    frame_position: Option<[TyF64; 2]>,
160    frame_plane: Option<Plane>,
161    leader_scale: Option<TyF64>,
162    font_size: Option<TyF64>,
163}
164
165impl GdtFeatureControlKind {
166    fn label(self) -> &'static str {
167        match self {
168            Self::Flatness => "Flatness",
169            Self::Straightness => "Straightness",
170            Self::Circularity => "Circularity",
171            Self::Cylindricity => "Cylindricity",
172            Self::Concentricity => "Concentricity",
173            Self::Symmetry => "Symmetry",
174            Self::Runout => "Runout",
175            Self::ProfileLine => "Profile line",
176            Self::ProfileSurface => "Profile surface",
177            Self::Position => "Position",
178            Self::Angularity => "Angularity",
179            Self::Perpendicularity => "Perpendicularity",
180            Self::Parallelism => "Parallelism",
181        }
182    }
183
184    fn symbol(self) -> MbdSymbol {
185        match self {
186            Self::Flatness => MbdSymbol::Flatness,
187            Self::Straightness => MbdSymbol::Straightness,
188            Self::Circularity => MbdSymbol::Roundness,
189            Self::Cylindricity => MbdSymbol::Cylindricity,
190            Self::Concentricity => MbdSymbol::Concentricity,
191            Self::Symmetry => MbdSymbol::Symmetry,
192            Self::Runout => MbdSymbol::Runout,
193            Self::ProfileLine => MbdSymbol::ProfileOfLine,
194            Self::ProfileSurface => MbdSymbol::SurfaceProfile,
195            Self::Position => MbdSymbol::Position,
196            Self::Angularity => MbdSymbol::Angularity,
197            Self::Perpendicularity => MbdSymbol::Perpendicularity,
198            Self::Parallelism => MbdSymbol::Parallelism,
199        }
200    }
201
202    fn diameter_symbol(self) -> Option<MbdSymbol> {
203        match self {
204            Self::Concentricity => Some(MbdSymbol::Diameter),
205            _ => None,
206        }
207    }
208
209    fn requires_datums(self) -> bool {
210        matches!(self, Self::Concentricity | Self::Symmetry | Self::Runout)
211    }
212}
213
214fn add_gdt_annotation_artifact(exec_state: &mut ExecState, args: &Args, annotation_id: uuid::Uuid) {
215    exec_state.add_artifact(Artifact::GdtAnnotation(GdtAnnotationArtifact {
216        id: ArtifactId::new(annotation_id),
217        code_ref: CodeRef::placeholder(args.source_range),
218        consumed: false,
219    }));
220}
221
222impl DistanceEntity {
223    async fn to_endpoint(&self, exec_state: &mut ExecState, args: &Args) -> Result<DistanceEndpoint, KclError> {
224        match self {
225            DistanceEntity::Face(face) => Ok(DistanceEndpoint {
226                entity_id: Some(face.id),
227                edge_reference: None,
228                entity_pos: AnnotationMbdLeaderPosition::Centroid {},
229            }),
230            DistanceEntity::TaggedFace(face) => Ok(DistanceEndpoint {
231                entity_id: Some(args.get_adjacent_face_to_tag(exec_state, face, false).await?),
232                edge_reference: None,
233                entity_pos: AnnotationMbdLeaderPosition::Centroid {},
234            }),
235            DistanceEntity::Edge(edge) => Ok(DistanceEndpoint {
236                entity_id: Some(edge.get_engine_id(exec_state, args)?),
237                edge_reference: None,
238                entity_pos: AnnotationMbdLeaderPosition::Centroid {},
239            }),
240            DistanceEntity::Specifier(edge_reference) => Ok(DistanceEndpoint {
241                entity_id: None,
242                edge_reference: Some(edge_reference.clone()),
243                entity_pos: AnnotationMbdLeaderPosition::Centroid {},
244            }),
245        }
246    }
247}
248
249impl<'a> FromKclValue<'a> for DistanceEntity {
250    fn from_kcl_val(arg: &'a KclValue) -> Option<Self> {
251        match arg {
252            KclValue::Face { value } => Some(Self::Face(value.to_owned())),
253            KclValue::Uuid { value, .. } => Some(Self::Edge(EdgeReference::Uuid(*value))),
254            KclValue::TagIdentifier(value) => Some(Self::TaggedFace(value.to_owned())),
255            _ => None,
256        }
257    }
258}
259
260async fn parse_distance_entity_arg(
261    arg_name: &str,
262    exec_state: &mut ExecState,
263    args: &Args,
264) -> Result<Option<DistanceEntity>, KclError> {
265    let Some(value): Option<KclValue> = args.get_kw_arg_opt(arg_name, &RuntimeType::any(), exec_state)? else {
266        return Ok(None);
267    };
268
269    if edge::is_edge_specifier_object(&value) {
270        let unresolved = edge::parse_edge_specifier_value(&value, args)?;
271        let edge_reference = edge::resolve_edge_specifier_with_face_tags(&unresolved, None, exec_state, args).await?;
272        return Ok(Some(DistanceEntity::Specifier(edge_reference)));
273    }
274
275    DistanceEntity::from_kcl_val(&value)
276        .map(Some)
277        .ok_or_else(|| {
278            KclError::new_type(KclErrorDetails::new(
279                format!(
280                    "`{arg_name}` must be a face, tagged face, tagged edge, edge UUID, or edge specifier object (e.g. {{ sideFaces = [...], endFaces = [...], index = 0 }})"
281                ),
282                vec![args.source_range],
283            ))
284        })
285}
286
287async fn parse_gdt_edges_arg(exec_state: &mut ExecState, args: &Args) -> Result<Vec<GdtEdgeReference>, KclError> {
288    // Face API edge specifiers are object-shaped payloads, so keep the runtime type
289    // broad here and validate each element below. This mirrors fillet/chamfer.
290    let Some(edges): Option<Vec<KclValue>> = args.get_kw_arg_opt("edges", &RuntimeType::any_array(), exec_state)?
291    else {
292        return Ok(Vec::new());
293    };
294
295    if edges.is_empty() {
296        return Err(KclError::new_semantic(KclErrorDetails::new(
297            "`edges` must contain at least one edge.".to_owned(),
298            vec![args.source_range],
299        )));
300    }
301
302    let mut parsed_edges = Vec::with_capacity(edges.len());
303    for edge_value in &edges {
304        if edge::is_edge_specifier_object(edge_value) {
305            let unresolved = edge::parse_edge_specifier_value(edge_value, args)?;
306            let edge_reference =
307                edge::resolve_edge_specifier_with_face_tags(&unresolved, None, exec_state, args).await?;
308            parsed_edges.push(GdtEdgeReference::Specifier(edge_reference));
309        } else if let Some(edge) = EdgeReference::from_kcl_val(edge_value) {
310            parsed_edges.push(GdtEdgeReference::Entity(edge));
311        } else {
312            return Err(KclError::new_type(KclErrorDetails::new(
313                "edges must contain tagged edges, edge UUIDs, or edge specifier objects (e.g. { sideFaces = [...], endFaces = [...], index = 0 })".to_owned(),
314                vec![args.source_range],
315            )));
316        }
317    }
318    Ok(parsed_edges)
319}
320
321pub async fn datum(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
322    let face: TagIdentifier = args.get_kw_arg("face", &RuntimeType::tagged_face(), exec_state)?;
323    let name: String = args.get_kw_arg("name", &RuntimeType::string(), exec_state)?;
324    let frame_position: Option<[TyF64; 2]> =
325        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
326    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
327    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
328    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
329
330    let annotation = inner_datum(
331        face,
332        name,
333        frame_position,
334        frame_plane,
335        leader_scale,
336        font_size,
337        exec_state,
338        &args,
339    )
340    .await?;
341    Ok(KclValue::GdtAnnotation {
342        value: Box::new(annotation),
343    })
344}
345
346#[allow(clippy::too_many_arguments)]
347async fn inner_datum(
348    face: TagIdentifier,
349    name: String,
350    frame_position: Option<[TyF64; 2]>,
351    frame_plane: Option<Plane>,
352    leader_scale: Option<TyF64>,
353    font_size: Option<TyF64>,
354    exec_state: &mut ExecState,
355    args: &Args,
356) -> Result<GdtAnnotation, KclError> {
357    const DATUM_LENGTH_ERROR: &str = "Datum name must be a single character.";
358    if name.len() > 1 {
359        return Err(KclError::new_semantic(KclErrorDetails::new(
360            DATUM_LENGTH_ERROR.to_owned(),
361            vec![args.source_range],
362        )));
363    }
364    let name_char = name.chars().next().ok_or_else(|| {
365        KclError::new_semantic(KclErrorDetails::new(
366            DATUM_LENGTH_ERROR.to_owned(),
367            vec![args.source_range],
368        ))
369    })?;
370    let mut frame_plane = if let Some(plane) = frame_plane {
371        plane
372    } else {
373        // No plane given. Use one of the standard planes.
374        xy_plane(exec_state, args).await?
375    };
376    ensure_sketch_plane_in_engine(
377        &mut frame_plane,
378        exec_state,
379        &args.ctx,
380        args.source_range,
381        args.node_path.clone(),
382    )
383    .await?;
384    let face_id = args.get_adjacent_face_to_tag(exec_state, &face, false).await?;
385    let meta = vec![Metadata::from(args.source_range)];
386    let annotation_id = exec_state.next_uuid();
387    let feature_control = AnnotationFeatureControl::builder()
388        .maybe_entity_id(Some(face_id))
389        // Point to the center of the face.
390        .entity_pos(KPoint2d { x: 0.5, y: 0.5 })
391        .leader_type(AnnotationLineEnd::Dot)
392        .defined_datum(name_char)
393        .plane_id(frame_plane.id)
394        .offset(if let Some(offset) = &frame_position {
395            KPoint2d {
396                x: offset[0].to_mm(),
397                y: offset[1].to_mm(),
398            }
399        } else {
400            KPoint2d { x: 100.0, y: 100.0 }
401        })
402        .precision(0)
403        .font_scale(gdt_font_scale(font_size.as_ref(), args)?)
404        .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
405        .leader_scale(gdt_dot_leader_scale(leader_scale.as_ref(), font_size.as_ref(), args)?)
406        .build();
407    let annotation_name = gdt_annotation_name(exec_state, args)?;
408    let options = AnnotationOptions::builder()
409        .feature_control(feature_control)
410        .maybe_name(annotation_name)
411        .build();
412    exec_state
413        .batch_modeling_cmd(
414            ModelingCmdMeta::from_args_id(exec_state, args, annotation_id),
415            ModelingCmd::from(
416                mcmd::NewAnnotation::builder()
417                    .options(options)
418                    .clobber(false)
419                    .annotation_type(AnnotationType::T3D)
420                    .build(),
421            ),
422        )
423        .await?;
424    add_gdt_annotation_artifact(exec_state, args, annotation_id);
425    Ok(GdtAnnotation {
426        id: annotation_id,
427        meta,
428    })
429}
430
431pub async fn note(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
432    let note: String = args.get_kw_arg("note", &RuntimeType::string(), exec_state)?;
433    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
434    let frame_position: Option<[TyF64; 2]> =
435        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
436    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
437
438    let annotation = inner_note(note, frame_plane, frame_position, font_size, exec_state, &args).await?;
439    Ok(KclValue::GdtAnnotation {
440        value: Box::new(annotation),
441    })
442}
443
444async fn inner_note(
445    note: String,
446    frame_plane: Option<Plane>,
447    frame_position: Option<[TyF64; 2]>,
448    font_size: Option<TyF64>,
449    exec_state: &mut ExecState,
450    args: &Args,
451) -> Result<GdtAnnotation, KclError> {
452    let mut frame_plane = if let Some(plane) = frame_plane {
453        plane
454    } else {
455        // No plane given. Default to the world XY plane.
456        xy_plane(exec_state, args).await?
457    };
458    ensure_sketch_plane_in_engine(
459        &mut frame_plane,
460        exec_state,
461        &args.ctx,
462        args.source_range,
463        args.node_path.clone(),
464    )
465    .await?;
466    let meta = vec![Metadata::from(args.source_range)];
467    let annotation_id = exec_state.next_uuid();
468    // A note does not attach to a face. Passing the plane as the entity tells the engine to
469    // place the note inline on that plane with no leader (an MBD free note).
470    let feature_tag = AnnotationFeatureTag::builder()
471        .maybe_entity_id(Some(frame_plane.id))
472        .entity_pos(KPoint2d { x: 0.0, y: 0.0 })
473        .leader_type(AnnotationLineEnd::None)
474        .key(String::new())
475        .value(note)
476        .show_key(false)
477        .plane_id(frame_plane.id)
478        .offset(if let Some(offset) = &frame_position {
479            KPoint2d {
480                x: offset[0].to_mm(),
481                y: offset[1].to_mm(),
482            }
483        } else {
484            KPoint2d { x: 100.0, y: 100.0 }
485        })
486        .font_scale(gdt_font_scale(font_size.as_ref(), args)?)
487        .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
488        .leader_scale(1.0)
489        .build();
490    let annotation_name = gdt_annotation_name(exec_state, args)?;
491    let options = AnnotationOptions::builder()
492        .feature_tag(feature_tag)
493        .maybe_name(annotation_name)
494        .build();
495    exec_state
496        .batch_modeling_cmd(
497            ModelingCmdMeta::from_args_id(exec_state, args, annotation_id),
498            ModelingCmd::from(
499                mcmd::NewAnnotation::builder()
500                    .options(options)
501                    .clobber(false)
502                    .annotation_type(AnnotationType::T3D)
503                    .build(),
504            ),
505        )
506        .await?;
507    add_gdt_annotation_artifact(exec_state, args, annotation_id);
508    Ok(GdtAnnotation {
509        id: annotation_id,
510        meta,
511    })
512}
513
514pub async fn flatness(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
515    let faces: Vec<TagIdentifier> = args.get_kw_arg(
516        "faces",
517        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
518        exec_state,
519    )?;
520    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
521    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
522    let frame_position: Option<[TyF64; 2]> =
523        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
524    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
525    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
526    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
527
528    let annotations = create_feature_control_annotations(
529        GdtFeatureControlKind::Flatness,
530        GdtFeatureControlParams {
531            faces,
532            edges: Vec::new(),
533            datums: None,
534            tolerance,
535            precision,
536            frame_position,
537            frame_plane,
538            leader_scale,
539            font_size,
540        },
541        exec_state,
542        &args,
543    )
544    .await?;
545    Ok(annotations.into())
546}
547
548pub async fn straightness(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
549    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
550        "faces",
551        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
552        exec_state,
553    )?;
554    let edges = parse_gdt_edges_arg(exec_state, &args).await?;
555    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
556    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
557    let frame_position: Option<[TyF64; 2]> =
558        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
559    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
560    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
561    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
562
563    let annotations = create_feature_control_annotations(
564        GdtFeatureControlKind::Straightness,
565        GdtFeatureControlParams {
566            faces: faces.unwrap_or_default(),
567            edges,
568            datums: None,
569            tolerance,
570            precision,
571            frame_position,
572            frame_plane,
573            leader_scale,
574            font_size,
575        },
576        exec_state,
577        &args,
578    )
579    .await?;
580    Ok(annotations.into())
581}
582
583pub async fn circularity(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
584    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
585        "faces",
586        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
587        exec_state,
588    )?;
589    let edges = parse_gdt_edges_arg(exec_state, &args).await?;
590    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
591    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
592    let frame_position: Option<[TyF64; 2]> =
593        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
594    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
595    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
596    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
597
598    let annotations = create_feature_control_annotations(
599        GdtFeatureControlKind::Circularity,
600        GdtFeatureControlParams {
601            faces: faces.unwrap_or_default(),
602            edges,
603            datums: None,
604            tolerance,
605            precision,
606            frame_position,
607            frame_plane,
608            leader_scale,
609            font_size,
610        },
611        exec_state,
612        &args,
613    )
614    .await?;
615    Ok(annotations.into())
616}
617
618pub async fn cylindricity(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
619    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
620        "faces",
621        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
622        exec_state,
623    )?;
624    let edges = parse_gdt_edges_arg(exec_state, &args).await?;
625    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
626    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
627    let frame_position: Option<[TyF64; 2]> =
628        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
629    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
630    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
631    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
632
633    let annotations = create_feature_control_annotations(
634        GdtFeatureControlKind::Cylindricity,
635        GdtFeatureControlParams {
636            faces: faces.unwrap_or_default(),
637            edges,
638            datums: None,
639            tolerance,
640            precision,
641            frame_position,
642            frame_plane,
643            leader_scale,
644            font_size,
645        },
646        exec_state,
647        &args,
648    )
649    .await?;
650    Ok(annotations.into())
651}
652
653pub async fn concentricity(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
654    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
655        "faces",
656        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
657        exec_state,
658    )?;
659    let edges = parse_gdt_edges_arg(exec_state, &args).await?;
660    let datums: Vec<String> = args.get_kw_arg(
661        "datums",
662        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
663        exec_state,
664    )?;
665    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
666    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
667    let frame_position: Option<[TyF64; 2]> =
668        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
669    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
670    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
671    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
672
673    let annotations = create_feature_control_annotations(
674        GdtFeatureControlKind::Concentricity,
675        GdtFeatureControlParams {
676            faces: faces.unwrap_or_default(),
677            edges,
678            datums: Some(datums),
679            tolerance,
680            precision,
681            frame_position,
682            frame_plane,
683            leader_scale,
684            font_size,
685        },
686        exec_state,
687        &args,
688    )
689    .await?;
690    Ok(annotations.into())
691}
692
693pub async fn symmetry(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
694    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
695        "faces",
696        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
697        exec_state,
698    )?;
699    let edges = parse_gdt_edges_arg(exec_state, &args).await?;
700    let datums: Vec<String> = args.get_kw_arg(
701        "datums",
702        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
703        exec_state,
704    )?;
705    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
706    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
707    let frame_position: Option<[TyF64; 2]> =
708        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
709    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
710    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
711    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
712
713    let annotations = create_feature_control_annotations(
714        GdtFeatureControlKind::Symmetry,
715        GdtFeatureControlParams {
716            faces: faces.unwrap_or_default(),
717            edges,
718            datums: Some(datums),
719            tolerance,
720            precision,
721            frame_position,
722            frame_plane,
723            leader_scale,
724            font_size,
725        },
726        exec_state,
727        &args,
728    )
729    .await?;
730    Ok(annotations.into())
731}
732
733pub async fn runout(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
734    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
735        "faces",
736        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
737        exec_state,
738    )?;
739    let edges = parse_gdt_edges_arg(exec_state, &args).await?;
740    let datums: Vec<String> = args.get_kw_arg(
741        "datums",
742        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
743        exec_state,
744    )?;
745    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
746    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
747    let frame_position: Option<[TyF64; 2]> =
748        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
749    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
750    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
751    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
752
753    let annotations = create_feature_control_annotations(
754        GdtFeatureControlKind::Runout,
755        GdtFeatureControlParams {
756            faces: faces.unwrap_or_default(),
757            edges,
758            datums: Some(datums),
759            tolerance,
760            precision,
761            frame_position,
762            frame_plane,
763            leader_scale,
764            font_size,
765        },
766        exec_state,
767        &args,
768    )
769    .await?;
770    Ok(annotations.into())
771}
772
773pub async fn profile_line(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
774    let edges = parse_gdt_edges_arg(exec_state, &args).await?;
775    let params = profile_common_params(&args, exec_state)?;
776
777    let annotations = inner_profile_line(edges, params, exec_state, &args).await?;
778    Ok(annotations.into())
779}
780
781pub async fn profile_surface(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
782    let faces: Vec<TagIdentifier> = args.get_kw_arg(
783        "faces",
784        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
785        exec_state,
786    )?;
787    let params = profile_common_params(&args, exec_state)?;
788
789    let annotations = inner_profile_surface(faces, params, exec_state, &args).await?;
790    Ok(annotations.into())
791}
792
793/// Backwards-compatible implementation for the historical `gdt::profile` KCL function.
794///
795/// New KCL should call `gdt::profileLine` for edges or `gdt::profileSurface` for faces.
796/// Keep the dispatch explicit so invalid combinations produce semantic KCL errors
797/// instead of silently choosing one entity type.
798pub async fn profile(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
799    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
800        "faces",
801        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
802        exec_state,
803    )?;
804    let edges = parse_gdt_edges_arg(exec_state, &args).await?;
805
806    let annotations = match (!edges.is_empty(), faces) {
807        (true, None) => {
808            let params = profile_common_params(&args, exec_state)?;
809            inner_profile_line(edges, params, exec_state, &args).await?
810        }
811        (false, Some(faces)) => {
812            let params = profile_common_params(&args, exec_state)?;
813            inner_profile_surface(faces, params, exec_state, &args).await?
814        }
815        (true, Some(_)) => {
816            return Err(KclError::new_semantic(KclErrorDetails::new(
817                "Profile cannot combine `edges` and `faces`. Use `profileLine` for edges or `profileSurface` for faces."
818                    .to_owned(),
819                vec![args.source_range],
820            )));
821        }
822        (false, None) => {
823            return Err(KclError::new_semantic(KclErrorDetails::new(
824                "Profile requires either `edges` for `profileLine` or `faces` for `profileSurface`.".to_owned(),
825                vec![args.source_range],
826            )));
827        }
828    };
829
830    Ok(annotations.into())
831}
832
833fn profile_common_params(args: &Args, exec_state: &mut ExecState) -> Result<GdtProfileCommonParams, KclError> {
834    let datums: Option<Vec<String>> = args.get_kw_arg_opt(
835        "datums",
836        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
837        exec_state,
838    )?;
839    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
840    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
841    let frame_position: Option<[TyF64; 2]> =
842        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
843    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
844    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
845    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
846
847    Ok(GdtProfileCommonParams {
848        datums,
849        tolerance,
850        precision,
851        frame_position,
852        frame_plane,
853        leader_scale,
854        font_size,
855    })
856}
857
858async fn inner_profile_line(
859    edges: Vec<GdtEdgeReference>,
860    params: GdtProfileCommonParams,
861    exec_state: &mut ExecState,
862    args: &Args,
863) -> Result<Vec<GdtAnnotation>, KclError> {
864    create_feature_control_annotations(
865        GdtFeatureControlKind::ProfileLine,
866        GdtFeatureControlParams {
867            faces: Vec::new(),
868            edges,
869            datums: params.datums,
870            tolerance: params.tolerance,
871            precision: params.precision,
872            frame_position: params.frame_position,
873            frame_plane: params.frame_plane,
874            leader_scale: params.leader_scale,
875            font_size: params.font_size,
876        },
877        exec_state,
878        args,
879    )
880    .await
881}
882
883async fn inner_profile_surface(
884    faces: Vec<TagIdentifier>,
885    params: GdtProfileCommonParams,
886    exec_state: &mut ExecState,
887    args: &Args,
888) -> Result<Vec<GdtAnnotation>, KclError> {
889    create_feature_control_annotations(
890        GdtFeatureControlKind::ProfileSurface,
891        GdtFeatureControlParams {
892            faces,
893            edges: Vec::new(),
894            datums: params.datums,
895            tolerance: params.tolerance,
896            precision: params.precision,
897            frame_position: params.frame_position,
898            frame_plane: params.frame_plane,
899            leader_scale: params.leader_scale,
900            font_size: params.font_size,
901        },
902        exec_state,
903        args,
904    )
905    .await
906}
907
908pub async fn position(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
909    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
910        "faces",
911        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
912        exec_state,
913    )?;
914    let edges = parse_gdt_edges_arg(exec_state, &args).await?;
915    let datums: Option<Vec<String>> = args.get_kw_arg_opt(
916        "datums",
917        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
918        exec_state,
919    )?;
920    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
921    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
922    let frame_position: Option<[TyF64; 2]> =
923        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
924    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
925    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
926    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
927
928    let annotations = create_feature_control_annotations(
929        GdtFeatureControlKind::Position,
930        GdtFeatureControlParams {
931            faces: faces.unwrap_or_default(),
932            edges,
933            datums,
934            tolerance,
935            precision,
936            frame_position,
937            frame_plane,
938            leader_scale,
939            font_size,
940        },
941        exec_state,
942        &args,
943    )
944    .await?;
945    Ok(annotations.into())
946}
947
948pub async fn distance(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
949    let from = parse_distance_entity_arg("from", exec_state, &args).await?;
950    let to = parse_distance_entity_arg("to", exec_state, &args).await?;
951    let edges = parse_gdt_edges_arg(exec_state, &args).await?;
952    let tolerance = args.get_kw_arg_opt("tolerance", &RuntimeType::length(), exec_state)?;
953    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
954    let frame_position: Option<[TyF64; 2]> =
955        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
956    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
957    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
958    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
959
960    let annotations = inner_distance(
961        from,
962        to,
963        edges,
964        tolerance,
965        precision,
966        frame_position,
967        frame_plane,
968        leader_scale,
969        font_size,
970        exec_state,
971        &args,
972    )
973    .await?;
974    Ok(annotations.into())
975}
976
977#[allow(clippy::too_many_arguments)]
978async fn inner_distance(
979    from: Option<DistanceEntity>,
980    to: Option<DistanceEntity>,
981    edges: Vec<GdtEdgeReference>,
982    tolerance: Option<TyF64>,
983    precision: Option<TyF64>,
984    frame_position: Option<[TyF64; 2]>,
985    frame_plane: Option<Plane>,
986    leader_scale: Option<TyF64>,
987    font_size: Option<TyF64>,
988    exec_state: &mut ExecState,
989    args: &Args,
990) -> Result<Vec<GdtAnnotation>, KclError> {
991    let precision = resolve_precision(precision, args)?;
992    let mut frame_plane = if let Some(plane) = frame_plane {
993        plane
994    } else {
995        xy_plane(exec_state, args).await?
996    };
997    ensure_sketch_plane_in_engine(
998        &mut frame_plane,
999        exec_state,
1000        &args.ctx,
1001        args.source_range,
1002        args.node_path.clone(),
1003    )
1004    .await?;
1005
1006    if from.is_some() || to.is_some() {
1007        if !edges.is_empty() {
1008            return Err(KclError::new_semantic(KclErrorDetails::new(
1009                "Distance cannot combine `from`/`to` with `edges`.".to_owned(),
1010                vec![args.source_range],
1011            )));
1012        }
1013
1014        let (Some(from), Some(to)) = (from, to) else {
1015            return Err(KclError::new_semantic(KclErrorDetails::new(
1016                "Distance requires both `from` and `to` when measuring between entities.".to_owned(),
1017                vec![args.source_range],
1018            )));
1019        };
1020
1021        let from = from.to_endpoint(exec_state, args).await?;
1022        let to = to.to_endpoint(exec_state, args).await?;
1023        let mut annotations = Vec::with_capacity(1);
1024        create_basic_distance_annotation(
1025            from,
1026            to,
1027            &tolerance,
1028            precision,
1029            frame_position.as_ref(),
1030            frame_plane.id,
1031            leader_scale.as_ref(),
1032            font_size.as_ref(),
1033            exec_state,
1034            args,
1035            &mut annotations,
1036        )
1037        .await?;
1038        return Ok(annotations);
1039    }
1040
1041    if edges.is_empty() {
1042        return Err(KclError::new_semantic(KclErrorDetails::new(
1043            "Distance requires either `edges` or both `from` and `to`.".to_owned(),
1044            vec![args.source_range],
1045        )));
1046    }
1047
1048    let mut annotations = Vec::with_capacity(edges.len());
1049    for edge in &edges {
1050        let (entity_id, edge_reference) = match edge {
1051            GdtEdgeReference::Entity(edge) => (Some(edge.get_engine_id(exec_state, args)?), None),
1052            GdtEdgeReference::Specifier(edge_reference) => (None, Some(edge_reference.clone())),
1053        };
1054        create_basic_distance_annotation(
1055            DistanceEndpoint {
1056                entity_id,
1057                edge_reference: edge_reference.clone(),
1058                entity_pos: AnnotationMbdLeaderPosition::NormalizedPos {
1059                    pos: KPoint2d { x: 0.0, y: 0.0 },
1060                },
1061            },
1062            DistanceEndpoint {
1063                entity_id,
1064                edge_reference,
1065                entity_pos: AnnotationMbdLeaderPosition::NormalizedPos {
1066                    pos: KPoint2d { x: 1.0, y: 0.0 },
1067                },
1068            },
1069            &tolerance,
1070            precision,
1071            frame_position.as_ref(),
1072            frame_plane.id,
1073            leader_scale.as_ref(),
1074            font_size.as_ref(),
1075            exec_state,
1076            args,
1077            &mut annotations,
1078        )
1079        .await?;
1080    }
1081    Ok(annotations)
1082}
1083
1084#[allow(clippy::too_many_arguments)]
1085async fn create_basic_distance_annotation(
1086    from: DistanceEndpoint,
1087    to: DistanceEndpoint,
1088    tolerance: &Option<TyF64>,
1089    precision: u32,
1090    frame_position: Option<&[TyF64; 2]>,
1091    frame_plane_id: uuid::Uuid,
1092    leader_scale: Option<&TyF64>,
1093    font_size: Option<&TyF64>,
1094    exec_state: &mut ExecState,
1095    args: &Args,
1096    annotations: &mut Vec<GdtAnnotation>,
1097) -> Result<(), KclError> {
1098    let meta = vec![Metadata::from(args.source_range)];
1099    let annotation_id = exec_state.next_uuid();
1100    let display_units = exec_state.length_unit();
1101    let dimension = AnnotationBasicDimension::builder()
1102        .maybe_from_entity_id(from.entity_id)
1103        .maybe_from_edge_reference(from.edge_reference)
1104        .from_entity_leader_pos(from.entity_pos)
1105        .maybe_to_entity_id(to.entity_id)
1106        .maybe_to_edge_reference(to.edge_reference)
1107        .to_entity_leader_pos(to.entity_pos)
1108        .dimension(
1109            AnnotationMbdBasicDimension::builder()
1110                .tolerance(
1111                    tolerance
1112                        .as_ref()
1113                        .map(|tol| tol.to_length_units(display_units))
1114                        .unwrap_or_default(),
1115                )
1116                .build(),
1117        )
1118        .plane_id(frame_plane_id)
1119        .offset(if let Some(offset) = frame_position {
1120            KPoint2d {
1121                x: offset[0].to_mm(),
1122                y: offset[1].to_mm(),
1123            }
1124        } else {
1125            KPoint2d { x: 100.0, y: 100.0 }
1126        })
1127        .precision(precision)
1128        .font_scale(gdt_font_scale(font_size, args)?)
1129        .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
1130        .arrow_scale(gdt_dimension_leader_scale(leader_scale, args)?)
1131        .build();
1132    let annotation_name = gdt_annotation_name(exec_state, args)?;
1133    let options = AnnotationOptions::builder()
1134        .dimension(dimension)
1135        .units(display_units.to_kcmc())
1136        .maybe_name(annotation_name)
1137        .build();
1138    let annotation_cmd = ModelingCmd::from(
1139        mcmd::NewAnnotation::builder()
1140            .options(options)
1141            .clobber(false)
1142            .annotation_type(AnnotationType::T3D)
1143            .build(),
1144    );
1145    let cmd_meta = ModelingCmdMeta::from_args_id(exec_state, args, annotation_id);
1146    exec_state.batch_modeling_cmd(cmd_meta, annotation_cmd).await?;
1147    add_gdt_annotation_artifact(exec_state, args, annotation_id);
1148    annotations.push(GdtAnnotation {
1149        id: annotation_id,
1150        meta,
1151    });
1152    Ok(())
1153}
1154
1155pub async fn angularity(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
1156    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
1157        "faces",
1158        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
1159        exec_state,
1160    )?;
1161    let edges = parse_gdt_edges_arg(exec_state, &args).await?;
1162    let datums: Option<Vec<String>> = args.get_kw_arg_opt(
1163        "datums",
1164        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
1165        exec_state,
1166    )?;
1167    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
1168    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
1169    let frame_position: Option<[TyF64; 2]> =
1170        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
1171    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
1172    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
1173    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
1174
1175    let annotations = create_feature_control_annotations(
1176        GdtFeatureControlKind::Angularity,
1177        GdtFeatureControlParams {
1178            faces: faces.unwrap_or_default(),
1179            edges,
1180            datums,
1181            tolerance,
1182            precision,
1183            frame_position,
1184            frame_plane,
1185            leader_scale,
1186            font_size,
1187        },
1188        exec_state,
1189        &args,
1190    )
1191    .await?;
1192    Ok(annotations.into())
1193}
1194
1195pub async fn perpendicularity(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
1196    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
1197        "faces",
1198        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
1199        exec_state,
1200    )?;
1201    let edges = parse_gdt_edges_arg(exec_state, &args).await?;
1202    let datums: Option<Vec<String>> = args.get_kw_arg_opt(
1203        "datums",
1204        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
1205        exec_state,
1206    )?;
1207    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
1208    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
1209    let frame_position: Option<[TyF64; 2]> =
1210        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
1211    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
1212    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
1213    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
1214
1215    let annotations = create_feature_control_annotations(
1216        GdtFeatureControlKind::Perpendicularity,
1217        GdtFeatureControlParams {
1218            faces: faces.unwrap_or_default(),
1219            edges,
1220            datums,
1221            tolerance,
1222            precision,
1223            frame_position,
1224            frame_plane,
1225            leader_scale,
1226            font_size,
1227        },
1228        exec_state,
1229        &args,
1230    )
1231    .await?;
1232    Ok(annotations.into())
1233}
1234
1235pub async fn parallelism(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
1236    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
1237        "faces",
1238        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
1239        exec_state,
1240    )?;
1241    let edges = parse_gdt_edges_arg(exec_state, &args).await?;
1242    let datums: Option<Vec<String>> = args.get_kw_arg_opt(
1243        "datums",
1244        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
1245        exec_state,
1246    )?;
1247    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
1248    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
1249    let frame_position: Option<[TyF64; 2]> =
1250        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
1251    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
1252    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
1253    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
1254
1255    let annotations = create_feature_control_annotations(
1256        GdtFeatureControlKind::Parallelism,
1257        GdtFeatureControlParams {
1258            faces: faces.unwrap_or_default(),
1259            edges,
1260            datums,
1261            tolerance,
1262            precision,
1263            frame_position,
1264            frame_plane,
1265            leader_scale,
1266            font_size,
1267        },
1268        exec_state,
1269        &args,
1270    )
1271    .await?;
1272    Ok(annotations.into())
1273}
1274
1275pub async fn annotation(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
1276    let annotation: String = args.get_kw_arg("annotation", &RuntimeType::string(), exec_state)?;
1277    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
1278        "faces",
1279        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
1280        exec_state,
1281    )?;
1282    let edges = parse_gdt_edges_arg(exec_state, &args).await?;
1283    let frame_position: Option<[TyF64; 2]> =
1284        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
1285    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
1286    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
1287    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
1288
1289    let annotations = inner_annotation(
1290        annotation,
1291        faces.unwrap_or_default(),
1292        edges,
1293        frame_position,
1294        frame_plane,
1295        leader_scale,
1296        font_size,
1297        exec_state,
1298        &args,
1299    )
1300    .await?;
1301    Ok(annotations.into())
1302}
1303
1304#[allow(clippy::too_many_arguments)]
1305async fn inner_annotation(
1306    annotation: String,
1307    faces: Vec<TagIdentifier>,
1308    edges: Vec<GdtEdgeReference>,
1309    frame_position: Option<[TyF64; 2]>,
1310    frame_plane: Option<Plane>,
1311    leader_scale: Option<TyF64>,
1312    font_size: Option<TyF64>,
1313    exec_state: &mut ExecState,
1314    args: &Args,
1315) -> Result<Vec<GdtAnnotation>, KclError> {
1316    if annotation.is_empty() {
1317        return Err(KclError::new_semantic(KclErrorDetails::new(
1318            "Annotation text must not be empty.".to_owned(),
1319            vec![args.source_range],
1320        )));
1321    }
1322    if faces.is_empty() && edges.is_empty() {
1323        return Err(KclError::new_semantic(KclErrorDetails::new(
1324            "Annotation requires at least one face or edge.".to_owned(),
1325            vec![args.source_range],
1326        )));
1327    }
1328
1329    let mut frame_plane = if let Some(plane) = frame_plane {
1330        plane
1331    } else {
1332        xy_plane(exec_state, args).await?
1333    };
1334    ensure_sketch_plane_in_engine(
1335        &mut frame_plane,
1336        exec_state,
1337        &args.ctx,
1338        args.source_range,
1339        args.node_path.clone(),
1340    )
1341    .await?;
1342
1343    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
1344    for face in &faces {
1345        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
1346        create_annotation(
1347            Some(face_id),
1348            None,
1349            &annotation,
1350            frame_position.as_ref(),
1351            frame_plane.id,
1352            leader_scale.as_ref(),
1353            font_size.as_ref(),
1354            exec_state,
1355            args,
1356            &mut annotations,
1357        )
1358        .await?;
1359    }
1360    for edge in &edges {
1361        match edge {
1362            GdtEdgeReference::Entity(edge) => {
1363                create_annotation(
1364                    Some(edge.get_engine_id(exec_state, args)?),
1365                    None,
1366                    &annotation,
1367                    frame_position.as_ref(),
1368                    frame_plane.id,
1369                    leader_scale.as_ref(),
1370                    font_size.as_ref(),
1371                    exec_state,
1372                    args,
1373                    &mut annotations,
1374                )
1375                .await?;
1376            }
1377            GdtEdgeReference::Specifier(edge_reference) => {
1378                create_annotation(
1379                    None,
1380                    Some(edge_reference.clone()),
1381                    &annotation,
1382                    frame_position.as_ref(),
1383                    frame_plane.id,
1384                    leader_scale.as_ref(),
1385                    font_size.as_ref(),
1386                    exec_state,
1387                    args,
1388                    &mut annotations,
1389                )
1390                .await?;
1391            }
1392        }
1393    }
1394
1395    Ok(annotations)
1396}
1397
1398fn resolve_precision(precision: Option<TyF64>, args: &Args) -> Result<u32, KclError> {
1399    if let Some(precision) = precision {
1400        let rounded = precision.n.round();
1401        if !(0.0..=9.0).contains(&rounded) {
1402            return Err(KclError::new_semantic(KclErrorDetails::new(
1403                "Precision must be between 0 and 9".to_owned(),
1404                vec![args.source_range],
1405            )));
1406        }
1407        Ok(rounded as u32)
1408    } else {
1409        Ok(3)
1410    }
1411}
1412
1413async fn resolve_gdt_frame_plane(
1414    frame_plane: Option<Plane>,
1415    exec_state: &mut ExecState,
1416    args: &Args,
1417) -> Result<Plane, KclError> {
1418    let mut frame_plane = if let Some(plane) = frame_plane {
1419        plane
1420    } else {
1421        // No plane given. Use one of the standard planes.
1422        xy_plane(exec_state, args).await?
1423    };
1424    ensure_sketch_plane_in_engine(
1425        &mut frame_plane,
1426        exec_state,
1427        &args.ctx,
1428        args.source_range,
1429        args.node_path.clone(),
1430    )
1431    .await?;
1432    Ok(frame_plane)
1433}
1434
1435async fn create_feature_control_annotations(
1436    kind: GdtFeatureControlKind,
1437    params: GdtFeatureControlParams,
1438    exec_state: &mut ExecState,
1439    args: &Args,
1440) -> Result<Vec<GdtAnnotation>, KclError> {
1441    let GdtFeatureControlParams {
1442        faces,
1443        edges,
1444        datums,
1445        tolerance,
1446        precision,
1447        frame_position,
1448        frame_plane,
1449        leader_scale,
1450        font_size,
1451    } = params;
1452
1453    if faces.is_empty() && edges.is_empty() {
1454        return Err(KclError::new_semantic(KclErrorDetails::new(
1455            format!("{} requires at least one face or edge.", kind.label()),
1456            vec![args.source_range],
1457        )));
1458    }
1459
1460    let precision = resolve_precision(precision, args)?;
1461    let datums = resolve_datums(datums, args, kind.label())?;
1462    if kind.requires_datums() && datums.is_empty() {
1463        return Err(KclError::new_semantic(KclErrorDetails::new(
1464            format!("{} requires at least one datum.", kind.label()),
1465            vec![args.source_range],
1466        )));
1467    }
1468    let frame_plane = resolve_gdt_frame_plane(frame_plane, exec_state, args).await?;
1469    let symbol = kind.symbol();
1470    let diameter_symbol = kind.diameter_symbol();
1471
1472    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
1473    for face in &faces {
1474        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
1475        create_feature_control_annotation(
1476            Some(face_id),
1477            None,
1478            symbol,
1479            diameter_symbol,
1480            &tolerance,
1481            &datums,
1482            precision,
1483            frame_position.as_ref(),
1484            frame_plane.id,
1485            leader_scale.as_ref(),
1486            font_size.as_ref(),
1487            exec_state,
1488            args,
1489            &mut annotations,
1490        )
1491        .await?;
1492    }
1493    for edge in &edges {
1494        match edge {
1495            GdtEdgeReference::Entity(edge) => {
1496                create_feature_control_annotation(
1497                    Some(edge.get_engine_id(exec_state, args)?),
1498                    None,
1499                    symbol,
1500                    diameter_symbol,
1501                    &tolerance,
1502                    &datums,
1503                    precision,
1504                    frame_position.as_ref(),
1505                    frame_plane.id,
1506                    leader_scale.as_ref(),
1507                    font_size.as_ref(),
1508                    exec_state,
1509                    args,
1510                    &mut annotations,
1511                )
1512                .await?;
1513            }
1514            GdtEdgeReference::Specifier(edge_reference) => {
1515                create_feature_control_annotation(
1516                    None,
1517                    Some(edge_reference.clone()),
1518                    symbol,
1519                    diameter_symbol,
1520                    &tolerance,
1521                    &datums,
1522                    precision,
1523                    frame_position.as_ref(),
1524                    frame_plane.id,
1525                    leader_scale.as_ref(),
1526                    font_size.as_ref(),
1527                    exec_state,
1528                    args,
1529                    &mut annotations,
1530                )
1531                .await?;
1532            }
1533        }
1534    }
1535
1536    Ok(annotations)
1537}
1538
1539#[allow(clippy::too_many_arguments)]
1540async fn create_feature_control_annotation(
1541    entity_id: Option<uuid::Uuid>,
1542    edge_reference: Option<kcmc::shared::EdgeSpecifier>,
1543    symbol: MbdSymbol,
1544    diameter_symbol: Option<MbdSymbol>,
1545    tolerance: &TyF64,
1546    datums: &[char],
1547    precision: u32,
1548    frame_position: Option<&[TyF64; 2]>,
1549    frame_plane_id: uuid::Uuid,
1550    leader_scale: Option<&TyF64>,
1551    font_size: Option<&TyF64>,
1552    exec_state: &mut ExecState,
1553    args: &Args,
1554    annotations: &mut Vec<GdtAnnotation>,
1555) -> Result<(), KclError> {
1556    let meta = vec![Metadata::from(args.source_range)];
1557    let annotation_id = exec_state.next_uuid();
1558    let display_units = exec_state.length_unit();
1559    let control_frame = gdt_control_frame(
1560        symbol,
1561        diameter_symbol,
1562        tolerance.to_length_units(display_units),
1563        datums,
1564    );
1565    let feature_control = AnnotationFeatureControl::builder()
1566        .maybe_entity_id(entity_id)
1567        .maybe_edge_reference(edge_reference)
1568        .entity_pos(KPoint2d { x: 0.5, y: 0.5 })
1569        .leader_type(AnnotationLineEnd::Dot)
1570        .control_frame(control_frame)
1571        .plane_id(frame_plane_id)
1572        .offset(if let Some(offset) = frame_position {
1573            KPoint2d {
1574                x: offset[0].to_mm(),
1575                y: offset[1].to_mm(),
1576            }
1577        } else {
1578            KPoint2d { x: 100.0, y: 100.0 }
1579        })
1580        .precision(precision)
1581        .font_scale(gdt_font_scale(font_size, args)?)
1582        .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
1583        .leader_scale(gdt_dot_leader_scale(leader_scale, font_size, args)?)
1584        .build();
1585    let annotation_name = gdt_annotation_name(exec_state, args)?;
1586    let options = AnnotationOptions::builder()
1587        .feature_control(feature_control)
1588        .maybe_name(annotation_name)
1589        .build();
1590    exec_state
1591        .batch_modeling_cmd(
1592            ModelingCmdMeta::from_args_id(exec_state, args, annotation_id),
1593            ModelingCmd::from(
1594                mcmd::NewAnnotation::builder()
1595                    .options(options)
1596                    .clobber(false)
1597                    .annotation_type(AnnotationType::T3D)
1598                    .build(),
1599            ),
1600        )
1601        .await?;
1602    add_gdt_annotation_artifact(exec_state, args, annotation_id);
1603    annotations.push(GdtAnnotation {
1604        id: annotation_id,
1605        meta,
1606    });
1607    Ok(())
1608}
1609
1610fn gdt_control_frame(
1611    symbol: MbdSymbol,
1612    diameter_symbol: Option<MbdSymbol>,
1613    tolerance: f64,
1614    datums: &[char],
1615) -> AnnotationMbdControlFrame {
1616    match datums {
1617        [] => AnnotationMbdControlFrame::builder()
1618            .symbol(symbol)
1619            .maybe_diameter_symbol(diameter_symbol)
1620            .tolerance(tolerance)
1621            .build(),
1622        [primary] => AnnotationMbdControlFrame::builder()
1623            .symbol(symbol)
1624            .maybe_diameter_symbol(diameter_symbol)
1625            .tolerance(tolerance)
1626            .primary_datum(*primary)
1627            .build(),
1628        [primary, secondary] => AnnotationMbdControlFrame::builder()
1629            .symbol(symbol)
1630            .maybe_diameter_symbol(diameter_symbol)
1631            .tolerance(tolerance)
1632            .primary_datum(*primary)
1633            .secondary_datum(*secondary)
1634            .build(),
1635        [primary, secondary, tertiary] => AnnotationMbdControlFrame::builder()
1636            .symbol(symbol)
1637            .maybe_diameter_symbol(diameter_symbol)
1638            .tolerance(tolerance)
1639            .primary_datum(*primary)
1640            .secondary_datum(*secondary)
1641            .tertiary_datum(*tertiary)
1642            .build(),
1643        _ => unreachable!("resolve_datums rejects more than three datums"),
1644    }
1645}
1646
1647#[allow(clippy::too_many_arguments)]
1648async fn create_annotation(
1649    entity_id: Option<uuid::Uuid>,
1650    edge_reference: Option<kcmc::shared::EdgeSpecifier>,
1651    annotation: &str,
1652    frame_position: Option<&[TyF64; 2]>,
1653    frame_plane_id: uuid::Uuid,
1654    leader_scale: Option<&TyF64>,
1655    font_size: Option<&TyF64>,
1656    exec_state: &mut ExecState,
1657    args: &Args,
1658    annotations: &mut Vec<GdtAnnotation>,
1659) -> Result<(), KclError> {
1660    let meta = vec![Metadata::from(args.source_range)];
1661    let annotation_id = exec_state.next_uuid();
1662    let feature_control = AnnotationFeatureControl::builder()
1663        .maybe_entity_id(entity_id)
1664        .maybe_edge_reference(edge_reference)
1665        .entity_pos(KPoint2d { x: 0.5, y: 0.5 })
1666        .leader_type(AnnotationLineEnd::Dot)
1667        .prefix(annotation.to_owned())
1668        .plane_id(frame_plane_id)
1669        .offset(if let Some(offset) = frame_position {
1670            KPoint2d {
1671                x: offset[0].to_mm(),
1672                y: offset[1].to_mm(),
1673            }
1674        } else {
1675            KPoint2d { x: 100.0, y: 100.0 }
1676        })
1677        .precision(0)
1678        .font_scale(gdt_font_scale(font_size, args)?)
1679        .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
1680        .leader_scale(gdt_dot_leader_scale(leader_scale, font_size, args)?)
1681        .build();
1682    let annotation_name = gdt_annotation_name(exec_state, args)?;
1683    let options = AnnotationOptions::builder()
1684        .feature_control(feature_control)
1685        .maybe_name(annotation_name)
1686        .build();
1687    exec_state
1688        .batch_modeling_cmd(
1689            ModelingCmdMeta::from_args_id(exec_state, args, annotation_id),
1690            ModelingCmd::from(
1691                mcmd::NewAnnotation::builder()
1692                    .options(options)
1693                    .clobber(false)
1694                    .annotation_type(AnnotationType::T3D)
1695                    .build(),
1696            ),
1697        )
1698        .await?;
1699    add_gdt_annotation_artifact(exec_state, args, annotation_id);
1700    annotations.push(GdtAnnotation {
1701        id: annotation_id,
1702        meta,
1703    });
1704    Ok(())
1705}
1706
1707fn resolve_datums(datums: Option<Vec<String>>, args: &Args, annotation_name: &str) -> Result<Vec<char>, KclError> {
1708    let datums = datums.unwrap_or_default();
1709    if datums.len() > 3 {
1710        return Err(KclError::new_semantic(KclErrorDetails::new(
1711            format!("{annotation_name} datums must include at most three names."),
1712            vec![args.source_range],
1713        )));
1714    }
1715
1716    let mut resolved = Vec::with_capacity(datums.len());
1717    for datum in &datums {
1718        let mut chars = datum.chars();
1719        let Some(name) = chars.next() else {
1720            return Err(KclError::new_semantic(KclErrorDetails::new(
1721                format!("{annotation_name} datum names must be a single character."),
1722                vec![args.source_range],
1723            )));
1724        };
1725        if chars.next().is_some() {
1726            return Err(KclError::new_semantic(KclErrorDetails::new(
1727                format!("{annotation_name} datum names must be a single character."),
1728                vec![args.source_range],
1729            )));
1730        }
1731        resolved.push(name);
1732    }
1733
1734    Ok(resolved)
1735}
1736
1737/// Get the XY plane by evaluating the `XY` expression so that it's the same as
1738/// if the user specified `XY`.
1739async fn xy_plane(exec_state: &mut ExecState, args: &Args) -> Result<Plane, KclError> {
1740    let plane_ast = plane_ast("XY", args.source_range);
1741    let metadata = Metadata::from(args.source_range);
1742    let plane_value = args.ctx.eval_expr_fresh_root(&plane_ast, exec_state, &metadata).await?;
1743    // Evaluating a constant name cannot exit().
1744    let plane_value = plane_value.into_value();
1745    Ok(plane_value
1746        .as_plane()
1747        .ok_or_else(|| {
1748            KclError::new_internal(KclErrorDetails::new(
1749                "Expected XY plane to be defined".to_owned(),
1750                vec![args.source_range],
1751            ))
1752        })?
1753        .clone())
1754}
1755
1756/// An AST node for a plane with the given name.
1757fn plane_ast(plane_name: &str, range: SourceRange) -> ast::Node<ast::Expr> {
1758    ast::Node::new(
1759        ast::Expr::Name(BoxNode::new(ast::Node::new(
1760            ast::Name {
1761                name: ast::Identifier::new(plane_name),
1762                path: Vec::new(),
1763                // TODO: We may want to set this to true once we implement it to
1764                // prevent it breaking if users redefine the identifier.
1765                abs_path: false,
1766                digest: None,
1767            },
1768            range.start(),
1769            range.end(),
1770            range.module_id(),
1771        ))),
1772        range.start(),
1773        range.end(),
1774        range.module_id(),
1775    )
1776}
1777
1778#[cfg(test)]
1779mod tests {
1780    use super::*;
1781    use crate::ExecutorContext;
1782    use crate::execution::Artifact;
1783    use crate::execution::ExecutorSettings;
1784    use crate::execution::MockConfig;
1785    use crate::execution::parse_execute;
1786
1787    const GDT_DISTANCE_KCL_TEMPLATE: &str = r#"
1788@settings(defaultLengthUnit = __UNIT__, kclVersion = 2)
1789
1790sketch001 = sketch(on = XY) {
1791  line1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
1792  line2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
1793  line3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
1794  line4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
1795  coincident([line1.end, line2.start])
1796  coincident([line2.end, line3.start])
1797  coincident([line3.end, line4.start])
1798  coincident([line4.end, line1.start])
1799  parallel([line2, line4])
1800  parallel([line3, line1])
1801  perpendicular([line1, line2])
1802  horizontal(line3)
1803}
1804
1805region001 = region(point = [5mm, 5mm], sketch = sketch001)
1806extrude001 = extrude(region001, length = 10mm)
1807gdt::distance(
1808  edges = [
1809    getCommonEdge(faces = [
1810      region001.tags.line4,
1811      region001.tags.line1
1812    ])
1813  ],
1814  tolerance = __TOLERANCE__,
1815  framePosition = __FRAME_POSITION__,
1816  fontSize = 2in,
1817)
1818"#;
1819
1820    const GDT_FLATNESS_KCL_TEMPLATE: &str = r#"
1821@settings(defaultLengthUnit = __UNIT__, kclVersion = 2)
1822
1823sketch001 = sketch(on = XY) {
1824  line1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
1825  line2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
1826  line3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
1827  line4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
1828  coincident([line1.end, line2.start])
1829  coincident([line2.end, line3.start])
1830  coincident([line3.end, line4.start])
1831  coincident([line4.end, line1.start])
1832  parallel([line2, line4])
1833  parallel([line3, line1])
1834  perpendicular([line1, line2])
1835  horizontal(line3)
1836}
1837
1838region001 = region(point = [5mm, 5mm], sketch = sketch001)
1839extrude001 = extrude(region001, length = 10mm, tagEnd = $capEnd001)
1840gdt::flatness(
1841  faces = [capEnd001],
1842  tolerance = __TOLERANCE__,
1843  framePosition = __FRAME_POSITION__,
1844  framePlane = XZ,
1845  fontSize = 2in,
1846)
1847"#;
1848
1849    fn gdt_distance_kcl(unit: &str, tolerance: &str, frame_position: &str) -> String {
1850        GDT_DISTANCE_KCL_TEMPLATE
1851            .replace("__UNIT__", unit)
1852            .replace("__TOLERANCE__", tolerance)
1853            .replace("__FRAME_POSITION__", frame_position)
1854    }
1855
1856    fn gdt_flatness_kcl(unit: &str, tolerance: &str, frame_position: &str) -> String {
1857        GDT_FLATNESS_KCL_TEMPLATE
1858            .replace("__UNIT__", unit)
1859            .replace("__TOLERANCE__", tolerance)
1860            .replace("__FRAME_POSITION__", frame_position)
1861    }
1862
1863    async fn gdt_commands(code: &str) -> Vec<ModelingCmd> {
1864        let result = parse_execute(code).await.unwrap();
1865        result
1866            .root_module_artifact_commands()
1867            .iter()
1868            .map(|artifact_command| artifact_command.command.clone())
1869            .collect()
1870    }
1871
1872    fn annotation_options(command: &ModelingCmd) -> Result<&AnnotationOptions, KclError> {
1873        let ModelingCmd::NewAnnotation(new_annotation) = command else {
1874            return Err(KclError::new_internal(KclErrorDetails::new(
1875                format!("expected new_annotation command, got {command:?}"),
1876                vec![SourceRange::default()],
1877            )));
1878        };
1879        Ok(&new_annotation.options)
1880    }
1881
1882    fn feature_control(command: &ModelingCmd) -> Result<&AnnotationFeatureControl, KclError> {
1883        let ModelingCmd::NewAnnotation(new_annotation) = command else {
1884            return Err(KclError::new_internal(KclErrorDetails::new(
1885                format!("expected new_annotation command, got {command:?}"),
1886                vec![SourceRange::default()],
1887            )));
1888        };
1889        new_annotation.options.feature_control.as_ref().ok_or_else(|| {
1890            KclError::new_internal(KclErrorDetails::new(
1891                "expected new_annotation command to have a feature_control".to_owned(),
1892                vec![SourceRange::default()],
1893            ))
1894        })
1895    }
1896
1897    fn find_control_frame_with_symbol(
1898        commands: &[ModelingCmd],
1899        symbol: MbdSymbol,
1900    ) -> Result<&AnnotationMbdControlFrame, KclError> {
1901        for command in commands {
1902            if let Ok(feature_control) = feature_control(command)
1903                && let Some(control_frame) = feature_control.control_frame.as_ref()
1904                && control_frame.symbol == symbol
1905            {
1906                return Ok(control_frame);
1907            }
1908        }
1909
1910        Err(KclError::new_internal(KclErrorDetails::new(
1911            format!("expected commands to contain a {symbol:?} control frame"),
1912            vec![SourceRange::default()],
1913        )))
1914    }
1915
1916    #[track_caller]
1917    fn assert_close(actual: f64, expected: f64) {
1918        assert!((actual - expected).abs() < 1e-6, "expected {expected}, got {actual}");
1919    }
1920
1921    fn new_annotation_command_index(commands: &[ModelingCmd]) -> Result<usize, KclError> {
1922        commands
1923            .iter()
1924            .position(|command| matches!(command, ModelingCmd::NewAnnotation(_)))
1925            .ok_or_else(|| {
1926                KclError::new_internal(KclErrorDetails::new(
1927                    "expected commands to contain a new_annotation command".to_owned(),
1928                    vec![SourceRange::default()],
1929                ))
1930            })
1931    }
1932
1933    #[tokio::test(flavor = "multi_thread")]
1934    async fn gdt_annotation_name_comes_from_explicit_argument() -> Result<(), KclError> {
1935        let unbound_code = gdt_flatness_kcl("mm", "0.01mm", "[10, -10]");
1936        let unbound_commands = gdt_commands(&unbound_code).await;
1937        let unbound_index = new_annotation_command_index(&unbound_commands)?;
1938        assert_eq!(annotation_options(&unbound_commands[unbound_index])?.name, None);
1939
1940        let assigned_code = unbound_code.replacen("gdt::flatness(", "topFlatness = gdt::flatness(", 1);
1941        let assigned_commands = gdt_commands(&assigned_code).await;
1942        let assigned_index = new_annotation_command_index(&assigned_commands)?;
1943        assert_eq!(annotation_options(&assigned_commands[assigned_index])?.name, None);
1944
1945        let named_code = unbound_code.replacen(
1946            "gdt::flatness(\n",
1947            "gdt::flatness(\n  annotationName = \"topFlatness\",\n",
1948            1,
1949        );
1950        let named_commands = gdt_commands(&named_code).await;
1951        let named_index = new_annotation_command_index(&named_commands)?;
1952        assert_eq!(
1953            annotation_options(&named_commands[named_index])?.name.as_deref(),
1954            Some("topFlatness")
1955        );
1956
1957        Ok(())
1958    }
1959
1960    #[test]
1961    fn gdt_font_scale_is_scene_height_divided_by_calibration_height() {
1962        let scale_at_calibrated_height = gdt_font_scale_for_height_mm(GDT_FONT_SCALE_1_HEIGHT_MM);
1963        assert!((scale_at_calibrated_height - 1.0).abs() < f32::EPSILON);
1964
1965        let double_height_scale = gdt_font_scale_for_height_mm(GDT_FONT_SCALE_1_HEIGHT_MM * 2.0);
1966        assert!((double_height_scale - 2.0).abs() < f32::EPSILON);
1967
1968        let inch_in_mm = 25.4;
1969        let inch_scale = gdt_font_scale_for_height_mm(inch_in_mm);
1970        assert!((inch_scale - (inch_in_mm / GDT_FONT_SCALE_1_HEIGHT_MM) as f32).abs() < f32::EPSILON);
1971    }
1972
1973    const GDT_FLATNESS_LEADER_KCL_TEMPLATE: &str = r#"
1974@settings(defaultLengthUnit = mm, kclVersion = 2)
1975
1976blockProfile = sketch(on = XY) {
1977  edge1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
1978  edge2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
1979  edge3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
1980  edge4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
1981  coincident([edge1.end, edge2.start])
1982  coincident([edge2.end, edge3.start])
1983  coincident([edge3.end, edge4.start])
1984  coincident([edge4.end, edge1.start])
1985  parallel([edge2, edge4])
1986  parallel([edge3, edge1])
1987  perpendicular([edge1, edge2])
1988  horizontal(edge3)
1989}
1990
1991region001 = region(point = [5mm, 5mm], sketch = blockProfile)
1992extrude001 = extrude(region001, length = 10mm, tagEnd = $top)
1993gdt::flatness(
1994  faces = [top],
1995  tolerance = 0.1mm,
1996  framePosition = [10mm, 0mm],
1997  framePlane = XZ,
1998  fontSize = __FONT_SIZE__
1999  __LEADER_SCALE__
2000)
2001"#;
2002
2003    fn gdt_flatness_leader_kcl(font_size: &str, leader_scale: Option<&str>) -> String {
2004        GDT_FLATNESS_LEADER_KCL_TEMPLATE
2005            .replace("__FONT_SIZE__", font_size)
2006            .replace(
2007                "__LEADER_SCALE__",
2008                leader_scale
2009                    .map(|scale| format!(",\n  leaderScale = {scale}"))
2010                    .unwrap_or_default()
2011                    .as_str(),
2012            )
2013    }
2014
2015    async fn gdt_flatness_feature_control(
2016        font_size: &str,
2017        leader_scale: Option<&str>,
2018    ) -> Result<AnnotationFeatureControl, KclError> {
2019        let code = gdt_flatness_leader_kcl(font_size, leader_scale);
2020        let commands = gdt_commands(&code).await;
2021        let annotation_index = new_annotation_command_index(&commands)?;
2022        Ok(feature_control(&commands[annotation_index])?.clone())
2023    }
2024
2025    #[tokio::test(flavor = "multi_thread")]
2026    async fn gdt_dot_leader_scale_is_normalized_against_font_scale() -> Result<(), KclError> {
2027        let tiny = gdt_flatness_feature_control("1mm", None).await?;
2028        let large = gdt_flatness_feature_control("100mm", None).await?;
2029
2030        assert_close(f64::from(tiny.font_scale), gdt_font_scale_for_height_mm(1.0).into());
2031        assert_close(f64::from(large.font_scale), gdt_font_scale_for_height_mm(100.0).into());
2032        assert_close(f64::from(tiny.leader_scale), 50.0);
2033        assert_close(f64::from(large.leader_scale), 0.5);
2034
2035        assert_close(
2036            f64::from(tiny.font_scale) * f64::from(tiny.leader_scale),
2037            f64::from(gdt_dot_leader_normal_size()),
2038        );
2039        assert_close(
2040            f64::from(large.font_scale) * f64::from(large.leader_scale),
2041            f64::from(gdt_dot_leader_normal_size()),
2042        );
2043        Ok(())
2044    }
2045
2046    #[tokio::test(flavor = "multi_thread")]
2047    async fn explicit_gdt_dot_leader_scale_multiplies_normal_size() -> Result<(), KclError> {
2048        let tiny = gdt_flatness_feature_control("1mm", Some("2")).await?;
2049        let large = gdt_flatness_feature_control("100mm", Some("2")).await?;
2050
2051        let expected_scaled_dot_size = f64::from(gdt_dot_leader_normal_size()) * 2.0;
2052        assert_close(
2053            f64::from(tiny.font_scale) * f64::from(tiny.leader_scale),
2054            expected_scaled_dot_size,
2055        );
2056        assert_close(
2057            f64::from(large.font_scale) * f64::from(large.leader_scale),
2058            expected_scaled_dot_size,
2059        );
2060        Ok(())
2061    }
2062
2063    #[tokio::test(flavor = "multi_thread")]
2064    async fn gdt_flatness_uses_scene_units_for_control_frame_tolerance() -> Result<(), KclError> {
2065        let cases = [
2066            ("in", "0.1in", "[10, -10]", 0.1, 254.0, -254.0),
2067            ("cm", "10mm", "[1, -1]", 1.0, 10.0, -10.0),
2068        ];
2069
2070        for (default_unit, tolerance, frame_position, expected_tolerance, expected_x, expected_y) in cases {
2071            let code = gdt_flatness_kcl(default_unit, tolerance, frame_position);
2072            let commands = gdt_commands(&code).await;
2073            let annotation_index = new_annotation_command_index(&commands)?;
2074            let feature_control = feature_control(&commands[annotation_index])?;
2075            let control_frame = feature_control.control_frame.as_ref().ok_or_else(|| {
2076                KclError::new_internal(KclErrorDetails::new(
2077                    "expected feature_control to have a control_frame".to_owned(),
2078                    vec![SourceRange::default()],
2079                ))
2080            })?;
2081
2082            assert_close(control_frame.tolerance, expected_tolerance);
2083            assert_close(feature_control.offset.x, expected_x);
2084            assert_close(feature_control.offset.y, expected_y);
2085            assert_close(
2086                f64::from(feature_control.font_scale),
2087                gdt_font_scale_for_height_mm(50.8).into(),
2088            );
2089        }
2090        Ok(())
2091    }
2092
2093    #[tokio::test(flavor = "multi_thread")]
2094    async fn gdt_distance_sets_units() -> Result<(), KclError> {
2095        let cases = [
2096            (
2097                "in",
2098                "2.54mm",
2099                "[10, -10]",
2100                kcmc::units::UnitLength::Inches,
2101                0.1,
2102                254.0,
2103                -254.0,
2104            ),
2105            (
2106                "cm",
2107                "10mm",
2108                "[1, -1]",
2109                kcmc::units::UnitLength::Centimeters,
2110                1.0,
2111                10.0,
2112                -10.0,
2113            ),
2114            (
2115                "mm",
2116                "2.54mm",
2117                "[10, -10]",
2118                kcmc::units::UnitLength::Millimeters,
2119                2.54,
2120                10.0,
2121                -10.0,
2122            ),
2123        ];
2124
2125        for (default_unit, tolerance, frame_position, scene_unit, expected_tolerance, expected_x, expected_y) in cases {
2126            let code = gdt_distance_kcl(default_unit, tolerance, frame_position);
2127            let commands = gdt_commands(&code).await;
2128            let annotation_index = new_annotation_command_index(&commands)?;
2129            let options = annotation_options(&commands[annotation_index])?;
2130
2131            assert_eq!(options.units, Some(scene_unit));
2132
2133            let dimension = options
2134                .dimension
2135                .as_ref()
2136                .expect("expected new_annotation command to have a dimension");
2137            assert!(dimension.from_entity_id.is_some());
2138            assert_eq!(dimension.from_entity_id, dimension.to_entity_id);
2139            assert!(dimension.from_edge_reference.is_none());
2140            assert!(dimension.to_edge_reference.is_none());
2141            // Edge length uses endpoints; the same centroid twice would give zero distance.
2142            assert_eq!(
2143                dimension.from_entity_leader_pos,
2144                Some(AnnotationMbdLeaderPosition::NormalizedPos {
2145                    pos: KPoint2d { x: 0.0, y: 0.0 },
2146                })
2147            );
2148            assert_eq!(
2149                dimension.to_entity_leader_pos,
2150                Some(AnnotationMbdLeaderPosition::NormalizedPos {
2151                    pos: KPoint2d { x: 1.0, y: 0.0 },
2152                })
2153            );
2154            assert_close(dimension.dimension.tolerance.unwrap(), expected_tolerance);
2155            assert_close(dimension.offset.x, expected_x);
2156            assert_close(dimension.offset.y, expected_y);
2157            assert_close(
2158                f64::from(dimension.font_scale),
2159                gdt_font_scale_for_height_mm(50.8).into(),
2160            );
2161        }
2162        Ok(())
2163    }
2164
2165    const GDT_FACE_API_EDGE_KCL_TEMPLATE: &str = r#"
2166@settings(defaultLengthUnit = mm, kclVersion = 2)
2167
2168sketch001 = sketch(on = XY) {
2169  line1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
2170  line2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
2171  line3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
2172  line4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
2173  coincident([line1.end, line2.start])
2174  coincident([line2.end, line3.start])
2175  coincident([line3.end, line4.start])
2176  coincident([line4.end, line1.start])
2177  parallel([line2, line4])
2178  parallel([line3, line1])
2179  perpendicular([line1, line2])
2180  horizontal(line3)
2181}
2182
2183region001 = region(point = [5mm, 5mm], sketch = sketch001)
2184extrude001 = extrude(region001, length = 10mm, tagStart = $capStart001)
2185__GDT_CALL__
2186"#;
2187
2188    fn gdt_face_api_edge_kcl(gdt_call: &str) -> String {
2189        GDT_FACE_API_EDGE_KCL_TEMPLATE.replace("__GDT_CALL__", gdt_call)
2190    }
2191
2192    fn assert_feature_control_uses_edge_reference(feature_control: &AnnotationFeatureControl) {
2193        assert!(feature_control.entity_id.is_none());
2194        let edge_reference = feature_control
2195            .edge_reference
2196            .as_ref()
2197            .expect("expected face API edge specifier to emit edge_reference");
2198        assert_eq!(edge_reference.side_faces.len(), 2);
2199        assert!(edge_reference.end_faces.is_empty());
2200        assert_eq!(edge_reference.index, None);
2201    }
2202
2203    #[tokio::test(flavor = "multi_thread")]
2204    async fn gdt_straightness_accepts_face_api_edge_specifier() -> Result<(), KclError> {
2205        let code = gdt_face_api_edge_kcl(
2206            r#"gdt::straightness(
2207  edges = [
2208    {
2209      sideFaces = [region001.tags.line1, capStart001]
2210    }
2211  ],
2212  tolerance = 0.1mm,
2213  framePosition = [12mm, 8mm],
2214  framePlane = XZ,
2215)"#,
2216        );
2217        let commands = gdt_commands(&code).await;
2218        let annotation_index = new_annotation_command_index(&commands)?;
2219        let feature_control = feature_control(&commands[annotation_index])?;
2220        assert_feature_control_uses_edge_reference(feature_control);
2221        assert!(feature_control.control_frame.is_some());
2222        Ok(())
2223    }
2224
2225    #[tokio::test(flavor = "multi_thread")]
2226    async fn gdt_annotation_accepts_face_api_edge_specifier() -> Result<(), KclError> {
2227        let code = gdt_face_api_edge_kcl(
2228            r#"gdt::annotation(
2229  annotation = "A",
2230  edges = [
2231    {
2232      sideFaces = [region001.tags.line1, capStart001]
2233    }
2234  ],
2235  framePosition = [12mm, 8mm],
2236  framePlane = XZ,
2237)"#,
2238        );
2239        let commands = gdt_commands(&code).await;
2240        let annotation_index = new_annotation_command_index(&commands)?;
2241        let feature_control = feature_control(&commands[annotation_index])?;
2242        assert_feature_control_uses_edge_reference(feature_control);
2243        assert_eq!(feature_control.prefix.as_deref(), Some("A"));
2244        Ok(())
2245    }
2246
2247    #[tokio::test(flavor = "multi_thread")]
2248    async fn gdt_distance_accepts_face_api_edge_specifier() -> Result<(), KclError> {
2249        let code = gdt_face_api_edge_kcl(
2250            r#"gdt::distance(
2251  edges = [
2252    {
2253      sideFaces = [region001.tags.line1, capStart001]
2254    }
2255  ],
2256  tolerance = 0.1mm,
2257  framePosition = [12mm, 8mm],
2258  framePlane = XZ,
2259)"#,
2260        );
2261        let commands = gdt_commands(&code).await;
2262        let annotation_index = new_annotation_command_index(&commands)?;
2263        let options = annotation_options(&commands[annotation_index])?;
2264        let dimension = options
2265            .dimension
2266            .as_ref()
2267            .expect("expected new_annotation command to have a dimension");
2268        assert!(dimension.from_entity_id.is_none());
2269        assert!(dimension.to_entity_id.is_none());
2270        assert_eq!(dimension.from_edge_reference, dimension.to_edge_reference);
2271        // Edge length uses endpoints; the same centroid twice would give zero distance.
2272        assert_eq!(
2273            dimension.from_entity_leader_pos,
2274            Some(AnnotationMbdLeaderPosition::NormalizedPos {
2275                pos: KPoint2d { x: 0.0, y: 0.0 },
2276            })
2277        );
2278        assert_eq!(
2279            dimension.to_entity_leader_pos,
2280            Some(AnnotationMbdLeaderPosition::NormalizedPos {
2281                pos: KPoint2d { x: 1.0, y: 0.0 },
2282            })
2283        );
2284        assert_eq!(
2285            dimension
2286                .from_edge_reference
2287                .as_ref()
2288                .expect("expected from_edge_reference")
2289                .side_faces
2290                .len(),
2291            2
2292        );
2293        assert_eq!(
2294            dimension
2295                .to_edge_reference
2296                .as_ref()
2297                .expect("expected to_edge_reference")
2298                .side_faces
2299                .len(),
2300            2
2301        );
2302        Ok(())
2303    }
2304
2305    #[tokio::test(flavor = "multi_thread")]
2306    async fn gdt_distance_from_to_accept_face_api_edge_specifiers() -> Result<(), KclError> {
2307        let code = gdt_face_api_edge_kcl(
2308            r#"gdt::distance(
2309  from = {
2310    sideFaces = [region001.tags.line1, capStart001]
2311  },
2312  to = {
2313    sideFaces = [region001.tags.line3, capStart001]
2314  },
2315  tolerance = 0.1mm,
2316  framePosition = [12mm, 8mm],
2317  framePlane = XZ,
2318)"#,
2319        );
2320        let commands = gdt_commands(&code).await;
2321        let annotation_index = new_annotation_command_index(&commands)?;
2322        let options = annotation_options(&commands[annotation_index])?;
2323        let dimension = options
2324            .dimension
2325            .as_ref()
2326            .expect("expected new_annotation command to have a dimension");
2327        assert!(dimension.from_entity_id.is_none());
2328        assert!(dimension.to_entity_id.is_none());
2329        // `from`/`to` measures between entity centers, so both positions use centroids.
2330        assert_eq!(
2331            dimension.from_entity_leader_pos,
2332            Some(AnnotationMbdLeaderPosition::Centroid {})
2333        );
2334        assert_eq!(
2335            dimension.to_entity_leader_pos,
2336            Some(AnnotationMbdLeaderPosition::Centroid {})
2337        );
2338        assert_eq!(
2339            dimension
2340                .from_edge_reference
2341                .as_ref()
2342                .expect("expected from_edge_reference")
2343                .side_faces
2344                .len(),
2345            2
2346        );
2347        assert_eq!(
2348            dimension
2349                .to_edge_reference
2350                .as_ref()
2351                .expect("expected to_edge_reference")
2352                .side_faces
2353                .len(),
2354            2
2355        );
2356        Ok(())
2357    }
2358
2359    const GDT_DATUM_KCL: &str = r#"
2360blockProfile = sketch(on = XY) {
2361  edge1 = line(start = [var 0mm, var 0mm], end = [var 8mm, var 0mm])
2362  edge2 = line(start = [var 8mm, var 0mm], end = [var 8mm, var 5mm])
2363  edge3 = line(start = [var 8mm, var 5mm], end = [var 0mm, var 5mm])
2364  edge4 = line(start = [var 0mm, var 5mm], end = [var 0mm, var 0mm])
2365  coincident([edge1.end, edge2.start])
2366  coincident([edge2.end, edge3.start])
2367  coincident([edge3.end, edge4.start])
2368  coincident([edge4.end, edge1.start])
2369  horizontal(edge1)
2370  vertical(edge2)
2371  horizontal(edge3)
2372  vertical(edge4)
2373}
2374
2375block = extrude(region(point = [4mm, 2mm], sketch = blockProfile), length = 4mm, tagEnd = $top)
2376
2377gdt::datum(face = top, name = "A", framePosition = [10mm, 0mm], framePlane = XZ)
2378"#;
2379
2380    async fn gdt_artifact_count(skip_artifact_graph: bool) -> usize {
2381        let settings = ExecutorSettings {
2382            skip_artifact_graph,
2383            ..Default::default()
2384        };
2385        let ctx = ExecutorContext::new_mock(Some(settings)).await;
2386        let program = crate::Program::parse_no_errs(GDT_DATUM_KCL).unwrap();
2387        let mock_config = MockConfig {
2388            use_prev_memory: false,
2389            ..Default::default()
2390        };
2391        let outcome = ctx.run_mock(&program, &mock_config).await.unwrap();
2392        ctx.close().await;
2393
2394        outcome
2395            .artifact_graph
2396            .values()
2397            .filter(|artifact| matches!(artifact, Artifact::GdtAnnotation(_)))
2398            .count()
2399    }
2400
2401    #[tokio::test(flavor = "multi_thread")]
2402    async fn gdt_annotations_do_not_follow_runtime_artifact_graph_setting() {
2403        assert_eq!(gdt_artifact_count(false).await, 1);
2404        assert_eq!(gdt_artifact_count(true).await, 1);
2405    }
2406
2407    const GDT_ANGULARITY_FACE_KCL: &str = r#"
2408@settings(defaultLengthUnit = mm, kclVersion = 2)
2409
2410basicAngle = 30deg
2411thickness = 3.5mm
2412flangeLength = 24mm
2413bendStartX = 5mm
2414legLength = 30mm
2415legRun = legLength * cos(basicAngle)
2416legRise = legLength * sin(basicAngle)
2417normalRun = thickness * sin(basicAngle)
2418normalRise = thickness * cos(basicAngle)
2419annotationFont = 2mm
2420
2421stampedProfile = sketch(on = XY) {
2422  datumFace = line(start = [var 0mm, var 0mm], end = [var 24mm, var 0mm])
2423  flangeEnd = line(start = [var 24mm, var 0mm], end = [var 24mm, var 3.5mm])
2424  innerFlange = line(start = [var 24mm, var 3.5mm], end = [var 5mm, var 3.5mm])
2425  controlledSurface = line(start = [var 5mm, var 3.5mm], end = [var 30.98mm, var 18.5mm])
2426  tabEnd = line(start = [var 30.98mm, var 18.5mm], end = [var 29.23mm, var 21.53mm])
2427  outerSurface = line(start = [var 29.23mm, var 21.53mm], end = [var 3.25mm, var 6.53mm])
2428  outsideBend = line(start = [var 3.25mm, var 6.53mm], end = [var 0mm, var 0mm])
2429  coincident([datumFace.end, flangeEnd.start])
2430  coincident([flangeEnd.end, innerFlange.start])
2431  coincident([innerFlange.end, controlledSurface.start])
2432  coincident([controlledSurface.end, tabEnd.start])
2433  coincident([tabEnd.end, outerSurface.start])
2434  coincident([outerSurface.end, outsideBend.start])
2435  coincident([outsideBend.end, datumFace.start])
2436  coincident([datumFace.start, ORIGIN])
2437  horizontal(datumFace)
2438  horizontal(innerFlange)
2439  vertical(flangeEnd)
2440  distance([datumFace.start, datumFace.end]) == flangeLength
2441  distance([flangeEnd.start, flangeEnd.end]) == thickness
2442  distance([innerFlange.start, innerFlange.end]) == flangeLength - bendStartX
2443  distance([controlledSurface.start, controlledSurface.end]) == legLength
2444  distance([tabEnd.start, tabEnd.end]) == thickness
2445  distance([outerSurface.start, outerSurface.end]) == legLength
2446  parallel([controlledSurface, outerSurface])
2447  perpendicular([controlledSurface, tabEnd])
2448  angle([datumFace, controlledSurface]) == basicAngle
2449}
2450
2451stampedPart = extrude(region(point = [12mm, 2mm], sketch = stampedProfile), length = 0.8mm)
2452
2453gdt::datum(face = stampedPart.sketch.tags.datumFace, name = "A", framePosition = [6mm, -4mm], framePlane = XY, fontSize = annotationFont)
2454gdt::angularity(faces = [stampedPart.sketch.tags.controlledSurface], tolerance = 0.1mm, datums = ["A"], framePosition = [-12mm, 11mm], framePlane = XZ, fontSize = annotationFont)
2455"#;
2456
2457    const GDT_ANGULARITY_EDGE_KCL: &str = r#"
2458@settings(defaultLengthUnit = mm, kclVersion = 2)
2459
2460basicAngle = 30deg
2461thickness = 3.5mm
2462flangeLength = 24mm
2463bendStartX = 5mm
2464legLength = 30mm
2465legRun = legLength * cos(basicAngle)
2466legRise = legLength * sin(basicAngle)
2467normalRun = thickness * sin(basicAngle)
2468normalRise = thickness * cos(basicAngle)
2469annotationFont = 2mm
2470
2471stampedProfile = sketch(on = XY) {
2472  datumFace = line(start = [var 0mm, var 0mm], end = [var 24mm, var 0mm])
2473  flangeEnd = line(start = [var 24mm, var 0mm], end = [var 24mm, var 3.5mm])
2474  innerFlange = line(start = [var 24mm, var 3.5mm], end = [var 5mm, var 3.5mm])
2475  controlledSurface = line(start = [var 5mm, var 3.5mm], end = [var 30.98mm, var 18.5mm])
2476  tabEnd = line(start = [var 30.98mm, var 18.5mm], end = [var 29.23mm, var 21.53mm])
2477  outerSurface = line(start = [var 29.23mm, var 21.53mm], end = [var 3.25mm, var 6.53mm])
2478  outsideBend = line(start = [var 3.25mm, var 6.53mm], end = [var 0mm, var 0mm])
2479  coincident([datumFace.end, flangeEnd.start])
2480  coincident([flangeEnd.end, innerFlange.start])
2481  coincident([innerFlange.end, controlledSurface.start])
2482  coincident([controlledSurface.end, tabEnd.start])
2483  coincident([tabEnd.end, outerSurface.start])
2484  coincident([outerSurface.end, outsideBend.start])
2485  coincident([outsideBend.end, datumFace.start])
2486  coincident([datumFace.start, ORIGIN])
2487  horizontal(datumFace)
2488  horizontal(innerFlange)
2489  vertical(flangeEnd)
2490  distance([datumFace.start, datumFace.end]) == flangeLength
2491  distance([flangeEnd.start, flangeEnd.end]) == thickness
2492  distance([innerFlange.start, innerFlange.end]) == flangeLength - bendStartX
2493  distance([controlledSurface.start, controlledSurface.end]) == legLength
2494  distance([tabEnd.start, tabEnd.end]) == thickness
2495  distance([outerSurface.start, outerSurface.end]) == legLength
2496  parallel([controlledSurface, outerSurface])
2497  perpendicular([controlledSurface, tabEnd])
2498  angle([datumFace, controlledSurface]) == basicAngle
2499}
2500
2501stampedRegion = region(point = [12mm, 2mm], sketch = stampedProfile)
2502hide(stampedProfile)
2503stampedPart = extrude(stampedRegion, length = 0.8mm)
2504
2505gdt::datum(face = stampedPart.sketch.tags.datumFace, name = "A", framePosition = [6mm, -4mm], framePlane = XY, fontSize = annotationFont)
2506gdt::angularity(edges = [stampedRegion.tags.controlledSurface], tolerance = 0.1mm, datums = ["A"], framePosition = [-12mm, 11mm], framePlane = XZ, fontSize = annotationFont)
2507"#;
2508
2509    #[tokio::test(flavor = "multi_thread")]
2510    async fn gdt_angularity_uses_angularity_symbol_with_datums() -> Result<(), KclError> {
2511        let cases = [
2512            ("angled face", GDT_ANGULARITY_FACE_KCL, 0.1),
2513            ("angled edge", GDT_ANGULARITY_EDGE_KCL, 0.1),
2514        ];
2515
2516        for (label, code, expected_tolerance) in cases {
2517            let commands = gdt_commands(code).await;
2518            let control_frame = find_control_frame_with_symbol(&commands, MbdSymbol::Angularity)?;
2519
2520            assert_close(control_frame.tolerance, expected_tolerance);
2521            assert_eq!(control_frame.primary_datum, Some('A'), "case: {label}");
2522            assert!(control_frame.secondary_datum.is_none(), "case: {label}");
2523            assert!(control_frame.tertiary_datum.is_none(), "case: {label}");
2524        }
2525        Ok(())
2526    }
2527
2528    const GDT_PROFILE_LINE_KCL: &str = r#"
2529@settings(defaultLengthUnit = mm, kclVersion = 2)
2530
2531blockProfile = sketch(on = XY) {
2532  edge1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
2533  edge2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 6mm])
2534  edge3 = line(start = [var 10mm, var 6mm], end = [var 0mm, var 6mm])
2535  edge4 = line(start = [var 0mm, var 6mm], end = [var 0mm, var 0mm])
2536  coincident([edge1.end, edge2.start])
2537  coincident([edge2.end, edge3.start])
2538  coincident([edge3.end, edge4.start])
2539  coincident([edge4.end, edge1.start])
2540  horizontal(edge1)
2541  vertical(edge2)
2542  horizontal(edge3)
2543  vertical(edge4)
2544}
2545
2546block = extrude(region(point = [5mm, 3mm], sketch = blockProfile), length = 4mm, tagEnd = $top)
2547profileEdge = getCommonEdge(faces = [block.sketch.tags.edge1, top])
2548gdt::profileLine(edges = [profileEdge], tolerance = 0.05mm, framePosition = [12mm, 8mm], framePlane = XZ)
2549"#;
2550
2551    const GDT_PROFILE_GENERIC_LINE_KCL: &str = r#"
2552@settings(defaultLengthUnit = mm, kclVersion = 2)
2553
2554blockProfile = sketch(on = XY) {
2555  edge1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
2556  edge2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 6mm])
2557  edge3 = line(start = [var 10mm, var 6mm], end = [var 0mm, var 6mm])
2558  edge4 = line(start = [var 0mm, var 6mm], end = [var 0mm, var 0mm])
2559  coincident([edge1.end, edge2.start])
2560  coincident([edge2.end, edge3.start])
2561  coincident([edge3.end, edge4.start])
2562  coincident([edge4.end, edge1.start])
2563  horizontal(edge1)
2564  vertical(edge2)
2565  horizontal(edge3)
2566  vertical(edge4)
2567}
2568
2569block = extrude(region(point = [5mm, 3mm], sketch = blockProfile), length = 4mm, tagEnd = $top)
2570profileEdge = getCommonEdge(faces = [block.sketch.tags.edge1, top])
2571gdt::profile(edges = [profileEdge], tolerance = 0.05mm, framePosition = [12mm, 8mm], framePlane = XZ)
2572"#;
2573
2574    const GDT_PROFILE_SURFACE_KCL: &str = r#"
2575@settings(defaultLengthUnit = mm, kclVersion = 2)
2576
2577cylinderSketch = sketch(on = XY) {
2578  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2579}
2580
2581cylinder = extrude(region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch), length = 10mm, tagEnd = $top)
2582gdt::profileSurface(faces = [top], tolerance = 0.05mm, framePosition = [12mm, 8mm], framePlane = XZ)
2583"#;
2584
2585    const GDT_PROFILE_GENERIC_SURFACE_KCL: &str = r#"
2586@settings(defaultLengthUnit = mm, kclVersion = 2)
2587
2588cylinderSketch = sketch(on = XY) {
2589  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2590}
2591
2592cylinder = extrude(region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch), length = 10mm, tagEnd = $top)
2593gdt::profile(faces = [top], tolerance = 0.05mm, framePosition = [12mm, 8mm], framePlane = XZ)
2594"#;
2595
2596    const GDT_PROFILE_BOTH_KCL: &str = r#"
2597@settings(defaultLengthUnit = mm, kclVersion = 2)
2598
2599blockProfile = sketch(on = XY) {
2600  edge1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
2601  edge2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 6mm])
2602  edge3 = line(start = [var 10mm, var 6mm], end = [var 0mm, var 6mm])
2603  edge4 = line(start = [var 0mm, var 6mm], end = [var 0mm, var 0mm])
2604  coincident([edge1.end, edge2.start])
2605  coincident([edge2.end, edge3.start])
2606  coincident([edge3.end, edge4.start])
2607  coincident([edge4.end, edge1.start])
2608  horizontal(edge1)
2609  vertical(edge2)
2610  horizontal(edge3)
2611  vertical(edge4)
2612}
2613
2614block = extrude(region(point = [5mm, 3mm], sketch = blockProfile), length = 4mm, tagEnd = $top)
2615profileEdge = getCommonEdge(faces = [block.sketch.tags.edge1, top])
2616gdt::profile(edges = [profileEdge], faces = [top], tolerance = 0.05mm)
2617"#;
2618
2619    const GDT_PROFILE_MISSING_ENTITIES_KCL: &str = r#"
2620@settings(defaultLengthUnit = mm, kclVersion = 2)
2621
2622gdt::profile(tolerance = 0.05mm)
2623"#;
2624
2625    #[tokio::test(flavor = "multi_thread")]
2626    async fn gdt_profile_line_uses_profile_of_line_symbol() -> Result<(), KclError> {
2627        let cases = [
2628            ("specific profileLine", GDT_PROFILE_LINE_KCL),
2629            ("generic profile with edges", GDT_PROFILE_GENERIC_LINE_KCL),
2630        ];
2631
2632        for (label, code) in cases {
2633            let commands = gdt_commands(code).await;
2634            let control_frame = find_control_frame_with_symbol(&commands, MbdSymbol::ProfileOfLine)?;
2635
2636            assert_close(control_frame.tolerance, 0.05);
2637            assert!(control_frame.primary_datum.is_none(), "case: {label}");
2638            assert!(control_frame.secondary_datum.is_none(), "case: {label}");
2639            assert!(control_frame.tertiary_datum.is_none(), "case: {label}");
2640        }
2641        Ok(())
2642    }
2643
2644    #[tokio::test(flavor = "multi_thread")]
2645    async fn gdt_profile_surface_uses_surface_profile_symbol() -> Result<(), KclError> {
2646        let cases = [
2647            ("specific profileSurface", GDT_PROFILE_SURFACE_KCL),
2648            ("generic profile with faces", GDT_PROFILE_GENERIC_SURFACE_KCL),
2649        ];
2650
2651        for (label, code) in cases {
2652            let commands = gdt_commands(code).await;
2653            let control_frame = find_control_frame_with_symbol(&commands, MbdSymbol::SurfaceProfile)?;
2654
2655            assert_close(control_frame.tolerance, 0.05);
2656            assert!(control_frame.primary_datum.is_none(), "case: {label}");
2657            assert!(control_frame.secondary_datum.is_none(), "case: {label}");
2658            assert!(control_frame.tertiary_datum.is_none(), "case: {label}");
2659        }
2660        Ok(())
2661    }
2662
2663    #[tokio::test(flavor = "multi_thread")]
2664    async fn gdt_profile_requires_edges_or_faces() {
2665        assert_eq!(
2666            parse_execute(GDT_PROFILE_MISSING_ENTITIES_KCL)
2667                .await
2668                .unwrap_err()
2669                .message(),
2670            "Profile requires either `edges` for `profileLine` or `faces` for `profileSurface`.",
2671        );
2672    }
2673
2674    #[tokio::test(flavor = "multi_thread")]
2675    async fn gdt_profile_rejects_combined_edges_and_faces() {
2676        assert_eq!(
2677            parse_execute(GDT_PROFILE_BOTH_KCL).await.unwrap_err().message(),
2678            "Profile cannot combine `edges` and `faces`. Use `profileLine` for edges or `profileSurface` for faces.",
2679        );
2680    }
2681
2682    // Mirrors the gdt::circularity doc examples: annotate a cylinder's circular
2683    // edge and its curved wall. Runs in mock mode, so it validates parsing, name
2684    // resolution, and that the control frame uses the Roundness (circularity)
2685    // symbol without datums. The doc examples additionally render against the
2686    // engine in kcl_test_examples.
2687    const GDT_CIRCULARITY_EDGE_KCL: &str = r#"
2688@settings(defaultLengthUnit = mm, kclVersion = 2)
2689
2690cylinderSketch = sketch(on = XY) {
2691  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2692}
2693
2694cylinderRegion = region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch)
2695hide(cylinderSketch)
2696cylinder = extrude(cylinderRegion, length = 10mm)
2697gdt::circularity(edges = [cylinderRegion.tags.perimeter], tolerance = 0.05mm)
2698"#;
2699
2700    const GDT_CIRCULARITY_WALL_KCL: &str = r#"
2701@settings(defaultLengthUnit = mm, kclVersion = 2)
2702
2703cylinderSketch = sketch(on = XY) {
2704  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2705}
2706
2707cylinder = extrude(region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch), length = 10mm)
2708gdt::circularity(faces = [cylinder.sketch.tags.perimeter], tolerance = 0.02mm, framePosition = [12mm, 8mm], framePlane = XZ)
2709"#;
2710
2711    const GDT_CIRCULARITY_COMMON_EDGE_KCL: &str = r#"
2712@settings(defaultLengthUnit = mm, kclVersion = 2)
2713
2714cylinderSketch = sketch(on = XY) {
2715  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2716}
2717
2718cylinder = extrude(region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch), length = 10mm, tagEnd = $top)
2719topEdge = getCommonEdge(faces = [cylinder.sketch.tags.perimeter, top])
2720gdt::circularity(edges = [topEdge], tolerance = 0.05mm, framePosition = [12mm, 8mm], framePlane = XZ)
2721"#;
2722
2723    #[tokio::test(flavor = "multi_thread")]
2724    async fn gdt_circularity_uses_roundness_symbol_without_datums() -> Result<(), KclError> {
2725        let cases = [
2726            ("circular edge", GDT_CIRCULARITY_EDGE_KCL, 0.05),
2727            ("cylinder wall", GDT_CIRCULARITY_WALL_KCL, 0.02),
2728            ("common edge", GDT_CIRCULARITY_COMMON_EDGE_KCL, 0.05),
2729        ];
2730
2731        for (label, code, expected_tolerance) in cases {
2732            let commands = gdt_commands(code).await;
2733            let annotation_index = new_annotation_command_index(&commands)?;
2734            let feature_control = feature_control(&commands[annotation_index])?;
2735            let control_frame = feature_control.control_frame.as_ref().ok_or_else(|| {
2736                KclError::new_internal(KclErrorDetails::new(
2737                    format!("expected {label} feature_control to have a control_frame"),
2738                    vec![SourceRange::default()],
2739                ))
2740            })?;
2741
2742            assert_eq!(control_frame.symbol, MbdSymbol::Roundness, "case: {label}");
2743            assert_close(control_frame.tolerance, expected_tolerance);
2744            // Circularity is a form tolerance and never references datums.
2745            assert!(control_frame.primary_datum.is_none(), "case: {label}");
2746            assert!(control_frame.secondary_datum.is_none(), "case: {label}");
2747            assert!(control_frame.tertiary_datum.is_none(), "case: {label}");
2748        }
2749        Ok(())
2750    }
2751
2752    // Mirrors the gdt::cylindricity doc examples: annotate a cylinder's curved
2753    // wall and its circular edge. Runs in mock mode, so it validates parsing,
2754    // name resolution, and that the control frame uses the Cylindricity symbol
2755    // without datums. The doc examples additionally render against the engine in
2756    // kcl_test_examples.
2757    const GDT_CYLINDRICITY_WALL_KCL: &str = r#"
2758@settings(defaultLengthUnit = mm, kclVersion = 2)
2759
2760cylinderSketch = sketch(on = XY) {
2761  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2762}
2763
2764cylinder = extrude(region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch), length = 10mm)
2765gdt::cylindricity(faces = [cylinder.sketch.tags.perimeter], tolerance = 0.02mm, framePosition = [-12mm, 8mm], framePlane = XZ)
2766"#;
2767
2768    const GDT_CYLINDRICITY_EDGE_KCL: &str = r#"
2769@settings(defaultLengthUnit = mm, kclVersion = 2)
2770
2771cylinderSketch = sketch(on = XY) {
2772  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2773}
2774
2775cylinderRegion = region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch)
2776hide(cylinderSketch)
2777cylinder = extrude(cylinderRegion, length = 10mm)
2778gdt::cylindricity(edges = [cylinderRegion.tags.perimeter], tolerance = 0.05mm, framePosition = [-12mm, 8mm])
2779"#;
2780
2781    const GDT_CYLINDRICITY_COMMON_EDGE_KCL: &str = r#"
2782@settings(defaultLengthUnit = mm, kclVersion = 2)
2783
2784cylinderSketch = sketch(on = XY) {
2785  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2786}
2787
2788cylinder = extrude(region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch), length = 10mm, tagEnd = $top)
2789topEdge = getCommonEdge(faces = [cylinder.sketch.tags.perimeter, top])
2790gdt::cylindricity(edges = [topEdge], tolerance = 0.05mm, framePosition = [-12mm, 8mm], framePlane = XZ)
2791"#;
2792
2793    #[tokio::test(flavor = "multi_thread")]
2794    async fn gdt_cylindricity_uses_cylindricity_symbol_without_datums() -> Result<(), KclError> {
2795        let cases = [
2796            ("cylinder wall", GDT_CYLINDRICITY_WALL_KCL, 0.02),
2797            ("circular edge", GDT_CYLINDRICITY_EDGE_KCL, 0.05),
2798            ("common edge", GDT_CYLINDRICITY_COMMON_EDGE_KCL, 0.05),
2799        ];
2800
2801        for (label, code, expected_tolerance) in cases {
2802            let commands = gdt_commands(code).await;
2803            let annotation_index = new_annotation_command_index(&commands)?;
2804            let feature_control = feature_control(&commands[annotation_index])?;
2805            let control_frame = feature_control.control_frame.as_ref().ok_or_else(|| {
2806                KclError::new_internal(KclErrorDetails::new(
2807                    format!("expected {label} feature_control to have a control_frame"),
2808                    vec![SourceRange::default()],
2809                ))
2810            })?;
2811
2812            assert_eq!(control_frame.symbol, MbdSymbol::Cylindricity, "case: {label}");
2813            assert_close(control_frame.tolerance, expected_tolerance);
2814            // Cylindricity is a form tolerance and never references datums.
2815            assert!(control_frame.primary_datum.is_none(), "case: {label}");
2816            assert!(control_frame.secondary_datum.is_none(), "case: {label}");
2817            assert!(control_frame.tertiary_datum.is_none(), "case: {label}");
2818        }
2819        Ok(())
2820    }
2821
2822    // Uses the GD&T Basics stepped-shaft example: reference feature B is
2823    // controlled relative to datum feature A. Runs in mock mode, so it validates
2824    // parsing, name resolution, and that the control frame uses the
2825    // Concentricity symbol with a diameter tolerance zone and datum reference.
2826    const GDT_CONCENTRICITY_REFERENCE_FEATURE_B_FACE_KCL: &str = r#"
2827@settings(defaultLengthUnit = mm, kclVersion = 2)
2828
2829datumASketch = sketch(on = XY) {
2830  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2831}
2832
2833datumA = extrude(region(point = datumASketch.perimeter.center, sketch = datumASketch), length = 16mm)
2834
2835referenceFeatureBSketch = sketch(on = XY) {
2836  perimeter = circle(start = [var 2.5mm, var 0mm], center = [var 0mm, var 0mm])
2837}
2838
2839referenceFeatureB = extrude(region(point = referenceFeatureBSketch.perimeter.center, sketch = referenceFeatureBSketch), length = 12mm)
2840  |> translate(z = -12mm)
2841
2842gdt::datum(face = datumA.sketch.tags.perimeter, name = "A", framePosition = [10mm, -12mm], framePlane = XZ)
2843gdt::concentricity(faces = [referenceFeatureB.sketch.tags.perimeter], tolerance = 0.2mm, datums = ["A"], framePosition = [-18mm, 12mm], framePlane = XZ)
2844"#;
2845
2846    const GDT_CONCENTRICITY_REFERENCE_FEATURE_B_EDGE_KCL: &str = r#"
2847@settings(defaultLengthUnit = mm, kclVersion = 2)
2848
2849datumASketch = sketch(on = XY) {
2850  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2851}
2852
2853datumA = extrude(region(point = datumASketch.perimeter.center, sketch = datumASketch), length = 16mm)
2854
2855referenceFeatureBSketch = sketch(on = XY) {
2856  perimeter = circle(start = [var 2.5mm, var 0mm], center = [var 0mm, var 0mm])
2857}
2858
2859referenceFeatureB = extrude(region(point = referenceFeatureBSketch.perimeter.center, sketch = referenceFeatureBSketch), length = 12mm, tagEnd = $endB)
2860  |> translate(z = -12mm)
2861endEdgeB = getCommonEdge(faces = [referenceFeatureB.sketch.tags.perimeter, endB])
2862
2863gdt::datum(face = datumA.sketch.tags.perimeter, name = "A", framePosition = [10mm, -12mm], framePlane = XZ)
2864gdt::concentricity(edges = [endEdgeB], tolerance = 0.2mm, datums = ["A"], framePosition = [-18mm, 12mm], framePlane = XZ)
2865"#;
2866
2867    #[tokio::test(flavor = "multi_thread")]
2868    async fn gdt_concentricity_uses_concentricity_symbol_with_diameter_zone_and_datums() -> Result<(), KclError> {
2869        let cases = [
2870            (
2871                "reference feature B face",
2872                GDT_CONCENTRICITY_REFERENCE_FEATURE_B_FACE_KCL,
2873                0.2,
2874            ),
2875            (
2876                "reference feature B edge",
2877                GDT_CONCENTRICITY_REFERENCE_FEATURE_B_EDGE_KCL,
2878                0.2,
2879            ),
2880        ];
2881
2882        for (label, code, expected_tolerance) in cases {
2883            let commands = gdt_commands(code).await;
2884            let control_frame = find_control_frame_with_symbol(&commands, MbdSymbol::Concentricity)?;
2885
2886            assert_eq!(
2887                control_frame.diameter_symbol,
2888                Some(MbdSymbol::Diameter),
2889                "case: {label}"
2890            );
2891            assert_close(control_frame.tolerance, expected_tolerance);
2892            assert_eq!(control_frame.primary_datum, Some('A'), "case: {label}");
2893            assert!(control_frame.secondary_datum.is_none(), "case: {label}");
2894            assert!(control_frame.tertiary_datum.is_none(), "case: {label}");
2895        }
2896        Ok(())
2897    }
2898
2899    // Models the GD&T Basics latch-block groove example as closely as the
2900    // current datum API allows. Each test annotates one groove floor target
2901    // so KCL emits one symmetry feature control frame.
2902    const GDT_SYMMETRY_LATCH_BLOCK_GROOVE_FACE_KCL: &str = r#"
2903@settings(defaultLengthUnit = mm, kclVersion = 2)
2904
2905latchProfile = sketch(on = XZ) {
2906  bottom = line(start = [var -20mm, var -10mm], end = [var 20mm, var -10mm])
2907  datumWidthFace = line(start = [var 20mm, var -10mm], end = [var 20mm, var 10mm])
2908  topRight = line(start = [var 20mm, var 10mm], end = [var 5mm, var 10mm])
2909  rightGrooveWall = line(start = [var 5mm, var 10mm], end = [var 5mm, var 3mm])
2910  grooveFloor = line(start = [var 5mm, var 3mm], end = [var -5mm, var 3mm])
2911  leftGrooveWall = line(start = [var -5mm, var 3mm], end = [var -5mm, var 10mm])
2912  topLeft = line(start = [var -5mm, var 10mm], end = [var -20mm, var 10mm])
2913  leftSide = line(start = [var -20mm, var 10mm], end = [var -20mm, var -10mm])
2914  coincident([bottom.end, datumWidthFace.start])
2915  coincident([datumWidthFace.end, topRight.start])
2916  coincident([topRight.end, rightGrooveWall.start])
2917  coincident([rightGrooveWall.end, grooveFloor.start])
2918  coincident([grooveFloor.end, leftGrooveWall.start])
2919  coincident([leftGrooveWall.end, topLeft.start])
2920  coincident([topLeft.end, leftSide.start])
2921  coincident([leftSide.end, bottom.start])
2922  horizontal(bottom)
2923  vertical(datumWidthFace)
2924  horizontal(topRight)
2925  vertical(rightGrooveWall)
2926  horizontal(grooveFloor)
2927  vertical(leftGrooveWall)
2928  horizontal(topLeft)
2929  vertical(leftSide)
2930}
2931
2932latchBlockRegion = region(point = [0mm, 0mm], sketch = latchProfile)
2933latchBlock = extrude(latchBlockRegion, length = 12mm)
2934
2935gdt::datum(face = latchBlock.sketch.tags.bottom, name = "A", framePosition = [0mm, -16mm], framePlane = XZ)
2936gdt::symmetry(faces = [latchBlock.sketch.tags.grooveFloor], tolerance = 0.2mm, datums = ["A"], framePosition = [-24mm, 14mm], framePlane = XZ)
2937"#;
2938
2939    const GDT_SYMMETRY_LATCH_BLOCK_GROOVE_EDGE_KCL: &str = r#"
2940@settings(defaultLengthUnit = mm, kclVersion = 2)
2941
2942latchProfile = sketch(on = XZ) {
2943  bottom = line(start = [var -20mm, var -10mm], end = [var 20mm, var -10mm])
2944  datumWidthFace = line(start = [var 20mm, var -10mm], end = [var 20mm, var 10mm])
2945  topRight = line(start = [var 20mm, var 10mm], end = [var 5mm, var 10mm])
2946  rightGrooveWall = line(start = [var 5mm, var 10mm], end = [var 5mm, var 3mm])
2947  grooveFloor = line(start = [var 5mm, var 3mm], end = [var -5mm, var 3mm])
2948  leftGrooveWall = line(start = [var -5mm, var 3mm], end = [var -5mm, var 10mm])
2949  topLeft = line(start = [var -5mm, var 10mm], end = [var -20mm, var 10mm])
2950  leftSide = line(start = [var -20mm, var 10mm], end = [var -20mm, var -10mm])
2951  coincident([bottom.end, datumWidthFace.start])
2952  coincident([datumWidthFace.end, topRight.start])
2953  coincident([topRight.end, rightGrooveWall.start])
2954  coincident([rightGrooveWall.end, grooveFloor.start])
2955  coincident([grooveFloor.end, leftGrooveWall.start])
2956  coincident([leftGrooveWall.end, topLeft.start])
2957  coincident([topLeft.end, leftSide.start])
2958  coincident([leftSide.end, bottom.start])
2959  horizontal(bottom)
2960  vertical(datumWidthFace)
2961  horizontal(topRight)
2962  vertical(rightGrooveWall)
2963  horizontal(grooveFloor)
2964  vertical(leftGrooveWall)
2965  horizontal(topLeft)
2966  vertical(leftSide)
2967}
2968
2969latchBlockRegion = region(point = [0mm, 0mm], sketch = latchProfile)
2970latchBlock = extrude(latchBlockRegion, length = 12mm, tagEnd = $frontFace)
2971grooveFloorFrontEdge = getCommonEdge(faces = [latchBlock.sketch.tags.grooveFloor, frontFace])
2972
2973gdt::datum(face = latchBlock.sketch.tags.bottom, name = "A", framePosition = [0mm, -16mm], framePlane = XZ)
2974gdt::symmetry(edges = [grooveFloorFrontEdge], tolerance = 0.2mm, datums = ["A"], framePosition = [-24mm, 14mm], framePlane = XZ)
2975"#;
2976
2977    #[tokio::test(flavor = "multi_thread")]
2978    async fn gdt_symmetry_uses_symmetry_symbol_with_datums_for_face() -> Result<(), KclError> {
2979        let commands = gdt_commands(GDT_SYMMETRY_LATCH_BLOCK_GROOVE_FACE_KCL).await;
2980        let control_frames: Vec<_> = commands
2981            .iter()
2982            .filter_map(|command| {
2983                feature_control(command)
2984                    .ok()
2985                    .and_then(|feature_control| feature_control.control_frame.as_ref())
2986                    .filter(|control_frame| control_frame.symbol == MbdSymbol::Symmetry)
2987            })
2988            .collect();
2989
2990        assert_eq!(control_frames.len(), 1);
2991        let control_frame = control_frames[0];
2992        assert_eq!(control_frame.diameter_symbol, None);
2993        assert_close(control_frame.tolerance, 0.2);
2994        assert_eq!(control_frame.primary_datum, Some('A'));
2995        assert!(control_frame.secondary_datum.is_none());
2996        assert!(control_frame.tertiary_datum.is_none());
2997        Ok(())
2998    }
2999
3000    #[tokio::test(flavor = "multi_thread")]
3001    async fn gdt_symmetry_uses_symmetry_symbol_with_datums_for_edge() -> Result<(), KclError> {
3002        let commands = gdt_commands(GDT_SYMMETRY_LATCH_BLOCK_GROOVE_EDGE_KCL).await;
3003        let control_frames: Vec<_> = commands
3004            .iter()
3005            .filter_map(|command| {
3006                feature_control(command)
3007                    .ok()
3008                    .and_then(|feature_control| feature_control.control_frame.as_ref())
3009                    .filter(|control_frame| control_frame.symbol == MbdSymbol::Symmetry)
3010            })
3011            .collect();
3012
3013        assert_eq!(control_frames.len(), 1);
3014        let control_frame = control_frames[0];
3015        assert_eq!(control_frame.diameter_symbol, None);
3016        assert_close(control_frame.tolerance, 0.2);
3017        assert_eq!(control_frame.primary_datum, Some('A'));
3018        assert!(control_frame.secondary_datum.is_none());
3019        assert!(control_frame.tertiary_datum.is_none());
3020        Ok(())
3021    }
3022
3023    // Covers the gdt::runout doc example plus a face-based variant. Runs in mock mode, so it validates
3024    // parsing, name resolution, and that the control frame uses the Runout
3025    // symbol with a datum reference and no diameter symbol.
3026    const GDT_RUNOUT_STEPPED_SHAFT_KCL: &str = r#"
3027@settings(defaultLengthUnit = mm, kclVersion = 2)
3028
3029annotationPlane = offsetPlane(XZ, offset = 24mm)
3030
3031controlledSketch = sketch(on = YZ) {
3032  upperPerimeter = arc(start = [var 10mm, var 0mm], end = [var -10mm, var 0mm], center = [var 0mm, var 0mm])
3033  lowerPerimeter = arc(start = [var -10mm, var 0mm], end = [var 10mm, var 0mm], center = [var 0mm, var 0mm])
3034  coincident([upperPerimeter.end, lowerPerimeter.start])
3035  coincident([lowerPerimeter.end, upperPerimeter.start])
3036}
3037
3038controlledShaft = extrude(
3039  region(point = [0mm, 1mm], sketch = controlledSketch),
3040  length = -58mm,
3041  tagStart = $controlledShoulder,
3042  tagEnd = $controlledFreeEnd
3043)
3044
3045controlledUpperShoulderEdge = getCommonEdge(faces = [
3046  controlledShaft.sketch.tags.upperPerimeter,
3047  controlledShoulder
3048])
3049
3050datumSketch = sketch(on = YZ) {
3051  perimeter = circle(start = [var 18mm, var 0mm], center = [var 0mm, var 0mm])
3052}
3053
3054datumShaft = extrude(
3055  region(point = datumSketch.perimeter.center, sketch = datumSketch),
3056  length = 36mm,
3057  tagEnd = $datumEnd
3058)
3059
3060gdt::datum(
3061  face = datumShaft.sketch.tags.perimeter,
3062  name = "A",
3063  framePosition = [18mm, -28mm],
3064  framePlane = annotationPlane,
3065  leaderScale = 1.15,
3066  fontSize = 6mm
3067)
3068
3069gdt::runout(
3070  edges = [controlledUpperShoulderEdge],
3071  tolerance = 0.2mm,
3072  datums = ["A"],
3073  precision = 1,
3074  framePosition = [12mm, 48mm],
3075  framePlane = annotationPlane,
3076  leaderScale = 1.15,
3077  fontSize = 6mm
3078)
3079"#;
3080
3081    const GDT_RUNOUT_FACE_KCL: &str = r#"
3082@settings(defaultLengthUnit = mm, kclVersion = 2)
3083
3084datumSketch = sketch(on = XY) {
3085  perimeter = circle(start = [var 6mm, var 0mm], center = [var 0mm, var 0mm])
3086}
3087
3088datumShaft = extrude(region(point = datumSketch.perimeter.center, sketch = datumSketch), length = 18mm)
3089
3090controlledSketch = sketch(on = XY) {
3091  perimeter = circle(start = [var 3mm, var 0mm], center = [var 0mm, var 0mm])
3092}
3093
3094controlledShaft = extrude(region(point = controlledSketch.perimeter.center, sketch = controlledSketch), length = 16mm)
3095  |> translate(z = -16mm)
3096
3097gdt::datum(face = datumShaft.sketch.tags.perimeter, name = "A", framePosition = [12mm, -14mm], framePlane = XZ)
3098gdt::runout(faces = [controlledShaft.sketch.tags.perimeter], tolerance = 0.2mm, datums = ["A"], framePosition = [-18mm, 12mm], framePlane = XZ)
3099"#;
3100
3101    #[tokio::test(flavor = "multi_thread")]
3102    async fn gdt_runout_uses_runout_symbol_with_axis_datum() -> Result<(), KclError> {
3103        let cases = [
3104            ("stepped shaft", GDT_RUNOUT_STEPPED_SHAFT_KCL, 0.2),
3105            ("controlled face", GDT_RUNOUT_FACE_KCL, 0.2),
3106        ];
3107
3108        for (label, code, expected_tolerance) in cases {
3109            let commands = gdt_commands(code).await;
3110            let control_frame = find_control_frame_with_symbol(&commands, MbdSymbol::Runout)?;
3111
3112            assert!(control_frame.diameter_symbol.is_none(), "case: {label}");
3113            assert_close(control_frame.tolerance, expected_tolerance);
3114            assert_eq!(control_frame.primary_datum, Some('A'), "case: {label}");
3115            assert!(control_frame.secondary_datum.is_none(), "case: {label}");
3116            assert!(control_frame.tertiary_datum.is_none(), "case: {label}");
3117        }
3118        Ok(())
3119    }
3120}