Skip to main content

kcl_lib/std/
gdt.rs

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