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