Skip to main content

kcl_lib/std/
gdt.rs

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