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