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::Centroid {},
1059            },
1060            DistanceEndpoint {
1061                entity_id,
1062                edge_reference,
1063                entity_pos: AnnotationMbdLeaderPosition::Centroid {},
1064            },
1065            &tolerance,
1066            precision,
1067            frame_position.as_ref(),
1068            frame_plane.id,
1069            leader_scale.as_ref(),
1070            font_size.as_ref(),
1071            exec_state,
1072            args,
1073            &mut annotations,
1074        )
1075        .await?;
1076    }
1077    Ok(annotations)
1078}
1079
1080#[allow(clippy::too_many_arguments)]
1081async fn create_basic_distance_annotation(
1082    from: DistanceEndpoint,
1083    to: DistanceEndpoint,
1084    tolerance: &Option<TyF64>,
1085    precision: u32,
1086    frame_position: Option<&[TyF64; 2]>,
1087    frame_plane_id: uuid::Uuid,
1088    leader_scale: Option<&TyF64>,
1089    font_size: Option<&TyF64>,
1090    exec_state: &mut ExecState,
1091    args: &Args,
1092    annotations: &mut Vec<GdtAnnotation>,
1093) -> Result<(), KclError> {
1094    let meta = vec![Metadata::from(args.source_range)];
1095    let annotation_id = exec_state.next_uuid();
1096    let display_units = exec_state.length_unit();
1097    let dimension = AnnotationBasicDimension::builder()
1098        .maybe_from_entity_id(from.entity_id)
1099        .maybe_from_edge_reference(from.edge_reference)
1100        .from_entity_leader_pos(from.entity_pos)
1101        .maybe_to_entity_id(to.entity_id)
1102        .maybe_to_edge_reference(to.edge_reference)
1103        .to_entity_leader_pos(to.entity_pos)
1104        .dimension(
1105            AnnotationMbdBasicDimension::builder()
1106                .tolerance(
1107                    tolerance
1108                        .as_ref()
1109                        .map(|tol| tol.to_length_units(display_units))
1110                        .unwrap_or_default(),
1111                )
1112                .build(),
1113        )
1114        .plane_id(frame_plane_id)
1115        .offset(if let Some(offset) = frame_position {
1116            KPoint2d {
1117                x: offset[0].to_mm(),
1118                y: offset[1].to_mm(),
1119            }
1120        } else {
1121            KPoint2d { x: 100.0, y: 100.0 }
1122        })
1123        .precision(precision)
1124        .font_scale(gdt_font_scale(font_size, args)?)
1125        .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
1126        .arrow_scale(gdt_dimension_leader_scale(leader_scale, args)?)
1127        .build();
1128    let annotation_name = gdt_annotation_name(exec_state, args)?;
1129    let options = AnnotationOptions::builder()
1130        .dimension(dimension)
1131        .units(display_units.to_kcmc())
1132        .maybe_name(annotation_name)
1133        .build();
1134    let annotation_cmd = ModelingCmd::from(
1135        mcmd::NewAnnotation::builder()
1136            .options(options)
1137            .clobber(false)
1138            .annotation_type(AnnotationType::T3D)
1139            .build(),
1140    );
1141    let cmd_meta = ModelingCmdMeta::from_args_id(exec_state, args, annotation_id);
1142    exec_state.batch_modeling_cmd(cmd_meta, annotation_cmd).await?;
1143    add_gdt_annotation_artifact(exec_state, args, annotation_id);
1144    annotations.push(GdtAnnotation {
1145        id: annotation_id,
1146        meta,
1147    });
1148    Ok(())
1149}
1150
1151pub async fn angularity(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
1152    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
1153        "faces",
1154        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
1155        exec_state,
1156    )?;
1157    let edges = parse_gdt_edges_arg(exec_state, &args).await?;
1158    let datums: Option<Vec<String>> = args.get_kw_arg_opt(
1159        "datums",
1160        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
1161        exec_state,
1162    )?;
1163    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
1164    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
1165    let frame_position: Option<[TyF64; 2]> =
1166        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
1167    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
1168    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
1169    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
1170
1171    let annotations = create_feature_control_annotations(
1172        GdtFeatureControlKind::Angularity,
1173        GdtFeatureControlParams {
1174            faces: faces.unwrap_or_default(),
1175            edges,
1176            datums,
1177            tolerance,
1178            precision,
1179            frame_position,
1180            frame_plane,
1181            leader_scale,
1182            font_size,
1183        },
1184        exec_state,
1185        &args,
1186    )
1187    .await?;
1188    Ok(annotations.into())
1189}
1190
1191pub async fn perpendicularity(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
1192    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
1193        "faces",
1194        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
1195        exec_state,
1196    )?;
1197    let edges = parse_gdt_edges_arg(exec_state, &args).await?;
1198    let datums: Option<Vec<String>> = args.get_kw_arg_opt(
1199        "datums",
1200        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
1201        exec_state,
1202    )?;
1203    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
1204    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
1205    let frame_position: Option<[TyF64; 2]> =
1206        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
1207    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
1208    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
1209    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
1210
1211    let annotations = create_feature_control_annotations(
1212        GdtFeatureControlKind::Perpendicularity,
1213        GdtFeatureControlParams {
1214            faces: faces.unwrap_or_default(),
1215            edges,
1216            datums,
1217            tolerance,
1218            precision,
1219            frame_position,
1220            frame_plane,
1221            leader_scale,
1222            font_size,
1223        },
1224        exec_state,
1225        &args,
1226    )
1227    .await?;
1228    Ok(annotations.into())
1229}
1230
1231pub async fn parallelism(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
1232    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
1233        "faces",
1234        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
1235        exec_state,
1236    )?;
1237    let edges = parse_gdt_edges_arg(exec_state, &args).await?;
1238    let datums: Option<Vec<String>> = args.get_kw_arg_opt(
1239        "datums",
1240        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
1241        exec_state,
1242    )?;
1243    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
1244    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
1245    let frame_position: Option<[TyF64; 2]> =
1246        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
1247    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
1248    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
1249    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
1250
1251    let annotations = create_feature_control_annotations(
1252        GdtFeatureControlKind::Parallelism,
1253        GdtFeatureControlParams {
1254            faces: faces.unwrap_or_default(),
1255            edges,
1256            datums,
1257            tolerance,
1258            precision,
1259            frame_position,
1260            frame_plane,
1261            leader_scale,
1262            font_size,
1263        },
1264        exec_state,
1265        &args,
1266    )
1267    .await?;
1268    Ok(annotations.into())
1269}
1270
1271pub async fn annotation(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
1272    let annotation: String = args.get_kw_arg("annotation", &RuntimeType::string(), exec_state)?;
1273    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
1274        "faces",
1275        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
1276        exec_state,
1277    )?;
1278    let edges = parse_gdt_edges_arg(exec_state, &args).await?;
1279    let frame_position: Option<[TyF64; 2]> =
1280        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
1281    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
1282    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
1283    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
1284
1285    let annotations = inner_annotation(
1286        annotation,
1287        faces.unwrap_or_default(),
1288        edges,
1289        frame_position,
1290        frame_plane,
1291        leader_scale,
1292        font_size,
1293        exec_state,
1294        &args,
1295    )
1296    .await?;
1297    Ok(annotations.into())
1298}
1299
1300#[allow(clippy::too_many_arguments)]
1301async fn inner_annotation(
1302    annotation: String,
1303    faces: Vec<TagIdentifier>,
1304    edges: Vec<GdtEdgeReference>,
1305    frame_position: Option<[TyF64; 2]>,
1306    frame_plane: Option<Plane>,
1307    leader_scale: Option<TyF64>,
1308    font_size: Option<TyF64>,
1309    exec_state: &mut ExecState,
1310    args: &Args,
1311) -> Result<Vec<GdtAnnotation>, KclError> {
1312    if annotation.is_empty() {
1313        return Err(KclError::new_semantic(KclErrorDetails::new(
1314            "Annotation text must not be empty.".to_owned(),
1315            vec![args.source_range],
1316        )));
1317    }
1318    if faces.is_empty() && edges.is_empty() {
1319        return Err(KclError::new_semantic(KclErrorDetails::new(
1320            "Annotation requires at least one face or edge.".to_owned(),
1321            vec![args.source_range],
1322        )));
1323    }
1324
1325    let mut frame_plane = if let Some(plane) = frame_plane {
1326        plane
1327    } else {
1328        xy_plane(exec_state, args).await?
1329    };
1330    ensure_sketch_plane_in_engine(
1331        &mut frame_plane,
1332        exec_state,
1333        &args.ctx,
1334        args.source_range,
1335        args.node_path.clone(),
1336    )
1337    .await?;
1338
1339    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
1340    for face in &faces {
1341        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
1342        create_annotation(
1343            Some(face_id),
1344            None,
1345            &annotation,
1346            frame_position.as_ref(),
1347            frame_plane.id,
1348            leader_scale.as_ref(),
1349            font_size.as_ref(),
1350            exec_state,
1351            args,
1352            &mut annotations,
1353        )
1354        .await?;
1355    }
1356    for edge in &edges {
1357        match edge {
1358            GdtEdgeReference::Entity(edge) => {
1359                create_annotation(
1360                    Some(edge.get_engine_id(exec_state, args)?),
1361                    None,
1362                    &annotation,
1363                    frame_position.as_ref(),
1364                    frame_plane.id,
1365                    leader_scale.as_ref(),
1366                    font_size.as_ref(),
1367                    exec_state,
1368                    args,
1369                    &mut annotations,
1370                )
1371                .await?;
1372            }
1373            GdtEdgeReference::Specifier(edge_reference) => {
1374                create_annotation(
1375                    None,
1376                    Some(edge_reference.clone()),
1377                    &annotation,
1378                    frame_position.as_ref(),
1379                    frame_plane.id,
1380                    leader_scale.as_ref(),
1381                    font_size.as_ref(),
1382                    exec_state,
1383                    args,
1384                    &mut annotations,
1385                )
1386                .await?;
1387            }
1388        }
1389    }
1390
1391    Ok(annotations)
1392}
1393
1394fn resolve_precision(precision: Option<TyF64>, args: &Args) -> Result<u32, KclError> {
1395    if let Some(precision) = precision {
1396        let rounded = precision.n.round();
1397        if !(0.0..=9.0).contains(&rounded) {
1398            return Err(KclError::new_semantic(KclErrorDetails::new(
1399                "Precision must be between 0 and 9".to_owned(),
1400                vec![args.source_range],
1401            )));
1402        }
1403        Ok(rounded as u32)
1404    } else {
1405        Ok(3)
1406    }
1407}
1408
1409async fn resolve_gdt_frame_plane(
1410    frame_plane: Option<Plane>,
1411    exec_state: &mut ExecState,
1412    args: &Args,
1413) -> Result<Plane, KclError> {
1414    let mut frame_plane = if let Some(plane) = frame_plane {
1415        plane
1416    } else {
1417        // No plane given. Use one of the standard planes.
1418        xy_plane(exec_state, args).await?
1419    };
1420    ensure_sketch_plane_in_engine(
1421        &mut frame_plane,
1422        exec_state,
1423        &args.ctx,
1424        args.source_range,
1425        args.node_path.clone(),
1426    )
1427    .await?;
1428    Ok(frame_plane)
1429}
1430
1431async fn create_feature_control_annotations(
1432    kind: GdtFeatureControlKind,
1433    params: GdtFeatureControlParams,
1434    exec_state: &mut ExecState,
1435    args: &Args,
1436) -> Result<Vec<GdtAnnotation>, KclError> {
1437    let GdtFeatureControlParams {
1438        faces,
1439        edges,
1440        datums,
1441        tolerance,
1442        precision,
1443        frame_position,
1444        frame_plane,
1445        leader_scale,
1446        font_size,
1447    } = params;
1448
1449    if faces.is_empty() && edges.is_empty() {
1450        return Err(KclError::new_semantic(KclErrorDetails::new(
1451            format!("{} requires at least one face or edge.", kind.label()),
1452            vec![args.source_range],
1453        )));
1454    }
1455
1456    let precision = resolve_precision(precision, args)?;
1457    let datums = resolve_datums(datums, args, kind.label())?;
1458    if kind.requires_datums() && datums.is_empty() {
1459        return Err(KclError::new_semantic(KclErrorDetails::new(
1460            format!("{} requires at least one datum.", kind.label()),
1461            vec![args.source_range],
1462        )));
1463    }
1464    let frame_plane = resolve_gdt_frame_plane(frame_plane, exec_state, args).await?;
1465    let symbol = kind.symbol();
1466    let diameter_symbol = kind.diameter_symbol();
1467
1468    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
1469    for face in &faces {
1470        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
1471        create_feature_control_annotation(
1472            Some(face_id),
1473            None,
1474            symbol,
1475            diameter_symbol,
1476            &tolerance,
1477            &datums,
1478            precision,
1479            frame_position.as_ref(),
1480            frame_plane.id,
1481            leader_scale.as_ref(),
1482            font_size.as_ref(),
1483            exec_state,
1484            args,
1485            &mut annotations,
1486        )
1487        .await?;
1488    }
1489    for edge in &edges {
1490        match edge {
1491            GdtEdgeReference::Entity(edge) => {
1492                create_feature_control_annotation(
1493                    Some(edge.get_engine_id(exec_state, args)?),
1494                    None,
1495                    symbol,
1496                    diameter_symbol,
1497                    &tolerance,
1498                    &datums,
1499                    precision,
1500                    frame_position.as_ref(),
1501                    frame_plane.id,
1502                    leader_scale.as_ref(),
1503                    font_size.as_ref(),
1504                    exec_state,
1505                    args,
1506                    &mut annotations,
1507                )
1508                .await?;
1509            }
1510            GdtEdgeReference::Specifier(edge_reference) => {
1511                create_feature_control_annotation(
1512                    None,
1513                    Some(edge_reference.clone()),
1514                    symbol,
1515                    diameter_symbol,
1516                    &tolerance,
1517                    &datums,
1518                    precision,
1519                    frame_position.as_ref(),
1520                    frame_plane.id,
1521                    leader_scale.as_ref(),
1522                    font_size.as_ref(),
1523                    exec_state,
1524                    args,
1525                    &mut annotations,
1526                )
1527                .await?;
1528            }
1529        }
1530    }
1531
1532    Ok(annotations)
1533}
1534
1535#[allow(clippy::too_many_arguments)]
1536async fn create_feature_control_annotation(
1537    entity_id: Option<uuid::Uuid>,
1538    edge_reference: Option<kcmc::shared::EdgeSpecifier>,
1539    symbol: MbdSymbol,
1540    diameter_symbol: Option<MbdSymbol>,
1541    tolerance: &TyF64,
1542    datums: &[char],
1543    precision: u32,
1544    frame_position: Option<&[TyF64; 2]>,
1545    frame_plane_id: uuid::Uuid,
1546    leader_scale: Option<&TyF64>,
1547    font_size: Option<&TyF64>,
1548    exec_state: &mut ExecState,
1549    args: &Args,
1550    annotations: &mut Vec<GdtAnnotation>,
1551) -> Result<(), KclError> {
1552    let meta = vec![Metadata::from(args.source_range)];
1553    let annotation_id = exec_state.next_uuid();
1554    let display_units = exec_state.length_unit();
1555    let control_frame = gdt_control_frame(
1556        symbol,
1557        diameter_symbol,
1558        tolerance.to_length_units(display_units),
1559        datums,
1560    );
1561    let feature_control = AnnotationFeatureControl::builder()
1562        .maybe_entity_id(entity_id)
1563        .maybe_edge_reference(edge_reference)
1564        .entity_pos(KPoint2d { x: 0.5, y: 0.5 })
1565        .leader_type(AnnotationLineEnd::Dot)
1566        .control_frame(control_frame)
1567        .plane_id(frame_plane_id)
1568        .offset(if let Some(offset) = frame_position {
1569            KPoint2d {
1570                x: offset[0].to_mm(),
1571                y: offset[1].to_mm(),
1572            }
1573        } else {
1574            KPoint2d { x: 100.0, y: 100.0 }
1575        })
1576        .precision(precision)
1577        .font_scale(gdt_font_scale(font_size, args)?)
1578        .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
1579        .leader_scale(gdt_dot_leader_scale(leader_scale, font_size, args)?)
1580        .build();
1581    let annotation_name = gdt_annotation_name(exec_state, args)?;
1582    let options = AnnotationOptions::builder()
1583        .feature_control(feature_control)
1584        .maybe_name(annotation_name)
1585        .build();
1586    exec_state
1587        .batch_modeling_cmd(
1588            ModelingCmdMeta::from_args_id(exec_state, args, annotation_id),
1589            ModelingCmd::from(
1590                mcmd::NewAnnotation::builder()
1591                    .options(options)
1592                    .clobber(false)
1593                    .annotation_type(AnnotationType::T3D)
1594                    .build(),
1595            ),
1596        )
1597        .await?;
1598    add_gdt_annotation_artifact(exec_state, args, annotation_id);
1599    annotations.push(GdtAnnotation {
1600        id: annotation_id,
1601        meta,
1602    });
1603    Ok(())
1604}
1605
1606fn gdt_control_frame(
1607    symbol: MbdSymbol,
1608    diameter_symbol: Option<MbdSymbol>,
1609    tolerance: f64,
1610    datums: &[char],
1611) -> AnnotationMbdControlFrame {
1612    match datums {
1613        [] => AnnotationMbdControlFrame::builder()
1614            .symbol(symbol)
1615            .maybe_diameter_symbol(diameter_symbol)
1616            .tolerance(tolerance)
1617            .build(),
1618        [primary] => AnnotationMbdControlFrame::builder()
1619            .symbol(symbol)
1620            .maybe_diameter_symbol(diameter_symbol)
1621            .tolerance(tolerance)
1622            .primary_datum(*primary)
1623            .build(),
1624        [primary, secondary] => AnnotationMbdControlFrame::builder()
1625            .symbol(symbol)
1626            .maybe_diameter_symbol(diameter_symbol)
1627            .tolerance(tolerance)
1628            .primary_datum(*primary)
1629            .secondary_datum(*secondary)
1630            .build(),
1631        [primary, secondary, tertiary] => AnnotationMbdControlFrame::builder()
1632            .symbol(symbol)
1633            .maybe_diameter_symbol(diameter_symbol)
1634            .tolerance(tolerance)
1635            .primary_datum(*primary)
1636            .secondary_datum(*secondary)
1637            .tertiary_datum(*tertiary)
1638            .build(),
1639        _ => unreachable!("resolve_datums rejects more than three datums"),
1640    }
1641}
1642
1643#[allow(clippy::too_many_arguments)]
1644async fn create_annotation(
1645    entity_id: Option<uuid::Uuid>,
1646    edge_reference: Option<kcmc::shared::EdgeSpecifier>,
1647    annotation: &str,
1648    frame_position: Option<&[TyF64; 2]>,
1649    frame_plane_id: uuid::Uuid,
1650    leader_scale: Option<&TyF64>,
1651    font_size: Option<&TyF64>,
1652    exec_state: &mut ExecState,
1653    args: &Args,
1654    annotations: &mut Vec<GdtAnnotation>,
1655) -> Result<(), KclError> {
1656    let meta = vec![Metadata::from(args.source_range)];
1657    let annotation_id = exec_state.next_uuid();
1658    let feature_control = AnnotationFeatureControl::builder()
1659        .maybe_entity_id(entity_id)
1660        .maybe_edge_reference(edge_reference)
1661        .entity_pos(KPoint2d { x: 0.5, y: 0.5 })
1662        .leader_type(AnnotationLineEnd::Dot)
1663        .prefix(annotation.to_owned())
1664        .plane_id(frame_plane_id)
1665        .offset(if let Some(offset) = frame_position {
1666            KPoint2d {
1667                x: offset[0].to_mm(),
1668                y: offset[1].to_mm(),
1669            }
1670        } else {
1671            KPoint2d { x: 100.0, y: 100.0 }
1672        })
1673        .precision(0)
1674        .font_scale(gdt_font_scale(font_size, args)?)
1675        .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
1676        .leader_scale(gdt_dot_leader_scale(leader_scale, font_size, args)?)
1677        .build();
1678    let annotation_name = gdt_annotation_name(exec_state, args)?;
1679    let options = AnnotationOptions::builder()
1680        .feature_control(feature_control)
1681        .maybe_name(annotation_name)
1682        .build();
1683    exec_state
1684        .batch_modeling_cmd(
1685            ModelingCmdMeta::from_args_id(exec_state, args, annotation_id),
1686            ModelingCmd::from(
1687                mcmd::NewAnnotation::builder()
1688                    .options(options)
1689                    .clobber(false)
1690                    .annotation_type(AnnotationType::T3D)
1691                    .build(),
1692            ),
1693        )
1694        .await?;
1695    add_gdt_annotation_artifact(exec_state, args, annotation_id);
1696    annotations.push(GdtAnnotation {
1697        id: annotation_id,
1698        meta,
1699    });
1700    Ok(())
1701}
1702
1703fn resolve_datums(datums: Option<Vec<String>>, args: &Args, annotation_name: &str) -> Result<Vec<char>, KclError> {
1704    let datums = datums.unwrap_or_default();
1705    if datums.len() > 3 {
1706        return Err(KclError::new_semantic(KclErrorDetails::new(
1707            format!("{annotation_name} datums must include at most three names."),
1708            vec![args.source_range],
1709        )));
1710    }
1711
1712    let mut resolved = Vec::with_capacity(datums.len());
1713    for datum in &datums {
1714        let mut chars = datum.chars();
1715        let Some(name) = chars.next() else {
1716            return Err(KclError::new_semantic(KclErrorDetails::new(
1717                format!("{annotation_name} datum names must be a single character."),
1718                vec![args.source_range],
1719            )));
1720        };
1721        if chars.next().is_some() {
1722            return Err(KclError::new_semantic(KclErrorDetails::new(
1723                format!("{annotation_name} datum names must be a single character."),
1724                vec![args.source_range],
1725            )));
1726        }
1727        resolved.push(name);
1728    }
1729
1730    Ok(resolved)
1731}
1732
1733/// Get the XY plane by evaluating the `XY` expression so that it's the same as
1734/// if the user specified `XY`.
1735async fn xy_plane(exec_state: &mut ExecState, args: &Args) -> Result<Plane, KclError> {
1736    let plane_ast = plane_ast("XY", args.source_range);
1737    let metadata = Metadata::from(args.source_range);
1738    let plane_value = args.ctx.eval_expr_fresh_root(&plane_ast, exec_state, &metadata).await?;
1739    // Evaluating a constant name cannot exit().
1740    let plane_value = plane_value.into_value();
1741    Ok(plane_value
1742        .as_plane()
1743        .ok_or_else(|| {
1744            KclError::new_internal(KclErrorDetails::new(
1745                "Expected XY plane to be defined".to_owned(),
1746                vec![args.source_range],
1747            ))
1748        })?
1749        .clone())
1750}
1751
1752/// An AST node for a plane with the given name.
1753fn plane_ast(plane_name: &str, range: SourceRange) -> ast::Node<ast::Expr> {
1754    ast::Node::new(
1755        ast::Expr::Name(BoxNode::new(ast::Node::new(
1756            ast::Name {
1757                name: ast::Identifier::new(plane_name),
1758                path: Vec::new(),
1759                // TODO: We may want to set this to true once we implement it to
1760                // prevent it breaking if users redefine the identifier.
1761                abs_path: false,
1762                digest: None,
1763            },
1764            range.start(),
1765            range.end(),
1766            range.module_id(),
1767        ))),
1768        range.start(),
1769        range.end(),
1770        range.module_id(),
1771    )
1772}
1773
1774#[cfg(test)]
1775mod tests {
1776    use super::*;
1777    use crate::ExecutorContext;
1778    use crate::execution::Artifact;
1779    use crate::execution::ExecutorSettings;
1780    use crate::execution::MockConfig;
1781    use crate::execution::parse_execute;
1782
1783    const GDT_DISTANCE_KCL_TEMPLATE: &str = r#"
1784@settings(defaultLengthUnit = __UNIT__, kclVersion = 2)
1785
1786sketch001 = sketch(on = XY) {
1787  line1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
1788  line2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
1789  line3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
1790  line4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
1791  coincident([line1.end, line2.start])
1792  coincident([line2.end, line3.start])
1793  coincident([line3.end, line4.start])
1794  coincident([line4.end, line1.start])
1795  parallel([line2, line4])
1796  parallel([line3, line1])
1797  perpendicular([line1, line2])
1798  horizontal(line3)
1799}
1800
1801region001 = region(point = [5mm, 5mm], sketch = sketch001)
1802extrude001 = extrude(region001, length = 10mm)
1803gdt::distance(
1804  edges = [
1805    getCommonEdge(faces = [
1806      region001.tags.line4,
1807      region001.tags.line1
1808    ])
1809  ],
1810  tolerance = __TOLERANCE__,
1811  framePosition = __FRAME_POSITION__,
1812  fontSize = 2in,
1813)
1814"#;
1815
1816    const GDT_FLATNESS_KCL_TEMPLATE: &str = r#"
1817@settings(defaultLengthUnit = __UNIT__, kclVersion = 2)
1818
1819sketch001 = sketch(on = XY) {
1820  line1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
1821  line2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
1822  line3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
1823  line4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
1824  coincident([line1.end, line2.start])
1825  coincident([line2.end, line3.start])
1826  coincident([line3.end, line4.start])
1827  coincident([line4.end, line1.start])
1828  parallel([line2, line4])
1829  parallel([line3, line1])
1830  perpendicular([line1, line2])
1831  horizontal(line3)
1832}
1833
1834region001 = region(point = [5mm, 5mm], sketch = sketch001)
1835extrude001 = extrude(region001, length = 10mm, tagEnd = $capEnd001)
1836gdt::flatness(
1837  faces = [capEnd001],
1838  tolerance = __TOLERANCE__,
1839  framePosition = __FRAME_POSITION__,
1840  framePlane = XZ,
1841  fontSize = 2in,
1842)
1843"#;
1844
1845    fn gdt_distance_kcl(unit: &str, tolerance: &str, frame_position: &str) -> String {
1846        GDT_DISTANCE_KCL_TEMPLATE
1847            .replace("__UNIT__", unit)
1848            .replace("__TOLERANCE__", tolerance)
1849            .replace("__FRAME_POSITION__", frame_position)
1850    }
1851
1852    fn gdt_flatness_kcl(unit: &str, tolerance: &str, frame_position: &str) -> String {
1853        GDT_FLATNESS_KCL_TEMPLATE
1854            .replace("__UNIT__", unit)
1855            .replace("__TOLERANCE__", tolerance)
1856            .replace("__FRAME_POSITION__", frame_position)
1857    }
1858
1859    async fn gdt_commands(code: &str) -> Vec<ModelingCmd> {
1860        let result = parse_execute(code).await.unwrap();
1861        result
1862            .root_module_artifact_commands()
1863            .iter()
1864            .map(|artifact_command| artifact_command.command.clone())
1865            .collect()
1866    }
1867
1868    fn annotation_options(command: &ModelingCmd) -> Result<&AnnotationOptions, KclError> {
1869        let ModelingCmd::NewAnnotation(new_annotation) = command else {
1870            return Err(KclError::new_internal(KclErrorDetails::new(
1871                format!("expected new_annotation command, got {command:?}"),
1872                vec![SourceRange::default()],
1873            )));
1874        };
1875        Ok(&new_annotation.options)
1876    }
1877
1878    fn feature_control(command: &ModelingCmd) -> Result<&AnnotationFeatureControl, KclError> {
1879        let ModelingCmd::NewAnnotation(new_annotation) = command else {
1880            return Err(KclError::new_internal(KclErrorDetails::new(
1881                format!("expected new_annotation command, got {command:?}"),
1882                vec![SourceRange::default()],
1883            )));
1884        };
1885        new_annotation.options.feature_control.as_ref().ok_or_else(|| {
1886            KclError::new_internal(KclErrorDetails::new(
1887                "expected new_annotation command to have a feature_control".to_owned(),
1888                vec![SourceRange::default()],
1889            ))
1890        })
1891    }
1892
1893    fn find_control_frame_with_symbol(
1894        commands: &[ModelingCmd],
1895        symbol: MbdSymbol,
1896    ) -> Result<&AnnotationMbdControlFrame, KclError> {
1897        for command in commands {
1898            if let Ok(feature_control) = feature_control(command)
1899                && let Some(control_frame) = feature_control.control_frame.as_ref()
1900                && control_frame.symbol == symbol
1901            {
1902                return Ok(control_frame);
1903            }
1904        }
1905
1906        Err(KclError::new_internal(KclErrorDetails::new(
1907            format!("expected commands to contain a {symbol:?} control frame"),
1908            vec![SourceRange::default()],
1909        )))
1910    }
1911
1912    #[track_caller]
1913    fn assert_close(actual: f64, expected: f64) {
1914        assert!((actual - expected).abs() < 1e-6, "expected {expected}, got {actual}");
1915    }
1916
1917    fn new_annotation_command_index(commands: &[ModelingCmd]) -> Result<usize, KclError> {
1918        commands
1919            .iter()
1920            .position(|command| matches!(command, ModelingCmd::NewAnnotation(_)))
1921            .ok_or_else(|| {
1922                KclError::new_internal(KclErrorDetails::new(
1923                    "expected commands to contain a new_annotation command".to_owned(),
1924                    vec![SourceRange::default()],
1925                ))
1926            })
1927    }
1928
1929    #[tokio::test(flavor = "multi_thread")]
1930    async fn gdt_annotation_name_comes_from_explicit_argument() -> Result<(), KclError> {
1931        let unbound_code = gdt_flatness_kcl("mm", "0.01mm", "[10, -10]");
1932        let unbound_commands = gdt_commands(&unbound_code).await;
1933        let unbound_index = new_annotation_command_index(&unbound_commands)?;
1934        assert_eq!(annotation_options(&unbound_commands[unbound_index])?.name, None);
1935
1936        let assigned_code = unbound_code.replacen("gdt::flatness(", "topFlatness = gdt::flatness(", 1);
1937        let assigned_commands = gdt_commands(&assigned_code).await;
1938        let assigned_index = new_annotation_command_index(&assigned_commands)?;
1939        assert_eq!(annotation_options(&assigned_commands[assigned_index])?.name, None);
1940
1941        let named_code = unbound_code.replacen(
1942            "gdt::flatness(\n",
1943            "gdt::flatness(\n  annotationName = \"topFlatness\",\n",
1944            1,
1945        );
1946        let named_commands = gdt_commands(&named_code).await;
1947        let named_index = new_annotation_command_index(&named_commands)?;
1948        assert_eq!(
1949            annotation_options(&named_commands[named_index])?.name.as_deref(),
1950            Some("topFlatness")
1951        );
1952
1953        Ok(())
1954    }
1955
1956    #[test]
1957    fn gdt_font_scale_is_scene_height_divided_by_calibration_height() {
1958        let scale_at_calibrated_height = gdt_font_scale_for_height_mm(GDT_FONT_SCALE_1_HEIGHT_MM);
1959        assert!((scale_at_calibrated_height - 1.0).abs() < f32::EPSILON);
1960
1961        let double_height_scale = gdt_font_scale_for_height_mm(GDT_FONT_SCALE_1_HEIGHT_MM * 2.0);
1962        assert!((double_height_scale - 2.0).abs() < f32::EPSILON);
1963
1964        let inch_in_mm = 25.4;
1965        let inch_scale = gdt_font_scale_for_height_mm(inch_in_mm);
1966        assert!((inch_scale - (inch_in_mm / GDT_FONT_SCALE_1_HEIGHT_MM) as f32).abs() < f32::EPSILON);
1967    }
1968
1969    const GDT_FLATNESS_LEADER_KCL_TEMPLATE: &str = r#"
1970@settings(defaultLengthUnit = mm, kclVersion = 2)
1971
1972blockProfile = sketch(on = XY) {
1973  edge1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
1974  edge2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
1975  edge3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
1976  edge4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
1977  coincident([edge1.end, edge2.start])
1978  coincident([edge2.end, edge3.start])
1979  coincident([edge3.end, edge4.start])
1980  coincident([edge4.end, edge1.start])
1981  parallel([edge2, edge4])
1982  parallel([edge3, edge1])
1983  perpendicular([edge1, edge2])
1984  horizontal(edge3)
1985}
1986
1987region001 = region(point = [5mm, 5mm], sketch = blockProfile)
1988extrude001 = extrude(region001, length = 10mm, tagEnd = $top)
1989gdt::flatness(
1990  faces = [top],
1991  tolerance = 0.1mm,
1992  framePosition = [10mm, 0mm],
1993  framePlane = XZ,
1994  fontSize = __FONT_SIZE__
1995  __LEADER_SCALE__
1996)
1997"#;
1998
1999    fn gdt_flatness_leader_kcl(font_size: &str, leader_scale: Option<&str>) -> String {
2000        GDT_FLATNESS_LEADER_KCL_TEMPLATE
2001            .replace("__FONT_SIZE__", font_size)
2002            .replace(
2003                "__LEADER_SCALE__",
2004                leader_scale
2005                    .map(|scale| format!(",\n  leaderScale = {scale}"))
2006                    .unwrap_or_default()
2007                    .as_str(),
2008            )
2009    }
2010
2011    async fn gdt_flatness_feature_control(
2012        font_size: &str,
2013        leader_scale: Option<&str>,
2014    ) -> Result<AnnotationFeatureControl, KclError> {
2015        let code = gdt_flatness_leader_kcl(font_size, leader_scale);
2016        let commands = gdt_commands(&code).await;
2017        let annotation_index = new_annotation_command_index(&commands)?;
2018        Ok(feature_control(&commands[annotation_index])?.clone())
2019    }
2020
2021    #[tokio::test(flavor = "multi_thread")]
2022    async fn gdt_dot_leader_scale_is_normalized_against_font_scale() -> Result<(), KclError> {
2023        let tiny = gdt_flatness_feature_control("1mm", None).await?;
2024        let large = gdt_flatness_feature_control("100mm", None).await?;
2025
2026        assert_close(f64::from(tiny.font_scale), gdt_font_scale_for_height_mm(1.0).into());
2027        assert_close(f64::from(large.font_scale), gdt_font_scale_for_height_mm(100.0).into());
2028        assert_close(f64::from(tiny.leader_scale), 50.0);
2029        assert_close(f64::from(large.leader_scale), 0.5);
2030
2031        assert_close(
2032            f64::from(tiny.font_scale) * f64::from(tiny.leader_scale),
2033            f64::from(gdt_dot_leader_normal_size()),
2034        );
2035        assert_close(
2036            f64::from(large.font_scale) * f64::from(large.leader_scale),
2037            f64::from(gdt_dot_leader_normal_size()),
2038        );
2039        Ok(())
2040    }
2041
2042    #[tokio::test(flavor = "multi_thread")]
2043    async fn explicit_gdt_dot_leader_scale_multiplies_normal_size() -> Result<(), KclError> {
2044        let tiny = gdt_flatness_feature_control("1mm", Some("2")).await?;
2045        let large = gdt_flatness_feature_control("100mm", Some("2")).await?;
2046
2047        let expected_scaled_dot_size = f64::from(gdt_dot_leader_normal_size()) * 2.0;
2048        assert_close(
2049            f64::from(tiny.font_scale) * f64::from(tiny.leader_scale),
2050            expected_scaled_dot_size,
2051        );
2052        assert_close(
2053            f64::from(large.font_scale) * f64::from(large.leader_scale),
2054            expected_scaled_dot_size,
2055        );
2056        Ok(())
2057    }
2058
2059    #[tokio::test(flavor = "multi_thread")]
2060    async fn gdt_flatness_uses_scene_units_for_control_frame_tolerance() -> Result<(), KclError> {
2061        let cases = [
2062            ("in", "0.1in", "[10, -10]", 0.1, 254.0, -254.0),
2063            ("cm", "10mm", "[1, -1]", 1.0, 10.0, -10.0),
2064        ];
2065
2066        for (default_unit, tolerance, frame_position, expected_tolerance, expected_x, expected_y) in cases {
2067            let code = gdt_flatness_kcl(default_unit, tolerance, frame_position);
2068            let commands = gdt_commands(&code).await;
2069            let annotation_index = new_annotation_command_index(&commands)?;
2070            let feature_control = feature_control(&commands[annotation_index])?;
2071            let control_frame = feature_control.control_frame.as_ref().ok_or_else(|| {
2072                KclError::new_internal(KclErrorDetails::new(
2073                    "expected feature_control to have a control_frame".to_owned(),
2074                    vec![SourceRange::default()],
2075                ))
2076            })?;
2077
2078            assert_close(control_frame.tolerance, expected_tolerance);
2079            assert_close(feature_control.offset.x, expected_x);
2080            assert_close(feature_control.offset.y, expected_y);
2081            assert_close(
2082                f64::from(feature_control.font_scale),
2083                gdt_font_scale_for_height_mm(50.8).into(),
2084            );
2085        }
2086        Ok(())
2087    }
2088
2089    #[tokio::test(flavor = "multi_thread")]
2090    async fn gdt_distance_sets_units() -> Result<(), KclError> {
2091        let cases = [
2092            (
2093                "in",
2094                "2.54mm",
2095                "[10, -10]",
2096                kcmc::units::UnitLength::Inches,
2097                0.1,
2098                254.0,
2099                -254.0,
2100            ),
2101            (
2102                "cm",
2103                "10mm",
2104                "[1, -1]",
2105                kcmc::units::UnitLength::Centimeters,
2106                1.0,
2107                10.0,
2108                -10.0,
2109            ),
2110            (
2111                "mm",
2112                "2.54mm",
2113                "[10, -10]",
2114                kcmc::units::UnitLength::Millimeters,
2115                2.54,
2116                10.0,
2117                -10.0,
2118            ),
2119        ];
2120
2121        for (default_unit, tolerance, frame_position, scene_unit, expected_tolerance, expected_x, expected_y) in cases {
2122            let code = gdt_distance_kcl(default_unit, tolerance, frame_position);
2123            let commands = gdt_commands(&code).await;
2124            let annotation_index = new_annotation_command_index(&commands)?;
2125            let options = annotation_options(&commands[annotation_index])?;
2126
2127            assert_eq!(options.units, Some(scene_unit));
2128
2129            let dimension = options
2130                .dimension
2131                .as_ref()
2132                .expect("expected new_annotation command to have a dimension");
2133            assert_close(dimension.dimension.tolerance.unwrap(), expected_tolerance);
2134            assert_close(dimension.offset.x, expected_x);
2135            assert_close(dimension.offset.y, expected_y);
2136            assert_close(
2137                f64::from(dimension.font_scale),
2138                gdt_font_scale_for_height_mm(50.8).into(),
2139            );
2140        }
2141        Ok(())
2142    }
2143
2144    const GDT_FACE_API_EDGE_KCL_TEMPLATE: &str = r#"
2145@settings(defaultLengthUnit = mm, kclVersion = 2)
2146
2147sketch001 = sketch(on = XY) {
2148  line1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
2149  line2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
2150  line3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
2151  line4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
2152  coincident([line1.end, line2.start])
2153  coincident([line2.end, line3.start])
2154  coincident([line3.end, line4.start])
2155  coincident([line4.end, line1.start])
2156  parallel([line2, line4])
2157  parallel([line3, line1])
2158  perpendicular([line1, line2])
2159  horizontal(line3)
2160}
2161
2162region001 = region(point = [5mm, 5mm], sketch = sketch001)
2163extrude001 = extrude(region001, length = 10mm, tagStart = $capStart001)
2164__GDT_CALL__
2165"#;
2166
2167    fn gdt_face_api_edge_kcl(gdt_call: &str) -> String {
2168        GDT_FACE_API_EDGE_KCL_TEMPLATE.replace("__GDT_CALL__", gdt_call)
2169    }
2170
2171    fn assert_feature_control_uses_edge_reference(feature_control: &AnnotationFeatureControl) {
2172        assert!(feature_control.entity_id.is_none());
2173        let edge_reference = feature_control
2174            .edge_reference
2175            .as_ref()
2176            .expect("expected face API edge specifier to emit edge_reference");
2177        assert_eq!(edge_reference.side_faces.len(), 2);
2178        assert!(edge_reference.end_faces.is_empty());
2179        assert_eq!(edge_reference.index, None);
2180    }
2181
2182    #[tokio::test(flavor = "multi_thread")]
2183    async fn gdt_straightness_accepts_face_api_edge_specifier() -> Result<(), KclError> {
2184        let code = gdt_face_api_edge_kcl(
2185            r#"gdt::straightness(
2186  edges = [
2187    {
2188      sideFaces = [region001.tags.line1, capStart001]
2189    }
2190  ],
2191  tolerance = 0.1mm,
2192  framePosition = [12mm, 8mm],
2193  framePlane = XZ,
2194)"#,
2195        );
2196        let commands = gdt_commands(&code).await;
2197        let annotation_index = new_annotation_command_index(&commands)?;
2198        let feature_control = feature_control(&commands[annotation_index])?;
2199        assert_feature_control_uses_edge_reference(feature_control);
2200        assert!(feature_control.control_frame.is_some());
2201        Ok(())
2202    }
2203
2204    #[tokio::test(flavor = "multi_thread")]
2205    async fn gdt_annotation_accepts_face_api_edge_specifier() -> Result<(), KclError> {
2206        let code = gdt_face_api_edge_kcl(
2207            r#"gdt::annotation(
2208  annotation = "A",
2209  edges = [
2210    {
2211      sideFaces = [region001.tags.line1, capStart001]
2212    }
2213  ],
2214  framePosition = [12mm, 8mm],
2215  framePlane = XZ,
2216)"#,
2217        );
2218        let commands = gdt_commands(&code).await;
2219        let annotation_index = new_annotation_command_index(&commands)?;
2220        let feature_control = feature_control(&commands[annotation_index])?;
2221        assert_feature_control_uses_edge_reference(feature_control);
2222        assert_eq!(feature_control.prefix.as_deref(), Some("A"));
2223        Ok(())
2224    }
2225
2226    #[tokio::test(flavor = "multi_thread")]
2227    async fn gdt_distance_accepts_face_api_edge_specifier() -> Result<(), KclError> {
2228        let code = gdt_face_api_edge_kcl(
2229            r#"gdt::distance(
2230  edges = [
2231    {
2232      sideFaces = [region001.tags.line1, capStart001]
2233    }
2234  ],
2235  tolerance = 0.1mm,
2236  framePosition = [12mm, 8mm],
2237  framePlane = XZ,
2238)"#,
2239        );
2240        let commands = gdt_commands(&code).await;
2241        let annotation_index = new_annotation_command_index(&commands)?;
2242        let options = annotation_options(&commands[annotation_index])?;
2243        let dimension = options
2244            .dimension
2245            .as_ref()
2246            .expect("expected new_annotation command to have a dimension");
2247        assert!(dimension.from_entity_id.is_none());
2248        assert!(dimension.to_entity_id.is_none());
2249        assert_eq!(
2250            dimension
2251                .from_edge_reference
2252                .as_ref()
2253                .expect("expected from_edge_reference")
2254                .side_faces
2255                .len(),
2256            2
2257        );
2258        assert_eq!(
2259            dimension
2260                .to_edge_reference
2261                .as_ref()
2262                .expect("expected to_edge_reference")
2263                .side_faces
2264                .len(),
2265            2
2266        );
2267        Ok(())
2268    }
2269
2270    #[tokio::test(flavor = "multi_thread")]
2271    async fn gdt_distance_from_to_accept_face_api_edge_specifiers() -> Result<(), KclError> {
2272        let code = gdt_face_api_edge_kcl(
2273            r#"gdt::distance(
2274  from = {
2275    sideFaces = [region001.tags.line1, capStart001]
2276  },
2277  to = {
2278    sideFaces = [region001.tags.line3, capStart001]
2279  },
2280  tolerance = 0.1mm,
2281  framePosition = [12mm, 8mm],
2282  framePlane = XZ,
2283)"#,
2284        );
2285        let commands = gdt_commands(&code).await;
2286        let annotation_index = new_annotation_command_index(&commands)?;
2287        let options = annotation_options(&commands[annotation_index])?;
2288        let dimension = options
2289            .dimension
2290            .as_ref()
2291            .expect("expected new_annotation command to have a dimension");
2292        assert!(dimension.from_entity_id.is_none());
2293        assert!(dimension.to_entity_id.is_none());
2294        assert_eq!(
2295            dimension
2296                .from_edge_reference
2297                .as_ref()
2298                .expect("expected from_edge_reference")
2299                .side_faces
2300                .len(),
2301            2
2302        );
2303        assert_eq!(
2304            dimension
2305                .to_edge_reference
2306                .as_ref()
2307                .expect("expected to_edge_reference")
2308                .side_faces
2309                .len(),
2310            2
2311        );
2312        Ok(())
2313    }
2314
2315    const GDT_DATUM_KCL: &str = r#"
2316blockProfile = sketch(on = XY) {
2317  edge1 = line(start = [var 0mm, var 0mm], end = [var 8mm, var 0mm])
2318  edge2 = line(start = [var 8mm, var 0mm], end = [var 8mm, var 5mm])
2319  edge3 = line(start = [var 8mm, var 5mm], end = [var 0mm, var 5mm])
2320  edge4 = line(start = [var 0mm, var 5mm], end = [var 0mm, var 0mm])
2321  coincident([edge1.end, edge2.start])
2322  coincident([edge2.end, edge3.start])
2323  coincident([edge3.end, edge4.start])
2324  coincident([edge4.end, edge1.start])
2325  horizontal(edge1)
2326  vertical(edge2)
2327  horizontal(edge3)
2328  vertical(edge4)
2329}
2330
2331block = extrude(region(point = [4mm, 2mm], sketch = blockProfile), length = 4mm, tagEnd = $top)
2332
2333gdt::datum(face = top, name = "A", framePosition = [10mm, 0mm], framePlane = XZ)
2334"#;
2335
2336    async fn gdt_artifact_count(skip_artifact_graph: bool) -> usize {
2337        let settings = ExecutorSettings {
2338            skip_artifact_graph,
2339            ..Default::default()
2340        };
2341        let ctx = ExecutorContext::new_mock(Some(settings)).await;
2342        let program = crate::Program::parse_no_errs(GDT_DATUM_KCL).unwrap();
2343        let mock_config = MockConfig {
2344            use_prev_memory: false,
2345            ..Default::default()
2346        };
2347        let outcome = ctx.run_mock(&program, &mock_config).await.unwrap();
2348        ctx.close().await;
2349
2350        outcome
2351            .artifact_graph
2352            .values()
2353            .filter(|artifact| matches!(artifact, Artifact::GdtAnnotation(_)))
2354            .count()
2355    }
2356
2357    #[tokio::test(flavor = "multi_thread")]
2358    async fn gdt_annotations_do_not_follow_runtime_artifact_graph_setting() {
2359        assert_eq!(gdt_artifact_count(false).await, 1);
2360        assert_eq!(gdt_artifact_count(true).await, 1);
2361    }
2362
2363    const GDT_ANGULARITY_FACE_KCL: &str = r#"
2364@settings(defaultLengthUnit = mm, kclVersion = 2)
2365
2366basicAngle = 30deg
2367thickness = 3.5mm
2368flangeLength = 24mm
2369bendStartX = 5mm
2370legLength = 30mm
2371legRun = legLength * cos(basicAngle)
2372legRise = legLength * sin(basicAngle)
2373normalRun = thickness * sin(basicAngle)
2374normalRise = thickness * cos(basicAngle)
2375annotationFont = 2mm
2376
2377stampedProfile = sketch(on = XY) {
2378  datumFace = line(start = [var 0mm, var 0mm], end = [var 24mm, var 0mm])
2379  flangeEnd = line(start = [var 24mm, var 0mm], end = [var 24mm, var 3.5mm])
2380  innerFlange = line(start = [var 24mm, var 3.5mm], end = [var 5mm, var 3.5mm])
2381  controlledSurface = line(start = [var 5mm, var 3.5mm], end = [var 30.98mm, var 18.5mm])
2382  tabEnd = line(start = [var 30.98mm, var 18.5mm], end = [var 29.23mm, var 21.53mm])
2383  outerSurface = line(start = [var 29.23mm, var 21.53mm], end = [var 3.25mm, var 6.53mm])
2384  outsideBend = line(start = [var 3.25mm, var 6.53mm], end = [var 0mm, var 0mm])
2385  coincident([datumFace.end, flangeEnd.start])
2386  coincident([flangeEnd.end, innerFlange.start])
2387  coincident([innerFlange.end, controlledSurface.start])
2388  coincident([controlledSurface.end, tabEnd.start])
2389  coincident([tabEnd.end, outerSurface.start])
2390  coincident([outerSurface.end, outsideBend.start])
2391  coincident([outsideBend.end, datumFace.start])
2392  coincident([datumFace.start, ORIGIN])
2393  horizontal(datumFace)
2394  horizontal(innerFlange)
2395  vertical(flangeEnd)
2396  distance([datumFace.start, datumFace.end]) == flangeLength
2397  distance([flangeEnd.start, flangeEnd.end]) == thickness
2398  distance([innerFlange.start, innerFlange.end]) == flangeLength - bendStartX
2399  distance([controlledSurface.start, controlledSurface.end]) == legLength
2400  distance([tabEnd.start, tabEnd.end]) == thickness
2401  distance([outerSurface.start, outerSurface.end]) == legLength
2402  parallel([controlledSurface, outerSurface])
2403  perpendicular([controlledSurface, tabEnd])
2404  angle([datumFace, controlledSurface]) == basicAngle
2405}
2406
2407stampedPart = extrude(region(point = [12mm, 2mm], sketch = stampedProfile), length = 0.8mm)
2408
2409gdt::datum(face = stampedPart.sketch.tags.datumFace, name = "A", framePosition = [6mm, -4mm], framePlane = XY, fontSize = annotationFont)
2410gdt::angularity(faces = [stampedPart.sketch.tags.controlledSurface], tolerance = 0.1mm, datums = ["A"], framePosition = [-12mm, 11mm], framePlane = XZ, fontSize = annotationFont)
2411"#;
2412
2413    const GDT_ANGULARITY_EDGE_KCL: &str = r#"
2414@settings(defaultLengthUnit = mm, kclVersion = 2)
2415
2416basicAngle = 30deg
2417thickness = 3.5mm
2418flangeLength = 24mm
2419bendStartX = 5mm
2420legLength = 30mm
2421legRun = legLength * cos(basicAngle)
2422legRise = legLength * sin(basicAngle)
2423normalRun = thickness * sin(basicAngle)
2424normalRise = thickness * cos(basicAngle)
2425annotationFont = 2mm
2426
2427stampedProfile = sketch(on = XY) {
2428  datumFace = line(start = [var 0mm, var 0mm], end = [var 24mm, var 0mm])
2429  flangeEnd = line(start = [var 24mm, var 0mm], end = [var 24mm, var 3.5mm])
2430  innerFlange = line(start = [var 24mm, var 3.5mm], end = [var 5mm, var 3.5mm])
2431  controlledSurface = line(start = [var 5mm, var 3.5mm], end = [var 30.98mm, var 18.5mm])
2432  tabEnd = line(start = [var 30.98mm, var 18.5mm], end = [var 29.23mm, var 21.53mm])
2433  outerSurface = line(start = [var 29.23mm, var 21.53mm], end = [var 3.25mm, var 6.53mm])
2434  outsideBend = line(start = [var 3.25mm, var 6.53mm], end = [var 0mm, var 0mm])
2435  coincident([datumFace.end, flangeEnd.start])
2436  coincident([flangeEnd.end, innerFlange.start])
2437  coincident([innerFlange.end, controlledSurface.start])
2438  coincident([controlledSurface.end, tabEnd.start])
2439  coincident([tabEnd.end, outerSurface.start])
2440  coincident([outerSurface.end, outsideBend.start])
2441  coincident([outsideBend.end, datumFace.start])
2442  coincident([datumFace.start, ORIGIN])
2443  horizontal(datumFace)
2444  horizontal(innerFlange)
2445  vertical(flangeEnd)
2446  distance([datumFace.start, datumFace.end]) == flangeLength
2447  distance([flangeEnd.start, flangeEnd.end]) == thickness
2448  distance([innerFlange.start, innerFlange.end]) == flangeLength - bendStartX
2449  distance([controlledSurface.start, controlledSurface.end]) == legLength
2450  distance([tabEnd.start, tabEnd.end]) == thickness
2451  distance([outerSurface.start, outerSurface.end]) == legLength
2452  parallel([controlledSurface, outerSurface])
2453  perpendicular([controlledSurface, tabEnd])
2454  angle([datumFace, controlledSurface]) == basicAngle
2455}
2456
2457stampedRegion = region(point = [12mm, 2mm], sketch = stampedProfile)
2458hide(stampedProfile)
2459stampedPart = extrude(stampedRegion, length = 0.8mm)
2460
2461gdt::datum(face = stampedPart.sketch.tags.datumFace, name = "A", framePosition = [6mm, -4mm], framePlane = XY, fontSize = annotationFont)
2462gdt::angularity(edges = [stampedRegion.tags.controlledSurface], tolerance = 0.1mm, datums = ["A"], framePosition = [-12mm, 11mm], framePlane = XZ, fontSize = annotationFont)
2463"#;
2464
2465    #[tokio::test(flavor = "multi_thread")]
2466    async fn gdt_angularity_uses_angularity_symbol_with_datums() -> Result<(), KclError> {
2467        let cases = [
2468            ("angled face", GDT_ANGULARITY_FACE_KCL, 0.1),
2469            ("angled edge", GDT_ANGULARITY_EDGE_KCL, 0.1),
2470        ];
2471
2472        for (label, code, expected_tolerance) in cases {
2473            let commands = gdt_commands(code).await;
2474            let control_frame = find_control_frame_with_symbol(&commands, MbdSymbol::Angularity)?;
2475
2476            assert_close(control_frame.tolerance, expected_tolerance);
2477            assert_eq!(control_frame.primary_datum, Some('A'), "case: {label}");
2478            assert!(control_frame.secondary_datum.is_none(), "case: {label}");
2479            assert!(control_frame.tertiary_datum.is_none(), "case: {label}");
2480        }
2481        Ok(())
2482    }
2483
2484    const GDT_PROFILE_LINE_KCL: &str = r#"
2485@settings(defaultLengthUnit = mm, kclVersion = 2)
2486
2487blockProfile = sketch(on = XY) {
2488  edge1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
2489  edge2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 6mm])
2490  edge3 = line(start = [var 10mm, var 6mm], end = [var 0mm, var 6mm])
2491  edge4 = line(start = [var 0mm, var 6mm], end = [var 0mm, var 0mm])
2492  coincident([edge1.end, edge2.start])
2493  coincident([edge2.end, edge3.start])
2494  coincident([edge3.end, edge4.start])
2495  coincident([edge4.end, edge1.start])
2496  horizontal(edge1)
2497  vertical(edge2)
2498  horizontal(edge3)
2499  vertical(edge4)
2500}
2501
2502block = extrude(region(point = [5mm, 3mm], sketch = blockProfile), length = 4mm, tagEnd = $top)
2503profileEdge = getCommonEdge(faces = [block.sketch.tags.edge1, top])
2504gdt::profileLine(edges = [profileEdge], tolerance = 0.05mm, framePosition = [12mm, 8mm], framePlane = XZ)
2505"#;
2506
2507    const GDT_PROFILE_GENERIC_LINE_KCL: &str = r#"
2508@settings(defaultLengthUnit = mm, kclVersion = 2)
2509
2510blockProfile = sketch(on = XY) {
2511  edge1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
2512  edge2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 6mm])
2513  edge3 = line(start = [var 10mm, var 6mm], end = [var 0mm, var 6mm])
2514  edge4 = line(start = [var 0mm, var 6mm], end = [var 0mm, var 0mm])
2515  coincident([edge1.end, edge2.start])
2516  coincident([edge2.end, edge3.start])
2517  coincident([edge3.end, edge4.start])
2518  coincident([edge4.end, edge1.start])
2519  horizontal(edge1)
2520  vertical(edge2)
2521  horizontal(edge3)
2522  vertical(edge4)
2523}
2524
2525block = extrude(region(point = [5mm, 3mm], sketch = blockProfile), length = 4mm, tagEnd = $top)
2526profileEdge = getCommonEdge(faces = [block.sketch.tags.edge1, top])
2527gdt::profile(edges = [profileEdge], tolerance = 0.05mm, framePosition = [12mm, 8mm], framePlane = XZ)
2528"#;
2529
2530    const GDT_PROFILE_SURFACE_KCL: &str = r#"
2531@settings(defaultLengthUnit = mm, kclVersion = 2)
2532
2533cylinderSketch = sketch(on = XY) {
2534  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2535}
2536
2537cylinder = extrude(region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch), length = 10mm, tagEnd = $top)
2538gdt::profileSurface(faces = [top], tolerance = 0.05mm, framePosition = [12mm, 8mm], framePlane = XZ)
2539"#;
2540
2541    const GDT_PROFILE_GENERIC_SURFACE_KCL: &str = r#"
2542@settings(defaultLengthUnit = mm, kclVersion = 2)
2543
2544cylinderSketch = sketch(on = XY) {
2545  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2546}
2547
2548cylinder = extrude(region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch), length = 10mm, tagEnd = $top)
2549gdt::profile(faces = [top], tolerance = 0.05mm, framePosition = [12mm, 8mm], framePlane = XZ)
2550"#;
2551
2552    const GDT_PROFILE_BOTH_KCL: &str = r#"
2553@settings(defaultLengthUnit = mm, kclVersion = 2)
2554
2555blockProfile = sketch(on = XY) {
2556  edge1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
2557  edge2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 6mm])
2558  edge3 = line(start = [var 10mm, var 6mm], end = [var 0mm, var 6mm])
2559  edge4 = line(start = [var 0mm, var 6mm], end = [var 0mm, var 0mm])
2560  coincident([edge1.end, edge2.start])
2561  coincident([edge2.end, edge3.start])
2562  coincident([edge3.end, edge4.start])
2563  coincident([edge4.end, edge1.start])
2564  horizontal(edge1)
2565  vertical(edge2)
2566  horizontal(edge3)
2567  vertical(edge4)
2568}
2569
2570block = extrude(region(point = [5mm, 3mm], sketch = blockProfile), length = 4mm, tagEnd = $top)
2571profileEdge = getCommonEdge(faces = [block.sketch.tags.edge1, top])
2572gdt::profile(edges = [profileEdge], faces = [top], tolerance = 0.05mm)
2573"#;
2574
2575    const GDT_PROFILE_MISSING_ENTITIES_KCL: &str = r#"
2576@settings(defaultLengthUnit = mm, kclVersion = 2)
2577
2578gdt::profile(tolerance = 0.05mm)
2579"#;
2580
2581    #[tokio::test(flavor = "multi_thread")]
2582    async fn gdt_profile_line_uses_profile_of_line_symbol() -> Result<(), KclError> {
2583        let cases = [
2584            ("specific profileLine", GDT_PROFILE_LINE_KCL),
2585            ("generic profile with edges", GDT_PROFILE_GENERIC_LINE_KCL),
2586        ];
2587
2588        for (label, code) in cases {
2589            let commands = gdt_commands(code).await;
2590            let control_frame = find_control_frame_with_symbol(&commands, MbdSymbol::ProfileOfLine)?;
2591
2592            assert_close(control_frame.tolerance, 0.05);
2593            assert!(control_frame.primary_datum.is_none(), "case: {label}");
2594            assert!(control_frame.secondary_datum.is_none(), "case: {label}");
2595            assert!(control_frame.tertiary_datum.is_none(), "case: {label}");
2596        }
2597        Ok(())
2598    }
2599
2600    #[tokio::test(flavor = "multi_thread")]
2601    async fn gdt_profile_surface_uses_surface_profile_symbol() -> Result<(), KclError> {
2602        let cases = [
2603            ("specific profileSurface", GDT_PROFILE_SURFACE_KCL),
2604            ("generic profile with faces", GDT_PROFILE_GENERIC_SURFACE_KCL),
2605        ];
2606
2607        for (label, code) in cases {
2608            let commands = gdt_commands(code).await;
2609            let control_frame = find_control_frame_with_symbol(&commands, MbdSymbol::SurfaceProfile)?;
2610
2611            assert_close(control_frame.tolerance, 0.05);
2612            assert!(control_frame.primary_datum.is_none(), "case: {label}");
2613            assert!(control_frame.secondary_datum.is_none(), "case: {label}");
2614            assert!(control_frame.tertiary_datum.is_none(), "case: {label}");
2615        }
2616        Ok(())
2617    }
2618
2619    #[tokio::test(flavor = "multi_thread")]
2620    async fn gdt_profile_requires_edges_or_faces() {
2621        assert_eq!(
2622            parse_execute(GDT_PROFILE_MISSING_ENTITIES_KCL)
2623                .await
2624                .unwrap_err()
2625                .message(),
2626            "Profile requires either `edges` for `profileLine` or `faces` for `profileSurface`.",
2627        );
2628    }
2629
2630    #[tokio::test(flavor = "multi_thread")]
2631    async fn gdt_profile_rejects_combined_edges_and_faces() {
2632        assert_eq!(
2633            parse_execute(GDT_PROFILE_BOTH_KCL).await.unwrap_err().message(),
2634            "Profile cannot combine `edges` and `faces`. Use `profileLine` for edges or `profileSurface` for faces.",
2635        );
2636    }
2637
2638    // Mirrors the gdt::circularity doc examples: annotate a cylinder's circular
2639    // edge and its curved wall. Runs in mock mode, so it validates parsing, name
2640    // resolution, and that the control frame uses the Roundness (circularity)
2641    // symbol without datums. The doc examples additionally render against the
2642    // engine in kcl_test_examples.
2643    const GDT_CIRCULARITY_EDGE_KCL: &str = r#"
2644@settings(defaultLengthUnit = mm, kclVersion = 2)
2645
2646cylinderSketch = sketch(on = XY) {
2647  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2648}
2649
2650cylinderRegion = region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch)
2651hide(cylinderSketch)
2652cylinder = extrude(cylinderRegion, length = 10mm)
2653gdt::circularity(edges = [cylinderRegion.tags.perimeter], tolerance = 0.05mm)
2654"#;
2655
2656    const GDT_CIRCULARITY_WALL_KCL: &str = r#"
2657@settings(defaultLengthUnit = mm, kclVersion = 2)
2658
2659cylinderSketch = sketch(on = XY) {
2660  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2661}
2662
2663cylinder = extrude(region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch), length = 10mm)
2664gdt::circularity(faces = [cylinder.sketch.tags.perimeter], tolerance = 0.02mm, framePosition = [12mm, 8mm], framePlane = XZ)
2665"#;
2666
2667    const GDT_CIRCULARITY_COMMON_EDGE_KCL: &str = r#"
2668@settings(defaultLengthUnit = mm, kclVersion = 2)
2669
2670cylinderSketch = sketch(on = XY) {
2671  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2672}
2673
2674cylinder = extrude(region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch), length = 10mm, tagEnd = $top)
2675topEdge = getCommonEdge(faces = [cylinder.sketch.tags.perimeter, top])
2676gdt::circularity(edges = [topEdge], tolerance = 0.05mm, framePosition = [12mm, 8mm], framePlane = XZ)
2677"#;
2678
2679    #[tokio::test(flavor = "multi_thread")]
2680    async fn gdt_circularity_uses_roundness_symbol_without_datums() -> Result<(), KclError> {
2681        let cases = [
2682            ("circular edge", GDT_CIRCULARITY_EDGE_KCL, 0.05),
2683            ("cylinder wall", GDT_CIRCULARITY_WALL_KCL, 0.02),
2684            ("common edge", GDT_CIRCULARITY_COMMON_EDGE_KCL, 0.05),
2685        ];
2686
2687        for (label, code, expected_tolerance) in cases {
2688            let commands = gdt_commands(code).await;
2689            let annotation_index = new_annotation_command_index(&commands)?;
2690            let feature_control = feature_control(&commands[annotation_index])?;
2691            let control_frame = feature_control.control_frame.as_ref().ok_or_else(|| {
2692                KclError::new_internal(KclErrorDetails::new(
2693                    format!("expected {label} feature_control to have a control_frame"),
2694                    vec![SourceRange::default()],
2695                ))
2696            })?;
2697
2698            assert_eq!(control_frame.symbol, MbdSymbol::Roundness, "case: {label}");
2699            assert_close(control_frame.tolerance, expected_tolerance);
2700            // Circularity is a form tolerance and never references datums.
2701            assert!(control_frame.primary_datum.is_none(), "case: {label}");
2702            assert!(control_frame.secondary_datum.is_none(), "case: {label}");
2703            assert!(control_frame.tertiary_datum.is_none(), "case: {label}");
2704        }
2705        Ok(())
2706    }
2707
2708    // Mirrors the gdt::cylindricity doc examples: annotate a cylinder's curved
2709    // wall and its circular edge. Runs in mock mode, so it validates parsing,
2710    // name resolution, and that the control frame uses the Cylindricity symbol
2711    // without datums. The doc examples additionally render against the engine in
2712    // kcl_test_examples.
2713    const GDT_CYLINDRICITY_WALL_KCL: &str = r#"
2714@settings(defaultLengthUnit = mm, kclVersion = 2)
2715
2716cylinderSketch = sketch(on = XY) {
2717  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2718}
2719
2720cylinder = extrude(region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch), length = 10mm)
2721gdt::cylindricity(faces = [cylinder.sketch.tags.perimeter], tolerance = 0.02mm, framePosition = [-12mm, 8mm], framePlane = XZ)
2722"#;
2723
2724    const GDT_CYLINDRICITY_EDGE_KCL: &str = r#"
2725@settings(defaultLengthUnit = mm, kclVersion = 2)
2726
2727cylinderSketch = sketch(on = XY) {
2728  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2729}
2730
2731cylinderRegion = region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch)
2732hide(cylinderSketch)
2733cylinder = extrude(cylinderRegion, length = 10mm)
2734gdt::cylindricity(edges = [cylinderRegion.tags.perimeter], tolerance = 0.05mm, framePosition = [-12mm, 8mm])
2735"#;
2736
2737    const GDT_CYLINDRICITY_COMMON_EDGE_KCL: &str = r#"
2738@settings(defaultLengthUnit = mm, kclVersion = 2)
2739
2740cylinderSketch = sketch(on = XY) {
2741  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2742}
2743
2744cylinder = extrude(region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch), length = 10mm, tagEnd = $top)
2745topEdge = getCommonEdge(faces = [cylinder.sketch.tags.perimeter, top])
2746gdt::cylindricity(edges = [topEdge], tolerance = 0.05mm, framePosition = [-12mm, 8mm], framePlane = XZ)
2747"#;
2748
2749    #[tokio::test(flavor = "multi_thread")]
2750    async fn gdt_cylindricity_uses_cylindricity_symbol_without_datums() -> Result<(), KclError> {
2751        let cases = [
2752            ("cylinder wall", GDT_CYLINDRICITY_WALL_KCL, 0.02),
2753            ("circular edge", GDT_CYLINDRICITY_EDGE_KCL, 0.05),
2754            ("common edge", GDT_CYLINDRICITY_COMMON_EDGE_KCL, 0.05),
2755        ];
2756
2757        for (label, code, expected_tolerance) in cases {
2758            let commands = gdt_commands(code).await;
2759            let annotation_index = new_annotation_command_index(&commands)?;
2760            let feature_control = feature_control(&commands[annotation_index])?;
2761            let control_frame = feature_control.control_frame.as_ref().ok_or_else(|| {
2762                KclError::new_internal(KclErrorDetails::new(
2763                    format!("expected {label} feature_control to have a control_frame"),
2764                    vec![SourceRange::default()],
2765                ))
2766            })?;
2767
2768            assert_eq!(control_frame.symbol, MbdSymbol::Cylindricity, "case: {label}");
2769            assert_close(control_frame.tolerance, expected_tolerance);
2770            // Cylindricity is a form tolerance and never references datums.
2771            assert!(control_frame.primary_datum.is_none(), "case: {label}");
2772            assert!(control_frame.secondary_datum.is_none(), "case: {label}");
2773            assert!(control_frame.tertiary_datum.is_none(), "case: {label}");
2774        }
2775        Ok(())
2776    }
2777
2778    // Uses the GD&T Basics stepped-shaft example: reference feature B is
2779    // controlled relative to datum feature A. Runs in mock mode, so it validates
2780    // parsing, name resolution, and that the control frame uses the
2781    // Concentricity symbol with a diameter tolerance zone and datum reference.
2782    const GDT_CONCENTRICITY_REFERENCE_FEATURE_B_FACE_KCL: &str = r#"
2783@settings(defaultLengthUnit = mm, kclVersion = 2)
2784
2785datumASketch = sketch(on = XY) {
2786  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2787}
2788
2789datumA = extrude(region(point = datumASketch.perimeter.center, sketch = datumASketch), length = 16mm)
2790
2791referenceFeatureBSketch = sketch(on = XY) {
2792  perimeter = circle(start = [var 2.5mm, var 0mm], center = [var 0mm, var 0mm])
2793}
2794
2795referenceFeatureB = extrude(region(point = referenceFeatureBSketch.perimeter.center, sketch = referenceFeatureBSketch), length = 12mm)
2796  |> translate(z = -12mm)
2797
2798gdt::datum(face = datumA.sketch.tags.perimeter, name = "A", framePosition = [10mm, -12mm], framePlane = XZ)
2799gdt::concentricity(faces = [referenceFeatureB.sketch.tags.perimeter], tolerance = 0.2mm, datums = ["A"], framePosition = [-18mm, 12mm], framePlane = XZ)
2800"#;
2801
2802    const GDT_CONCENTRICITY_REFERENCE_FEATURE_B_EDGE_KCL: &str = r#"
2803@settings(defaultLengthUnit = mm, kclVersion = 2)
2804
2805datumASketch = sketch(on = XY) {
2806  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2807}
2808
2809datumA = extrude(region(point = datumASketch.perimeter.center, sketch = datumASketch), length = 16mm)
2810
2811referenceFeatureBSketch = sketch(on = XY) {
2812  perimeter = circle(start = [var 2.5mm, var 0mm], center = [var 0mm, var 0mm])
2813}
2814
2815referenceFeatureB = extrude(region(point = referenceFeatureBSketch.perimeter.center, sketch = referenceFeatureBSketch), length = 12mm, tagEnd = $endB)
2816  |> translate(z = -12mm)
2817endEdgeB = getCommonEdge(faces = [referenceFeatureB.sketch.tags.perimeter, endB])
2818
2819gdt::datum(face = datumA.sketch.tags.perimeter, name = "A", framePosition = [10mm, -12mm], framePlane = XZ)
2820gdt::concentricity(edges = [endEdgeB], tolerance = 0.2mm, datums = ["A"], framePosition = [-18mm, 12mm], framePlane = XZ)
2821"#;
2822
2823    #[tokio::test(flavor = "multi_thread")]
2824    async fn gdt_concentricity_uses_concentricity_symbol_with_diameter_zone_and_datums() -> Result<(), KclError> {
2825        let cases = [
2826            (
2827                "reference feature B face",
2828                GDT_CONCENTRICITY_REFERENCE_FEATURE_B_FACE_KCL,
2829                0.2,
2830            ),
2831            (
2832                "reference feature B edge",
2833                GDT_CONCENTRICITY_REFERENCE_FEATURE_B_EDGE_KCL,
2834                0.2,
2835            ),
2836        ];
2837
2838        for (label, code, expected_tolerance) in cases {
2839            let commands = gdt_commands(code).await;
2840            let control_frame = find_control_frame_with_symbol(&commands, MbdSymbol::Concentricity)?;
2841
2842            assert_eq!(
2843                control_frame.diameter_symbol,
2844                Some(MbdSymbol::Diameter),
2845                "case: {label}"
2846            );
2847            assert_close(control_frame.tolerance, expected_tolerance);
2848            assert_eq!(control_frame.primary_datum, Some('A'), "case: {label}");
2849            assert!(control_frame.secondary_datum.is_none(), "case: {label}");
2850            assert!(control_frame.tertiary_datum.is_none(), "case: {label}");
2851        }
2852        Ok(())
2853    }
2854
2855    // Models the GD&T Basics latch-block groove example as closely as the
2856    // current datum API allows. Each test annotates one groove floor target
2857    // so KCL emits one symmetry feature control frame.
2858    const GDT_SYMMETRY_LATCH_BLOCK_GROOVE_FACE_KCL: &str = r#"
2859@settings(defaultLengthUnit = mm, kclVersion = 2)
2860
2861latchProfile = sketch(on = XZ) {
2862  bottom = line(start = [var -20mm, var -10mm], end = [var 20mm, var -10mm])
2863  datumWidthFace = line(start = [var 20mm, var -10mm], end = [var 20mm, var 10mm])
2864  topRight = line(start = [var 20mm, var 10mm], end = [var 5mm, var 10mm])
2865  rightGrooveWall = line(start = [var 5mm, var 10mm], end = [var 5mm, var 3mm])
2866  grooveFloor = line(start = [var 5mm, var 3mm], end = [var -5mm, var 3mm])
2867  leftGrooveWall = line(start = [var -5mm, var 3mm], end = [var -5mm, var 10mm])
2868  topLeft = line(start = [var -5mm, var 10mm], end = [var -20mm, var 10mm])
2869  leftSide = line(start = [var -20mm, var 10mm], end = [var -20mm, var -10mm])
2870  coincident([bottom.end, datumWidthFace.start])
2871  coincident([datumWidthFace.end, topRight.start])
2872  coincident([topRight.end, rightGrooveWall.start])
2873  coincident([rightGrooveWall.end, grooveFloor.start])
2874  coincident([grooveFloor.end, leftGrooveWall.start])
2875  coincident([leftGrooveWall.end, topLeft.start])
2876  coincident([topLeft.end, leftSide.start])
2877  coincident([leftSide.end, bottom.start])
2878  horizontal(bottom)
2879  vertical(datumWidthFace)
2880  horizontal(topRight)
2881  vertical(rightGrooveWall)
2882  horizontal(grooveFloor)
2883  vertical(leftGrooveWall)
2884  horizontal(topLeft)
2885  vertical(leftSide)
2886}
2887
2888latchBlockRegion = region(point = [0mm, 0mm], sketch = latchProfile)
2889latchBlock = extrude(latchBlockRegion, length = 12mm)
2890
2891gdt::datum(face = latchBlock.sketch.tags.bottom, name = "A", framePosition = [0mm, -16mm], framePlane = XZ)
2892gdt::symmetry(faces = [latchBlock.sketch.tags.grooveFloor], tolerance = 0.2mm, datums = ["A"], framePosition = [-24mm, 14mm], framePlane = XZ)
2893"#;
2894
2895    const GDT_SYMMETRY_LATCH_BLOCK_GROOVE_EDGE_KCL: &str = r#"
2896@settings(defaultLengthUnit = mm, kclVersion = 2)
2897
2898latchProfile = sketch(on = XZ) {
2899  bottom = line(start = [var -20mm, var -10mm], end = [var 20mm, var -10mm])
2900  datumWidthFace = line(start = [var 20mm, var -10mm], end = [var 20mm, var 10mm])
2901  topRight = line(start = [var 20mm, var 10mm], end = [var 5mm, var 10mm])
2902  rightGrooveWall = line(start = [var 5mm, var 10mm], end = [var 5mm, var 3mm])
2903  grooveFloor = line(start = [var 5mm, var 3mm], end = [var -5mm, var 3mm])
2904  leftGrooveWall = line(start = [var -5mm, var 3mm], end = [var -5mm, var 10mm])
2905  topLeft = line(start = [var -5mm, var 10mm], end = [var -20mm, var 10mm])
2906  leftSide = line(start = [var -20mm, var 10mm], end = [var -20mm, var -10mm])
2907  coincident([bottom.end, datumWidthFace.start])
2908  coincident([datumWidthFace.end, topRight.start])
2909  coincident([topRight.end, rightGrooveWall.start])
2910  coincident([rightGrooveWall.end, grooveFloor.start])
2911  coincident([grooveFloor.end, leftGrooveWall.start])
2912  coincident([leftGrooveWall.end, topLeft.start])
2913  coincident([topLeft.end, leftSide.start])
2914  coincident([leftSide.end, bottom.start])
2915  horizontal(bottom)
2916  vertical(datumWidthFace)
2917  horizontal(topRight)
2918  vertical(rightGrooveWall)
2919  horizontal(grooveFloor)
2920  vertical(leftGrooveWall)
2921  horizontal(topLeft)
2922  vertical(leftSide)
2923}
2924
2925latchBlockRegion = region(point = [0mm, 0mm], sketch = latchProfile)
2926latchBlock = extrude(latchBlockRegion, length = 12mm, tagEnd = $frontFace)
2927grooveFloorFrontEdge = getCommonEdge(faces = [latchBlock.sketch.tags.grooveFloor, frontFace])
2928
2929gdt::datum(face = latchBlock.sketch.tags.bottom, name = "A", framePosition = [0mm, -16mm], framePlane = XZ)
2930gdt::symmetry(edges = [grooveFloorFrontEdge], tolerance = 0.2mm, datums = ["A"], framePosition = [-24mm, 14mm], framePlane = XZ)
2931"#;
2932
2933    #[tokio::test(flavor = "multi_thread")]
2934    async fn gdt_symmetry_uses_symmetry_symbol_with_datums_for_face() -> Result<(), KclError> {
2935        let commands = gdt_commands(GDT_SYMMETRY_LATCH_BLOCK_GROOVE_FACE_KCL).await;
2936        let control_frames: Vec<_> = commands
2937            .iter()
2938            .filter_map(|command| {
2939                feature_control(command)
2940                    .ok()
2941                    .and_then(|feature_control| feature_control.control_frame.as_ref())
2942                    .filter(|control_frame| control_frame.symbol == MbdSymbol::Symmetry)
2943            })
2944            .collect();
2945
2946        assert_eq!(control_frames.len(), 1);
2947        let control_frame = control_frames[0];
2948        assert_eq!(control_frame.diameter_symbol, None);
2949        assert_close(control_frame.tolerance, 0.2);
2950        assert_eq!(control_frame.primary_datum, Some('A'));
2951        assert!(control_frame.secondary_datum.is_none());
2952        assert!(control_frame.tertiary_datum.is_none());
2953        Ok(())
2954    }
2955
2956    #[tokio::test(flavor = "multi_thread")]
2957    async fn gdt_symmetry_uses_symmetry_symbol_with_datums_for_edge() -> Result<(), KclError> {
2958        let commands = gdt_commands(GDT_SYMMETRY_LATCH_BLOCK_GROOVE_EDGE_KCL).await;
2959        let control_frames: Vec<_> = commands
2960            .iter()
2961            .filter_map(|command| {
2962                feature_control(command)
2963                    .ok()
2964                    .and_then(|feature_control| feature_control.control_frame.as_ref())
2965                    .filter(|control_frame| control_frame.symbol == MbdSymbol::Symmetry)
2966            })
2967            .collect();
2968
2969        assert_eq!(control_frames.len(), 1);
2970        let control_frame = control_frames[0];
2971        assert_eq!(control_frame.diameter_symbol, None);
2972        assert_close(control_frame.tolerance, 0.2);
2973        assert_eq!(control_frame.primary_datum, Some('A'));
2974        assert!(control_frame.secondary_datum.is_none());
2975        assert!(control_frame.tertiary_datum.is_none());
2976        Ok(())
2977    }
2978
2979    // Covers the gdt::runout doc example plus a face-based variant. Runs in mock mode, so it validates
2980    // parsing, name resolution, and that the control frame uses the Runout
2981    // symbol with a datum reference and no diameter symbol.
2982    const GDT_RUNOUT_STEPPED_SHAFT_KCL: &str = r#"
2983@settings(defaultLengthUnit = mm, kclVersion = 2)
2984
2985annotationPlane = offsetPlane(XZ, offset = 24mm)
2986
2987controlledSketch = sketch(on = YZ) {
2988  upperPerimeter = arc(start = [var 10mm, var 0mm], end = [var -10mm, var 0mm], center = [var 0mm, var 0mm])
2989  lowerPerimeter = arc(start = [var -10mm, var 0mm], end = [var 10mm, var 0mm], center = [var 0mm, var 0mm])
2990  coincident([upperPerimeter.end, lowerPerimeter.start])
2991  coincident([lowerPerimeter.end, upperPerimeter.start])
2992}
2993
2994controlledShaft = extrude(
2995  region(point = [0mm, 1mm], sketch = controlledSketch),
2996  length = -58mm,
2997  tagStart = $controlledShoulder,
2998  tagEnd = $controlledFreeEnd
2999)
3000
3001controlledUpperShoulderEdge = getCommonEdge(faces = [
3002  controlledShaft.sketch.tags.upperPerimeter,
3003  controlledShoulder
3004])
3005
3006datumSketch = sketch(on = YZ) {
3007  perimeter = circle(start = [var 18mm, var 0mm], center = [var 0mm, var 0mm])
3008}
3009
3010datumShaft = extrude(
3011  region(point = datumSketch.perimeter.center, sketch = datumSketch),
3012  length = 36mm,
3013  tagEnd = $datumEnd
3014)
3015
3016gdt::datum(
3017  face = datumShaft.sketch.tags.perimeter,
3018  name = "A",
3019  framePosition = [18mm, -28mm],
3020  framePlane = annotationPlane,
3021  leaderScale = 1.15,
3022  fontSize = 6mm
3023)
3024
3025gdt::runout(
3026  edges = [controlledUpperShoulderEdge],
3027  tolerance = 0.2mm,
3028  datums = ["A"],
3029  precision = 1,
3030  framePosition = [12mm, 48mm],
3031  framePlane = annotationPlane,
3032  leaderScale = 1.15,
3033  fontSize = 6mm
3034)
3035"#;
3036
3037    const GDT_RUNOUT_FACE_KCL: &str = r#"
3038@settings(defaultLengthUnit = mm, kclVersion = 2)
3039
3040datumSketch = sketch(on = XY) {
3041  perimeter = circle(start = [var 6mm, var 0mm], center = [var 0mm, var 0mm])
3042}
3043
3044datumShaft = extrude(region(point = datumSketch.perimeter.center, sketch = datumSketch), length = 18mm)
3045
3046controlledSketch = sketch(on = XY) {
3047  perimeter = circle(start = [var 3mm, var 0mm], center = [var 0mm, var 0mm])
3048}
3049
3050controlledShaft = extrude(region(point = controlledSketch.perimeter.center, sketch = controlledSketch), length = 16mm)
3051  |> translate(z = -16mm)
3052
3053gdt::datum(face = datumShaft.sketch.tags.perimeter, name = "A", framePosition = [12mm, -14mm], framePlane = XZ)
3054gdt::runout(faces = [controlledShaft.sketch.tags.perimeter], tolerance = 0.2mm, datums = ["A"], framePosition = [-18mm, 12mm], framePlane = XZ)
3055"#;
3056
3057    #[tokio::test(flavor = "multi_thread")]
3058    async fn gdt_runout_uses_runout_symbol_with_axis_datum() -> Result<(), KclError> {
3059        let cases = [
3060            ("stepped shaft", GDT_RUNOUT_STEPPED_SHAFT_KCL, 0.2),
3061            ("controlled face", GDT_RUNOUT_FACE_KCL, 0.2),
3062        ];
3063
3064        for (label, code, expected_tolerance) in cases {
3065            let commands = gdt_commands(code).await;
3066            let control_frame = find_control_frame_with_symbol(&commands, MbdSymbol::Runout)?;
3067
3068            assert!(control_frame.diameter_symbol.is_none(), "case: {label}");
3069            assert_close(control_frame.tolerance, expected_tolerance);
3070            assert_eq!(control_frame.primary_datum, Some('A'), "case: {label}");
3071            assert!(control_frame.secondary_datum.is_none(), "case: {label}");
3072            assert!(control_frame.tertiary_datum.is_none(), "case: {label}");
3073        }
3074        Ok(())
3075    }
3076}