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