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