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