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
111fn add_gdt_annotation_artifact(exec_state: &mut ExecState, args: &Args, annotation_id: uuid::Uuid) {
112    exec_state.add_artifact(Artifact::GdtAnnotation(GdtAnnotationArtifact {
113        id: ArtifactId::new(annotation_id),
114        code_ref: CodeRef::placeholder(args.source_range),
115    }));
116}
117
118impl DistanceEntity {
119    async fn to_endpoint(&self, exec_state: &mut ExecState, args: &Args) -> Result<DistanceEndpoint, KclError> {
120        match self {
121            DistanceEntity::Face(face) => Ok(DistanceEndpoint {
122                entity_id: face.id,
123                entity_pos: KPoint2d { x: 0.5, y: 0.5 },
124            }),
125            DistanceEntity::TaggedFace(face) => Ok(DistanceEndpoint {
126                entity_id: args.get_adjacent_face_to_tag(exec_state, face, false).await?,
127                entity_pos: KPoint2d { x: 0.5, y: 0.5 },
128            }),
129            DistanceEntity::Edge(edge) => Ok(DistanceEndpoint {
130                entity_id: edge.get_engine_id(exec_state, args)?,
131                entity_pos: KPoint2d { x: 0.5, y: 0.0 },
132            }),
133        }
134    }
135}
136
137impl<'a> FromKclValue<'a> for DistanceEntity {
138    fn from_kcl_val(arg: &'a KclValue) -> Option<Self> {
139        match arg {
140            KclValue::Face { value } => Some(Self::Face(value.to_owned())),
141            KclValue::Uuid { value, .. } => Some(Self::Edge(EdgeReference::Uuid(*value))),
142            KclValue::TagIdentifier(value) => Some(Self::TaggedFace(value.to_owned())),
143            _ => None,
144        }
145    }
146}
147
148fn distance_entity_type() -> RuntimeType {
149    RuntimeType::Union(vec![
150        RuntimeType::face(),
151        RuntimeType::tagged_face(),
152        RuntimeType::edge(),
153    ])
154}
155
156pub async fn datum(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
157    let face: TagIdentifier = args.get_kw_arg("face", &RuntimeType::tagged_face(), exec_state)?;
158    let name: String = args.get_kw_arg("name", &RuntimeType::string(), exec_state)?;
159    let frame_position: Option<[TyF64; 2]> =
160        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
161    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
162    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
163    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
164
165    let annotation = inner_datum(
166        face,
167        name,
168        frame_position,
169        frame_plane,
170        leader_scale,
171        font_size,
172        exec_state,
173        &args,
174    )
175    .await?;
176    Ok(KclValue::GdtAnnotation {
177        value: Box::new(annotation),
178    })
179}
180
181#[allow(clippy::too_many_arguments)]
182async fn inner_datum(
183    face: TagIdentifier,
184    name: String,
185    frame_position: Option<[TyF64; 2]>,
186    frame_plane: Option<Plane>,
187    leader_scale: Option<TyF64>,
188    font_size: Option<TyF64>,
189    exec_state: &mut ExecState,
190    args: &Args,
191) -> Result<GdtAnnotation, KclError> {
192    const DATUM_LENGTH_ERROR: &str = "Datum name must be a single character.";
193    if name.len() > 1 {
194        return Err(KclError::new_semantic(KclErrorDetails::new(
195            DATUM_LENGTH_ERROR.to_owned(),
196            vec![args.source_range],
197        )));
198    }
199    let name_char = name.chars().next().ok_or_else(|| {
200        KclError::new_semantic(KclErrorDetails::new(
201            DATUM_LENGTH_ERROR.to_owned(),
202            vec![args.source_range],
203        ))
204    })?;
205    let mut frame_plane = if let Some(plane) = frame_plane {
206        plane
207    } else {
208        // No plane given. Use one of the standard planes.
209        xy_plane(exec_state, args).await?
210    };
211    ensure_sketch_plane_in_engine(
212        &mut frame_plane,
213        exec_state,
214        &args.ctx,
215        args.source_range,
216        args.node_path.clone(),
217    )
218    .await?;
219    let face_id = args.get_adjacent_face_to_tag(exec_state, &face, false).await?;
220    let meta = vec![Metadata::from(args.source_range)];
221    let annotation_id = exec_state.next_uuid();
222    let feature_control = AnnotationFeatureControl::builder()
223        .entity_id(face_id)
224        // Point to the center of the face.
225        .entity_pos(KPoint2d { x: 0.5, y: 0.5 })
226        .leader_type(AnnotationLineEnd::Dot)
227        .defined_datum(name_char)
228        .plane_id(frame_plane.id)
229        .offset(if let Some(offset) = &frame_position {
230            KPoint2d {
231                x: offset[0].to_mm(),
232                y: offset[1].to_mm(),
233            }
234        } else {
235            KPoint2d { x: 100.0, y: 100.0 }
236        })
237        .precision(0)
238        .font_scale(gdt_font_scale(font_size.as_ref(), args)?)
239        .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
240        .leader_scale(gdt_dot_leader_scale(leader_scale.as_ref(), font_size.as_ref(), args)?)
241        .build();
242    exec_state
243        .batch_modeling_cmd(
244            ModelingCmdMeta::from_args_id(exec_state, args, annotation_id),
245            ModelingCmd::from(
246                mcmd::NewAnnotation::builder()
247                    .options(AnnotationOptions::builder().feature_control(feature_control).build())
248                    .clobber(false)
249                    .annotation_type(AnnotationType::T3D)
250                    .build(),
251            ),
252        )
253        .await?;
254    add_gdt_annotation_artifact(exec_state, args, annotation_id);
255    Ok(GdtAnnotation {
256        id: annotation_id,
257        meta,
258    })
259}
260
261pub async fn flatness(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
262    let faces: Vec<TagIdentifier> = args.get_kw_arg(
263        "faces",
264        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
265        exec_state,
266    )?;
267    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
268    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
269    let frame_position: Option<[TyF64; 2]> =
270        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
271    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
272    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
273    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
274
275    let annotations = inner_flatness(
276        faces,
277        tolerance,
278        precision,
279        frame_position,
280        frame_plane,
281        leader_scale,
282        font_size,
283        exec_state,
284        &args,
285    )
286    .await?;
287    Ok(annotations.into())
288}
289
290#[allow(clippy::too_many_arguments)]
291async fn inner_flatness(
292    faces: Vec<TagIdentifier>,
293    tolerance: TyF64,
294    precision: Option<TyF64>,
295    frame_position: Option<[TyF64; 2]>,
296    frame_plane: Option<Plane>,
297    leader_scale: Option<TyF64>,
298    font_size: Option<TyF64>,
299    exec_state: &mut ExecState,
300    args: &Args,
301) -> Result<Vec<GdtAnnotation>, KclError> {
302    let precision = resolve_precision(precision, args)?;
303    let mut frame_plane = if let Some(plane) = frame_plane {
304        plane
305    } else {
306        // No plane given. Use one of the standard planes.
307        xy_plane(exec_state, args).await?
308    };
309    ensure_sketch_plane_in_engine(
310        &mut frame_plane,
311        exec_state,
312        &args.ctx,
313        args.source_range,
314        args.node_path.clone(),
315    )
316    .await?;
317    let mut annotations = Vec::with_capacity(faces.len());
318    let display_units = exec_state.length_unit();
319    for face in &faces {
320        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
321        let meta = vec![Metadata::from(args.source_range)];
322        let annotation_id = exec_state.next_uuid();
323        let feature_control = AnnotationFeatureControl::builder()
324            .entity_id(face_id)
325            // Point to the center of the face.
326            .entity_pos(KPoint2d { x: 0.5, y: 0.5 })
327            .leader_type(AnnotationLineEnd::Dot)
328            .control_frame(
329                AnnotationMbdControlFrame::builder()
330                    .symbol(MbdSymbol::Flatness)
331                    .tolerance(tolerance.to_length_units(display_units))
332                    .build(),
333            )
334            .plane_id(frame_plane.id)
335            .offset(if let Some(offset) = &frame_position {
336                KPoint2d {
337                    x: offset[0].to_mm(),
338                    y: offset[1].to_mm(),
339                }
340            } else {
341                KPoint2d { x: 100.0, y: 100.0 }
342            })
343            .precision(precision)
344            .font_scale(gdt_font_scale(font_size.as_ref(), args)?)
345            .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
346            .leader_scale(gdt_dot_leader_scale(leader_scale.as_ref(), font_size.as_ref(), args)?)
347            .build();
348        let options = AnnotationOptions::builder().feature_control(feature_control).build();
349        exec_state
350            .batch_modeling_cmd(
351                ModelingCmdMeta::from_args_id(exec_state, args, annotation_id),
352                ModelingCmd::from(
353                    mcmd::NewAnnotation::builder()
354                        .options(options)
355                        .clobber(false)
356                        .annotation_type(AnnotationType::T3D)
357                        .build(),
358                ),
359            )
360            .await?;
361        add_gdt_annotation_artifact(exec_state, args, annotation_id);
362        annotations.push(GdtAnnotation {
363            id: annotation_id,
364            meta,
365        });
366    }
367    Ok(annotations)
368}
369
370pub async fn straightness(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
371    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
372        "faces",
373        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
374        exec_state,
375    )?;
376    let edges: Option<Vec<EdgeReference>> = args.get_kw_arg_opt(
377        "edges",
378        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
379        exec_state,
380    )?;
381    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
382    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
383    let frame_position: Option<[TyF64; 2]> =
384        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
385    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
386    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
387    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
388
389    let annotations = inner_straightness(
390        faces.unwrap_or_default(),
391        edges.unwrap_or_default(),
392        tolerance,
393        precision,
394        frame_position,
395        frame_plane,
396        leader_scale,
397        font_size,
398        exec_state,
399        &args,
400    )
401    .await?;
402    Ok(annotations.into())
403}
404
405#[expect(clippy::too_many_arguments)]
406async fn inner_straightness(
407    faces: Vec<TagIdentifier>,
408    edges: Vec<EdgeReference>,
409    tolerance: TyF64,
410    precision: Option<TyF64>,
411    frame_position: Option<[TyF64; 2]>,
412    frame_plane: Option<Plane>,
413    leader_scale: Option<TyF64>,
414    font_size: Option<TyF64>,
415    exec_state: &mut ExecState,
416    args: &Args,
417) -> Result<Vec<GdtAnnotation>, KclError> {
418    if faces.is_empty() && edges.is_empty() {
419        return Err(KclError::new_semantic(KclErrorDetails::new(
420            "Straightness requires at least one face or edge.".to_owned(),
421            vec![args.source_range],
422        )));
423    }
424
425    let precision = resolve_precision(precision, args)?;
426    let mut frame_plane = if let Some(plane) = frame_plane {
427        plane
428    } else {
429        // No plane given. Use one of the standard planes.
430        xy_plane(exec_state, args).await?
431    };
432    ensure_sketch_plane_in_engine(
433        &mut frame_plane,
434        exec_state,
435        &args.ctx,
436        args.source_range,
437        args.node_path.clone(),
438    )
439    .await?;
440
441    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
442    for face in &faces {
443        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
444        create_feature_control_annotation(
445            face_id,
446            MbdSymbol::Straightness,
447            &tolerance,
448            &[],
449            precision,
450            frame_position.as_ref(),
451            frame_plane.id,
452            leader_scale.as_ref(),
453            font_size.as_ref(),
454            exec_state,
455            args,
456            &mut annotations,
457        )
458        .await?;
459    }
460    for edge in &edges {
461        let edge_id = edge.get_engine_id(exec_state, args)?;
462        create_feature_control_annotation(
463            edge_id,
464            MbdSymbol::Straightness,
465            &tolerance,
466            &[],
467            precision,
468            frame_position.as_ref(),
469            frame_plane.id,
470            leader_scale.as_ref(),
471            font_size.as_ref(),
472            exec_state,
473            args,
474            &mut annotations,
475        )
476        .await?;
477    }
478    Ok(annotations)
479}
480
481pub async fn circularity(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
482    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
483        "faces",
484        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
485        exec_state,
486    )?;
487    let edges: Option<Vec<EdgeReference>> = args.get_kw_arg_opt(
488        "edges",
489        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
490        exec_state,
491    )?;
492    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
493    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
494    let frame_position: Option<[TyF64; 2]> =
495        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
496    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
497    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
498    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
499
500    let annotations = inner_circularity(
501        faces.unwrap_or_default(),
502        edges.unwrap_or_default(),
503        tolerance,
504        precision,
505        frame_position,
506        frame_plane,
507        leader_scale,
508        font_size,
509        exec_state,
510        &args,
511    )
512    .await?;
513    Ok(annotations.into())
514}
515
516#[expect(clippy::too_many_arguments)]
517async fn inner_circularity(
518    faces: Vec<TagIdentifier>,
519    edges: Vec<EdgeReference>,
520    tolerance: TyF64,
521    precision: Option<TyF64>,
522    frame_position: Option<[TyF64; 2]>,
523    frame_plane: Option<Plane>,
524    leader_scale: Option<TyF64>,
525    font_size: Option<TyF64>,
526    exec_state: &mut ExecState,
527    args: &Args,
528) -> Result<Vec<GdtAnnotation>, KclError> {
529    if faces.is_empty() && edges.is_empty() {
530        return Err(KclError::new_semantic(KclErrorDetails::new(
531            "Circularity requires at least one face or edge.".to_owned(),
532            vec![args.source_range],
533        )));
534    }
535
536    let precision = resolve_precision(precision, args)?;
537    let mut frame_plane = if let Some(plane) = frame_plane {
538        plane
539    } else {
540        // No plane given. Use one of the standard planes.
541        xy_plane(exec_state, args).await?
542    };
543    ensure_sketch_plane_in_engine(
544        &mut frame_plane,
545        exec_state,
546        &args.ctx,
547        args.source_range,
548        args.node_path.clone(),
549    )
550    .await?;
551
552    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
553    for face in &faces {
554        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
555        create_feature_control_annotation(
556            face_id,
557            MbdSymbol::Roundness,
558            &tolerance,
559            &[],
560            precision,
561            frame_position.as_ref(),
562            frame_plane.id,
563            leader_scale.as_ref(),
564            font_size.as_ref(),
565            exec_state,
566            args,
567            &mut annotations,
568        )
569        .await?;
570    }
571    for edge in &edges {
572        let edge_id = edge.get_engine_id(exec_state, args)?;
573        create_feature_control_annotation(
574            edge_id,
575            MbdSymbol::Roundness,
576            &tolerance,
577            &[],
578            precision,
579            frame_position.as_ref(),
580            frame_plane.id,
581            leader_scale.as_ref(),
582            font_size.as_ref(),
583            exec_state,
584            args,
585            &mut annotations,
586        )
587        .await?;
588    }
589    Ok(annotations)
590}
591
592pub async fn cylindricity(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
593    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
594        "faces",
595        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
596        exec_state,
597    )?;
598    let edges: Option<Vec<EdgeReference>> = args.get_kw_arg_opt(
599        "edges",
600        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
601        exec_state,
602    )?;
603    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
604    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
605    let frame_position: Option<[TyF64; 2]> =
606        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
607    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
608    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
609    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
610
611    let annotations = inner_cylindricity(
612        faces.unwrap_or_default(),
613        edges.unwrap_or_default(),
614        tolerance,
615        precision,
616        frame_position,
617        frame_plane,
618        leader_scale,
619        font_size,
620        exec_state,
621        &args,
622    )
623    .await?;
624    Ok(annotations.into())
625}
626
627#[expect(clippy::too_many_arguments)]
628async fn inner_cylindricity(
629    faces: Vec<TagIdentifier>,
630    edges: Vec<EdgeReference>,
631    tolerance: TyF64,
632    precision: Option<TyF64>,
633    frame_position: Option<[TyF64; 2]>,
634    frame_plane: Option<Plane>,
635    leader_scale: Option<TyF64>,
636    font_size: Option<TyF64>,
637    exec_state: &mut ExecState,
638    args: &Args,
639) -> Result<Vec<GdtAnnotation>, KclError> {
640    if faces.is_empty() && edges.is_empty() {
641        return Err(KclError::new_semantic(KclErrorDetails::new(
642            "Cylindricity requires at least one face or edge.".to_owned(),
643            vec![args.source_range],
644        )));
645    }
646
647    let precision = resolve_precision(precision, args)?;
648    let mut frame_plane = if let Some(plane) = frame_plane {
649        plane
650    } else {
651        // No plane given. Use one of the standard planes.
652        xy_plane(exec_state, args).await?
653    };
654    ensure_sketch_plane_in_engine(
655        &mut frame_plane,
656        exec_state,
657        &args.ctx,
658        args.source_range,
659        args.node_path.clone(),
660    )
661    .await?;
662
663    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
664    for face in &faces {
665        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
666        create_feature_control_annotation(
667            face_id,
668            MbdSymbol::Cylindricity,
669            &tolerance,
670            &[],
671            precision,
672            frame_position.as_ref(),
673            frame_plane.id,
674            leader_scale.as_ref(),
675            font_size.as_ref(),
676            exec_state,
677            args,
678            &mut annotations,
679        )
680        .await?;
681    }
682    for edge in &edges {
683        let edge_id = edge.get_engine_id(exec_state, args)?;
684        create_feature_control_annotation(
685            edge_id,
686            MbdSymbol::Cylindricity,
687            &tolerance,
688            &[],
689            precision,
690            frame_position.as_ref(),
691            frame_plane.id,
692            leader_scale.as_ref(),
693            font_size.as_ref(),
694            exec_state,
695            args,
696            &mut annotations,
697        )
698        .await?;
699    }
700    Ok(annotations)
701}
702
703pub async fn profile(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
704    let edges: Vec<EdgeReference> = args.get_kw_arg(
705        "edges",
706        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
707        exec_state,
708    )?;
709    let datums: Option<Vec<String>> = args.get_kw_arg_opt(
710        "datums",
711        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
712        exec_state,
713    )?;
714    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
715    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
716    let frame_position: Option<[TyF64; 2]> =
717        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
718    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
719    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
720    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
721
722    let annotations = inner_profile(
723        edges,
724        datums,
725        tolerance,
726        precision,
727        frame_position,
728        frame_plane,
729        leader_scale,
730        font_size,
731        exec_state,
732        &args,
733    )
734    .await?;
735    Ok(annotations.into())
736}
737
738#[allow(clippy::too_many_arguments)]
739async fn inner_profile(
740    edges: Vec<EdgeReference>,
741    datums: Option<Vec<String>>,
742    tolerance: TyF64,
743    precision: Option<TyF64>,
744    frame_position: Option<[TyF64; 2]>,
745    frame_plane: Option<Plane>,
746    leader_scale: Option<TyF64>,
747    font_size: Option<TyF64>,
748    exec_state: &mut ExecState,
749    args: &Args,
750) -> Result<Vec<GdtAnnotation>, KclError> {
751    let precision = resolve_precision(precision, args)?;
752    let datums = resolve_datums(datums, args, "Profile")?;
753    let mut frame_plane = if let Some(plane) = frame_plane {
754        plane
755    } else {
756        xy_plane(exec_state, args).await?
757    };
758    ensure_sketch_plane_in_engine(
759        &mut frame_plane,
760        exec_state,
761        &args.ctx,
762        args.source_range,
763        args.node_path.clone(),
764    )
765    .await?;
766
767    let mut annotations = Vec::with_capacity(edges.len());
768    for edge in &edges {
769        let edge_id = edge.get_engine_id(exec_state, args)?;
770        create_feature_control_annotation(
771            edge_id,
772            MbdSymbol::ProfileOfLine,
773            &tolerance,
774            &datums,
775            precision,
776            frame_position.as_ref(),
777            frame_plane.id,
778            leader_scale.as_ref(),
779            font_size.as_ref(),
780            exec_state,
781            args,
782            &mut annotations,
783        )
784        .await?;
785    }
786    Ok(annotations)
787}
788
789pub async fn position(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
790    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
791        "faces",
792        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
793        exec_state,
794    )?;
795    let edges: Option<Vec<EdgeReference>> = args.get_kw_arg_opt(
796        "edges",
797        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
798        exec_state,
799    )?;
800    let datums: Option<Vec<String>> = args.get_kw_arg_opt(
801        "datums",
802        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
803        exec_state,
804    )?;
805    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
806    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
807    let frame_position: Option<[TyF64; 2]> =
808        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
809    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
810    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
811    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
812
813    let annotations = inner_position(
814        faces.unwrap_or_default(),
815        edges.unwrap_or_default(),
816        tolerance,
817        datums,
818        precision,
819        frame_position,
820        frame_plane,
821        leader_scale,
822        font_size,
823        exec_state,
824        &args,
825    )
826    .await?;
827    Ok(annotations.into())
828}
829
830#[allow(clippy::too_many_arguments)]
831async fn inner_position(
832    faces: Vec<TagIdentifier>,
833    edges: Vec<EdgeReference>,
834    tolerance: TyF64,
835    datums: Option<Vec<String>>,
836    precision: Option<TyF64>,
837    frame_position: Option<[TyF64; 2]>,
838    frame_plane: Option<Plane>,
839    leader_scale: Option<TyF64>,
840    font_size: Option<TyF64>,
841    exec_state: &mut ExecState,
842    args: &Args,
843) -> Result<Vec<GdtAnnotation>, KclError> {
844    if faces.is_empty() && edges.is_empty() {
845        return Err(KclError::new_semantic(KclErrorDetails::new(
846            "Position requires at least one face or edge.".to_owned(),
847            vec![args.source_range],
848        )));
849    }
850
851    let precision = resolve_precision(precision, args)?;
852    let datums = resolve_datums(datums, args, "Position")?;
853    let mut frame_plane = if let Some(plane) = frame_plane {
854        plane
855    } else {
856        xy_plane(exec_state, args).await?
857    };
858    ensure_sketch_plane_in_engine(
859        &mut frame_plane,
860        exec_state,
861        &args.ctx,
862        args.source_range,
863        args.node_path.clone(),
864    )
865    .await?;
866
867    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
868    for face in &faces {
869        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
870        create_feature_control_annotation(
871            face_id,
872            MbdSymbol::Position,
873            &tolerance,
874            &datums,
875            precision,
876            frame_position.as_ref(),
877            frame_plane.id,
878            leader_scale.as_ref(),
879            font_size.as_ref(),
880            exec_state,
881            args,
882            &mut annotations,
883        )
884        .await?;
885    }
886    for edge in &edges {
887        let edge_id = edge.get_engine_id(exec_state, args)?;
888        create_feature_control_annotation(
889            edge_id,
890            MbdSymbol::Position,
891            &tolerance,
892            &datums,
893            precision,
894            frame_position.as_ref(),
895            frame_plane.id,
896            leader_scale.as_ref(),
897            font_size.as_ref(),
898            exec_state,
899            args,
900            &mut annotations,
901        )
902        .await?;
903    }
904    Ok(annotations)
905}
906
907pub async fn distance(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
908    let from: Option<DistanceEntity> = args.get_kw_arg_opt("from", &distance_entity_type(), exec_state)?;
909    let to: Option<DistanceEntity> = args.get_kw_arg_opt("to", &distance_entity_type(), exec_state)?;
910    let edges: Option<Vec<EdgeReference>> = args.get_kw_arg_opt(
911        "edges",
912        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
913        exec_state,
914    )?;
915    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
916    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
917    let frame_position: Option<[TyF64; 2]> =
918        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
919    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
920    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
921    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
922
923    let annotations = inner_distance(
924        from,
925        to,
926        edges.unwrap_or_default(),
927        tolerance,
928        precision,
929        frame_position,
930        frame_plane,
931        leader_scale,
932        font_size,
933        exec_state,
934        &args,
935    )
936    .await?;
937    Ok(annotations.into())
938}
939
940#[allow(clippy::too_many_arguments)]
941async fn inner_distance(
942    from: Option<DistanceEntity>,
943    to: Option<DistanceEntity>,
944    edges: Vec<EdgeReference>,
945    tolerance: TyF64,
946    precision: Option<TyF64>,
947    frame_position: Option<[TyF64; 2]>,
948    frame_plane: Option<Plane>,
949    leader_scale: Option<TyF64>,
950    font_size: Option<TyF64>,
951    exec_state: &mut ExecState,
952    args: &Args,
953) -> Result<Vec<GdtAnnotation>, KclError> {
954    let precision = resolve_precision(precision, args)?;
955    let mut frame_plane = if let Some(plane) = frame_plane {
956        plane
957    } else {
958        xy_plane(exec_state, args).await?
959    };
960    ensure_sketch_plane_in_engine(
961        &mut frame_plane,
962        exec_state,
963        &args.ctx,
964        args.source_range,
965        args.node_path.clone(),
966    )
967    .await?;
968
969    if from.is_some() || to.is_some() {
970        if !edges.is_empty() {
971            return Err(KclError::new_semantic(KclErrorDetails::new(
972                "Distance cannot combine `from`/`to` with `edges`.".to_owned(),
973                vec![args.source_range],
974            )));
975        }
976
977        let (Some(from), Some(to)) = (from, to) else {
978            return Err(KclError::new_semantic(KclErrorDetails::new(
979                "Distance requires both `from` and `to` when measuring between entities.".to_owned(),
980                vec![args.source_range],
981            )));
982        };
983
984        let from = from.to_endpoint(exec_state, args).await?;
985        let to = to.to_endpoint(exec_state, args).await?;
986        let mut annotations = Vec::with_capacity(1);
987        create_basic_distance_annotation(
988            from,
989            to,
990            &tolerance,
991            precision,
992            frame_position.as_ref(),
993            frame_plane.id,
994            leader_scale.as_ref(),
995            font_size.as_ref(),
996            exec_state,
997            args,
998            &mut annotations,
999        )
1000        .await?;
1001        return Ok(annotations);
1002    }
1003
1004    if edges.is_empty() {
1005        return Err(KclError::new_semantic(KclErrorDetails::new(
1006            "Distance requires either `edges` or both `from` and `to`.".to_owned(),
1007            vec![args.source_range],
1008        )));
1009    }
1010
1011    let mut annotations = Vec::with_capacity(edges.len());
1012    for edge in &edges {
1013        let edge_id = edge.get_engine_id(exec_state, args)?;
1014        create_basic_distance_annotation(
1015            DistanceEndpoint {
1016                entity_id: edge_id,
1017                entity_pos: KPoint2d { x: 0.0, y: 0.0 },
1018            },
1019            DistanceEndpoint {
1020                entity_id: edge_id,
1021                entity_pos: KPoint2d { x: 1.0, y: 0.0 },
1022            },
1023            &tolerance,
1024            precision,
1025            frame_position.as_ref(),
1026            frame_plane.id,
1027            leader_scale.as_ref(),
1028            font_size.as_ref(),
1029            exec_state,
1030            args,
1031            &mut annotations,
1032        )
1033        .await?;
1034    }
1035    Ok(annotations)
1036}
1037
1038#[allow(clippy::too_many_arguments)]
1039async fn create_basic_distance_annotation(
1040    from: DistanceEndpoint,
1041    to: DistanceEndpoint,
1042    tolerance: &TyF64,
1043    precision: u32,
1044    frame_position: Option<&[TyF64; 2]>,
1045    frame_plane_id: uuid::Uuid,
1046    leader_scale: Option<&TyF64>,
1047    font_size: Option<&TyF64>,
1048    exec_state: &mut ExecState,
1049    args: &Args,
1050    annotations: &mut Vec<GdtAnnotation>,
1051) -> Result<(), KclError> {
1052    let meta = vec![Metadata::from(args.source_range)];
1053    let annotation_id = exec_state.next_uuid();
1054    let display_units = exec_state.length_unit();
1055    let dimension = AnnotationBasicDimension::builder()
1056        .from_entity_id(from.entity_id)
1057        .from_entity_pos(from.entity_pos)
1058        .to_entity_id(to.entity_id)
1059        .to_entity_pos(to.entity_pos)
1060        .dimension(
1061            AnnotationMbdBasicDimension::builder()
1062                .tolerance(tolerance.to_length_units(display_units))
1063                .build(),
1064        )
1065        .plane_id(frame_plane_id)
1066        .offset(if let Some(offset) = frame_position {
1067            KPoint2d {
1068                x: offset[0].to_mm(),
1069                y: offset[1].to_mm(),
1070            }
1071        } else {
1072            KPoint2d { x: 100.0, y: 100.0 }
1073        })
1074        .precision(precision)
1075        .font_scale(gdt_font_scale(font_size, args)?)
1076        .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
1077        .arrow_scale(gdt_dimension_leader_scale(leader_scale, args)?)
1078        .build();
1079    let options = AnnotationOptions::builder()
1080        .dimension(dimension)
1081        .units(display_units)
1082        .build();
1083    let annotation_cmd = ModelingCmd::from(
1084        mcmd::NewAnnotation::builder()
1085            .options(options)
1086            .clobber(false)
1087            .annotation_type(AnnotationType::T3D)
1088            .build(),
1089    );
1090    let cmd_meta = ModelingCmdMeta::from_args_id(exec_state, args, annotation_id);
1091    exec_state.batch_modeling_cmd(cmd_meta, annotation_cmd).await?;
1092    add_gdt_annotation_artifact(exec_state, args, annotation_id);
1093    annotations.push(GdtAnnotation {
1094        id: annotation_id,
1095        meta,
1096    });
1097    Ok(())
1098}
1099
1100pub async fn perpendicularity(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 = inner_perpendicularity(
1125        faces.unwrap_or_default(),
1126        edges.unwrap_or_default(),
1127        datums,
1128        tolerance,
1129        precision,
1130        frame_position,
1131        frame_plane,
1132        leader_scale,
1133        font_size,
1134        exec_state,
1135        &args,
1136    )
1137    .await?;
1138    Ok(annotations.into())
1139}
1140
1141#[allow(clippy::too_many_arguments)]
1142async fn inner_perpendicularity(
1143    faces: Vec<TagIdentifier>,
1144    edges: Vec<EdgeReference>,
1145    datums: Option<Vec<String>>,
1146    tolerance: TyF64,
1147    precision: Option<TyF64>,
1148    frame_position: Option<[TyF64; 2]>,
1149    frame_plane: Option<Plane>,
1150    leader_scale: Option<TyF64>,
1151    font_size: Option<TyF64>,
1152    exec_state: &mut ExecState,
1153    args: &Args,
1154) -> Result<Vec<GdtAnnotation>, KclError> {
1155    if faces.is_empty() && edges.is_empty() {
1156        return Err(KclError::new_semantic(KclErrorDetails::new(
1157            "Perpendicularity requires at least one face or edge.".to_owned(),
1158            vec![args.source_range],
1159        )));
1160    }
1161
1162    let precision = resolve_precision(precision, args)?;
1163    let datums = resolve_datums(datums, args, "Perpendicularity")?;
1164    let mut frame_plane = if let Some(plane) = frame_plane {
1165        plane
1166    } else {
1167        xy_plane(exec_state, args).await?
1168    };
1169    ensure_sketch_plane_in_engine(
1170        &mut frame_plane,
1171        exec_state,
1172        &args.ctx,
1173        args.source_range,
1174        args.node_path.clone(),
1175    )
1176    .await?;
1177
1178    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
1179    for face in &faces {
1180        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
1181        create_feature_control_annotation(
1182            face_id,
1183            MbdSymbol::Perpendicularity,
1184            &tolerance,
1185            &datums,
1186            precision,
1187            frame_position.as_ref(),
1188            frame_plane.id,
1189            leader_scale.as_ref(),
1190            font_size.as_ref(),
1191            exec_state,
1192            args,
1193            &mut annotations,
1194        )
1195        .await?;
1196    }
1197    for edge in &edges {
1198        let edge_id = edge.get_engine_id(exec_state, args)?;
1199        create_feature_control_annotation(
1200            edge_id,
1201            MbdSymbol::Perpendicularity,
1202            &tolerance,
1203            &datums,
1204            precision,
1205            frame_position.as_ref(),
1206            frame_plane.id,
1207            leader_scale.as_ref(),
1208            font_size.as_ref(),
1209            exec_state,
1210            args,
1211            &mut annotations,
1212        )
1213        .await?;
1214    }
1215
1216    Ok(annotations)
1217}
1218
1219pub async fn parallelism(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
1220    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
1221        "faces",
1222        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
1223        exec_state,
1224    )?;
1225    let edges: Option<Vec<EdgeReference>> = args.get_kw_arg_opt(
1226        "edges",
1227        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
1228        exec_state,
1229    )?;
1230    let datums: Option<Vec<String>> = args.get_kw_arg_opt(
1231        "datums",
1232        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
1233        exec_state,
1234    )?;
1235    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
1236    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
1237    let frame_position: Option<[TyF64; 2]> =
1238        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
1239    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
1240    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
1241    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
1242
1243    let annotations = inner_parallelism(
1244        faces.unwrap_or_default(),
1245        edges.unwrap_or_default(),
1246        datums,
1247        tolerance,
1248        precision,
1249        frame_position,
1250        frame_plane,
1251        leader_scale,
1252        font_size,
1253        exec_state,
1254        &args,
1255    )
1256    .await?;
1257    Ok(annotations.into())
1258}
1259
1260#[allow(clippy::too_many_arguments)]
1261async fn inner_parallelism(
1262    faces: Vec<TagIdentifier>,
1263    edges: Vec<EdgeReference>,
1264    datums: Option<Vec<String>>,
1265    tolerance: TyF64,
1266    precision: Option<TyF64>,
1267    frame_position: Option<[TyF64; 2]>,
1268    frame_plane: Option<Plane>,
1269    leader_scale: Option<TyF64>,
1270    font_size: Option<TyF64>,
1271    exec_state: &mut ExecState,
1272    args: &Args,
1273) -> Result<Vec<GdtAnnotation>, KclError> {
1274    if faces.is_empty() && edges.is_empty() {
1275        return Err(KclError::new_semantic(KclErrorDetails::new(
1276            "Parallelism requires at least one face or edge.".to_owned(),
1277            vec![args.source_range],
1278        )));
1279    }
1280
1281    let precision = resolve_precision(precision, args)?;
1282    let datums = resolve_datums(datums, args, "Parallelism")?;
1283    let mut frame_plane = if let Some(plane) = frame_plane {
1284        plane
1285    } else {
1286        xy_plane(exec_state, args).await?
1287    };
1288    ensure_sketch_plane_in_engine(
1289        &mut frame_plane,
1290        exec_state,
1291        &args.ctx,
1292        args.source_range,
1293        args.node_path.clone(),
1294    )
1295    .await?;
1296
1297    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
1298    for face in &faces {
1299        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
1300        create_feature_control_annotation(
1301            face_id,
1302            MbdSymbol::Parallelism,
1303            &tolerance,
1304            &datums,
1305            precision,
1306            frame_position.as_ref(),
1307            frame_plane.id,
1308            leader_scale.as_ref(),
1309            font_size.as_ref(),
1310            exec_state,
1311            args,
1312            &mut annotations,
1313        )
1314        .await?;
1315    }
1316    for edge in &edges {
1317        let edge_id = edge.get_engine_id(exec_state, args)?;
1318        create_feature_control_annotation(
1319            edge_id,
1320            MbdSymbol::Parallelism,
1321            &tolerance,
1322            &datums,
1323            precision,
1324            frame_position.as_ref(),
1325            frame_plane.id,
1326            leader_scale.as_ref(),
1327            font_size.as_ref(),
1328            exec_state,
1329            args,
1330            &mut annotations,
1331        )
1332        .await?;
1333    }
1334
1335    Ok(annotations)
1336}
1337
1338pub async fn annotation(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
1339    let annotation: String = args.get_kw_arg("annotation", &RuntimeType::string(), exec_state)?;
1340    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
1341        "faces",
1342        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
1343        exec_state,
1344    )?;
1345    let edges: Option<Vec<EdgeReference>> = args.get_kw_arg_opt(
1346        "edges",
1347        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
1348        exec_state,
1349    )?;
1350    let frame_position: Option<[TyF64; 2]> =
1351        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
1352    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
1353    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
1354    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
1355
1356    let annotations = inner_annotation(
1357        annotation,
1358        faces.unwrap_or_default(),
1359        edges.unwrap_or_default(),
1360        frame_position,
1361        frame_plane,
1362        leader_scale,
1363        font_size,
1364        exec_state,
1365        &args,
1366    )
1367    .await?;
1368    Ok(annotations.into())
1369}
1370
1371#[allow(clippy::too_many_arguments)]
1372async fn inner_annotation(
1373    annotation: String,
1374    faces: Vec<TagIdentifier>,
1375    edges: Vec<EdgeReference>,
1376    frame_position: Option<[TyF64; 2]>,
1377    frame_plane: Option<Plane>,
1378    leader_scale: Option<TyF64>,
1379    font_size: Option<TyF64>,
1380    exec_state: &mut ExecState,
1381    args: &Args,
1382) -> Result<Vec<GdtAnnotation>, KclError> {
1383    if annotation.is_empty() {
1384        return Err(KclError::new_semantic(KclErrorDetails::new(
1385            "Annotation text must not be empty.".to_owned(),
1386            vec![args.source_range],
1387        )));
1388    }
1389    if faces.is_empty() && edges.is_empty() {
1390        return Err(KclError::new_semantic(KclErrorDetails::new(
1391            "Annotation requires at least one face or edge.".to_owned(),
1392            vec![args.source_range],
1393        )));
1394    }
1395
1396    let mut frame_plane = if let Some(plane) = frame_plane {
1397        plane
1398    } else {
1399        xy_plane(exec_state, args).await?
1400    };
1401    ensure_sketch_plane_in_engine(
1402        &mut frame_plane,
1403        exec_state,
1404        &args.ctx,
1405        args.source_range,
1406        args.node_path.clone(),
1407    )
1408    .await?;
1409
1410    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
1411    for face in &faces {
1412        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
1413        create_annotation(
1414            face_id,
1415            &annotation,
1416            frame_position.as_ref(),
1417            frame_plane.id,
1418            leader_scale.as_ref(),
1419            font_size.as_ref(),
1420            exec_state,
1421            args,
1422            &mut annotations,
1423        )
1424        .await?;
1425    }
1426    for edge in &edges {
1427        let edge_id = edge.get_engine_id(exec_state, args)?;
1428        create_annotation(
1429            edge_id,
1430            &annotation,
1431            frame_position.as_ref(),
1432            frame_plane.id,
1433            leader_scale.as_ref(),
1434            font_size.as_ref(),
1435            exec_state,
1436            args,
1437            &mut annotations,
1438        )
1439        .await?;
1440    }
1441
1442    Ok(annotations)
1443}
1444
1445fn resolve_precision(precision: Option<TyF64>, args: &Args) -> Result<u32, KclError> {
1446    if let Some(precision) = precision {
1447        let rounded = precision.n.round();
1448        if !(0.0..=9.0).contains(&rounded) {
1449            return Err(KclError::new_semantic(KclErrorDetails::new(
1450                "Precision must be between 0 and 9".to_owned(),
1451                vec![args.source_range],
1452            )));
1453        }
1454        Ok(rounded as u32)
1455    } else {
1456        Ok(3)
1457    }
1458}
1459
1460#[allow(clippy::too_many_arguments)]
1461async fn create_feature_control_annotation(
1462    entity_id: uuid::Uuid,
1463    symbol: MbdSymbol,
1464    tolerance: &TyF64,
1465    datums: &[char],
1466    precision: u32,
1467    frame_position: Option<&[TyF64; 2]>,
1468    frame_plane_id: uuid::Uuid,
1469    leader_scale: Option<&TyF64>,
1470    font_size: Option<&TyF64>,
1471    exec_state: &mut ExecState,
1472    args: &Args,
1473    annotations: &mut Vec<GdtAnnotation>,
1474) -> Result<(), KclError> {
1475    let meta = vec![Metadata::from(args.source_range)];
1476    let annotation_id = exec_state.next_uuid();
1477    let display_units = exec_state.length_unit();
1478    let control_frame = gdt_control_frame(symbol, tolerance.to_length_units(display_units), datums);
1479    let feature_control = AnnotationFeatureControl::builder()
1480        .entity_id(entity_id)
1481        .entity_pos(KPoint2d { x: 0.5, y: 0.5 })
1482        .leader_type(AnnotationLineEnd::Dot)
1483        .control_frame(control_frame)
1484        .plane_id(frame_plane_id)
1485        .offset(if let Some(offset) = frame_position {
1486            KPoint2d {
1487                x: offset[0].to_mm(),
1488                y: offset[1].to_mm(),
1489            }
1490        } else {
1491            KPoint2d { x: 100.0, y: 100.0 }
1492        })
1493        .precision(precision)
1494        .font_scale(gdt_font_scale(font_size, args)?)
1495        .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
1496        .leader_scale(gdt_dot_leader_scale(leader_scale, font_size, args)?)
1497        .build();
1498    let options = AnnotationOptions::builder().feature_control(feature_control).build();
1499    exec_state
1500        .batch_modeling_cmd(
1501            ModelingCmdMeta::from_args_id(exec_state, args, annotation_id),
1502            ModelingCmd::from(
1503                mcmd::NewAnnotation::builder()
1504                    .options(options)
1505                    .clobber(false)
1506                    .annotation_type(AnnotationType::T3D)
1507                    .build(),
1508            ),
1509        )
1510        .await?;
1511    add_gdt_annotation_artifact(exec_state, args, annotation_id);
1512    annotations.push(GdtAnnotation {
1513        id: annotation_id,
1514        meta,
1515    });
1516    Ok(())
1517}
1518
1519fn gdt_control_frame(symbol: MbdSymbol, tolerance: f64, datums: &[char]) -> AnnotationMbdControlFrame {
1520    match datums {
1521        [] => AnnotationMbdControlFrame::builder()
1522            .symbol(symbol)
1523            .tolerance(tolerance)
1524            .build(),
1525        [primary] => AnnotationMbdControlFrame::builder()
1526            .symbol(symbol)
1527            .tolerance(tolerance)
1528            .primary_datum(*primary)
1529            .build(),
1530        [primary, secondary] => AnnotationMbdControlFrame::builder()
1531            .symbol(symbol)
1532            .tolerance(tolerance)
1533            .primary_datum(*primary)
1534            .secondary_datum(*secondary)
1535            .build(),
1536        [primary, secondary, tertiary] => AnnotationMbdControlFrame::builder()
1537            .symbol(symbol)
1538            .tolerance(tolerance)
1539            .primary_datum(*primary)
1540            .secondary_datum(*secondary)
1541            .tertiary_datum(*tertiary)
1542            .build(),
1543        _ => unreachable!("resolve_datums rejects more than three datums"),
1544    }
1545}
1546
1547#[allow(clippy::too_many_arguments)]
1548async fn create_annotation(
1549    entity_id: uuid::Uuid,
1550    annotation: &str,
1551    frame_position: Option<&[TyF64; 2]>,
1552    frame_plane_id: uuid::Uuid,
1553    leader_scale: Option<&TyF64>,
1554    font_size: Option<&TyF64>,
1555    exec_state: &mut ExecState,
1556    args: &Args,
1557    annotations: &mut Vec<GdtAnnotation>,
1558) -> Result<(), KclError> {
1559    let meta = vec![Metadata::from(args.source_range)];
1560    let annotation_id = exec_state.next_uuid();
1561    let feature_control = AnnotationFeatureControl::builder()
1562        .entity_id(entity_id)
1563        .entity_pos(KPoint2d { x: 0.5, y: 0.5 })
1564        .leader_type(AnnotationLineEnd::Dot)
1565        .prefix(annotation.to_owned())
1566        .plane_id(frame_plane_id)
1567        .offset(if let Some(offset) = frame_position {
1568            KPoint2d {
1569                x: offset[0].to_mm(),
1570                y: offset[1].to_mm(),
1571            }
1572        } else {
1573            KPoint2d { x: 100.0, y: 100.0 }
1574        })
1575        .precision(0)
1576        .font_scale(gdt_font_scale(font_size, args)?)
1577        .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
1578        .leader_scale(gdt_dot_leader_scale(leader_scale, font_size, args)?)
1579        .build();
1580    let options = AnnotationOptions::builder().feature_control(feature_control).build();
1581    exec_state
1582        .batch_modeling_cmd(
1583            ModelingCmdMeta::from_args_id(exec_state, args, annotation_id),
1584            ModelingCmd::from(
1585                mcmd::NewAnnotation::builder()
1586                    .options(options)
1587                    .clobber(false)
1588                    .annotation_type(AnnotationType::T3D)
1589                    .build(),
1590            ),
1591        )
1592        .await?;
1593    add_gdt_annotation_artifact(exec_state, args, annotation_id);
1594    annotations.push(GdtAnnotation {
1595        id: annotation_id,
1596        meta,
1597    });
1598    Ok(())
1599}
1600
1601fn resolve_datums(datums: Option<Vec<String>>, args: &Args, annotation_name: &str) -> Result<Vec<char>, KclError> {
1602    let datums = datums.unwrap_or_default();
1603    if datums.len() > 3 {
1604        return Err(KclError::new_semantic(KclErrorDetails::new(
1605            format!("{annotation_name} datums must include at most three names."),
1606            vec![args.source_range],
1607        )));
1608    }
1609
1610    let mut resolved = Vec::with_capacity(datums.len());
1611    for datum in &datums {
1612        let mut chars = datum.chars();
1613        let Some(name) = chars.next() else {
1614            return Err(KclError::new_semantic(KclErrorDetails::new(
1615                format!("{annotation_name} datum names must be a single character."),
1616                vec![args.source_range],
1617            )));
1618        };
1619        if chars.next().is_some() {
1620            return Err(KclError::new_semantic(KclErrorDetails::new(
1621                format!("{annotation_name} datum names must be a single character."),
1622                vec![args.source_range],
1623            )));
1624        }
1625        resolved.push(name);
1626    }
1627
1628    Ok(resolved)
1629}
1630
1631/// Get the XY plane by evaluating the `XY` expression so that it's the same as
1632/// if the user specified `XY`.
1633async fn xy_plane(exec_state: &mut ExecState, args: &Args) -> Result<Plane, KclError> {
1634    let plane_ast = plane_ast("XY", args.source_range);
1635    let metadata = Metadata::from(args.source_range);
1636    let plane_value = args
1637        .ctx
1638        .execute_expr(&plane_ast, exec_state, &metadata, &[], StatementKind::Expression)
1639        .await?;
1640    let plane_value = match plane_value.control {
1641        ControlFlowKind::Continue => plane_value.into_value(),
1642        ControlFlowKind::Exit => {
1643            let message = "Early return inside plane value is currently not supported".to_owned();
1644            debug_assert!(false, "{}", &message);
1645            return Err(KclError::new_internal(KclErrorDetails::new(
1646                message,
1647                vec![args.source_range],
1648            )));
1649        }
1650    };
1651    Ok(plane_value
1652        .as_plane()
1653        .ok_or_else(|| {
1654            KclError::new_internal(KclErrorDetails::new(
1655                "Expected XY plane to be defined".to_owned(),
1656                vec![args.source_range],
1657            ))
1658        })?
1659        .clone())
1660}
1661
1662/// An AST node for a plane with the given name.
1663fn plane_ast(plane_name: &str, range: SourceRange) -> ast::Node<ast::Expr> {
1664    ast::Node::new(
1665        ast::Expr::Name(Box::new(ast::Node::new(
1666            ast::Name {
1667                name: ast::Identifier::new(plane_name),
1668                path: Vec::new(),
1669                // TODO: We may want to set this to true once we implement it to
1670                // prevent it breaking if users redefine the identifier.
1671                abs_path: false,
1672                digest: None,
1673            },
1674            range.start(),
1675            range.end(),
1676            range.module_id(),
1677        ))),
1678        range.start(),
1679        range.end(),
1680        range.module_id(),
1681    )
1682}
1683
1684#[cfg(test)]
1685mod tests {
1686    use super::*;
1687    use crate::ExecutorContext;
1688    use crate::execution::Artifact;
1689    use crate::execution::ExecutorSettings;
1690    use crate::execution::MockConfig;
1691    use crate::execution::parse_execute;
1692
1693    const GDT_DISTANCE_KCL_TEMPLATE: &str = r#"
1694@settings(defaultLengthUnit = __UNIT__, kclVersion = 2)
1695
1696sketch001 = sketch(on = XY) {
1697  line1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
1698  line2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
1699  line3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
1700  line4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
1701  coincident([line1.end, line2.start])
1702  coincident([line2.end, line3.start])
1703  coincident([line3.end, line4.start])
1704  coincident([line4.end, line1.start])
1705  parallel([line2, line4])
1706  parallel([line3, line1])
1707  perpendicular([line1, line2])
1708  horizontal(line3)
1709}
1710
1711region001 = region(point = [5mm, 5mm], sketch = sketch001)
1712extrude001 = extrude(region001, length = 10mm)
1713gdt::distance(
1714  edges = [
1715    getCommonEdge(faces = [
1716      region001.tags.line4,
1717      region001.tags.line1
1718    ])
1719  ],
1720  tolerance = __TOLERANCE__,
1721  framePosition = __FRAME_POSITION__,
1722  fontSize = 2in,
1723)
1724"#;
1725
1726    const GDT_FLATNESS_KCL_TEMPLATE: &str = r#"
1727@settings(defaultLengthUnit = __UNIT__, kclVersion = 2)
1728
1729sketch001 = sketch(on = XY) {
1730  line1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
1731  line2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
1732  line3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
1733  line4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
1734  coincident([line1.end, line2.start])
1735  coincident([line2.end, line3.start])
1736  coincident([line3.end, line4.start])
1737  coincident([line4.end, line1.start])
1738  parallel([line2, line4])
1739  parallel([line3, line1])
1740  perpendicular([line1, line2])
1741  horizontal(line3)
1742}
1743
1744region001 = region(point = [5mm, 5mm], sketch = sketch001)
1745extrude001 = extrude(region001, length = 10mm, tagEnd = $capEnd001)
1746gdt::flatness(
1747  faces = [capEnd001],
1748  tolerance = __TOLERANCE__,
1749  framePosition = __FRAME_POSITION__,
1750  framePlane = XZ,
1751  fontSize = 2in,
1752)
1753"#;
1754
1755    fn gdt_distance_kcl(unit: &str, tolerance: &str, frame_position: &str) -> String {
1756        GDT_DISTANCE_KCL_TEMPLATE
1757            .replace("__UNIT__", unit)
1758            .replace("__TOLERANCE__", tolerance)
1759            .replace("__FRAME_POSITION__", frame_position)
1760    }
1761
1762    fn gdt_flatness_kcl(unit: &str, tolerance: &str, frame_position: &str) -> String {
1763        GDT_FLATNESS_KCL_TEMPLATE
1764            .replace("__UNIT__", unit)
1765            .replace("__TOLERANCE__", tolerance)
1766            .replace("__FRAME_POSITION__", frame_position)
1767    }
1768
1769    async fn gdt_commands(code: &str) -> Vec<ModelingCmd> {
1770        let result = parse_execute(code).await.unwrap();
1771        result
1772            .root_module_artifact_commands()
1773            .iter()
1774            .map(|artifact_command| artifact_command.command.clone())
1775            .collect()
1776    }
1777
1778    fn annotation_options(command: &ModelingCmd) -> Result<&AnnotationOptions, KclError> {
1779        let ModelingCmd::NewAnnotation(new_annotation) = command else {
1780            return Err(KclError::new_internal(KclErrorDetails::new(
1781                format!("expected new_annotation command, got {command:?}"),
1782                vec![SourceRange::default()],
1783            )));
1784        };
1785        Ok(&new_annotation.options)
1786    }
1787
1788    fn feature_control(command: &ModelingCmd) -> Result<&AnnotationFeatureControl, KclError> {
1789        let ModelingCmd::NewAnnotation(new_annotation) = command else {
1790            return Err(KclError::new_internal(KclErrorDetails::new(
1791                format!("expected new_annotation command, got {command:?}"),
1792                vec![SourceRange::default()],
1793            )));
1794        };
1795        new_annotation.options.feature_control.as_ref().ok_or_else(|| {
1796            KclError::new_internal(KclErrorDetails::new(
1797                "expected new_annotation command to have a feature_control".to_owned(),
1798                vec![SourceRange::default()],
1799            ))
1800        })
1801    }
1802
1803    #[track_caller]
1804    fn assert_close(actual: f64, expected: f64) {
1805        assert!((actual - expected).abs() < 1e-6, "expected {expected}, got {actual}");
1806    }
1807
1808    fn new_annotation_command_index(commands: &[ModelingCmd]) -> Result<usize, KclError> {
1809        commands
1810            .iter()
1811            .position(|command| matches!(command, ModelingCmd::NewAnnotation(_)))
1812            .ok_or_else(|| {
1813                KclError::new_internal(KclErrorDetails::new(
1814                    "expected commands to contain a new_annotation command".to_owned(),
1815                    vec![SourceRange::default()],
1816                ))
1817            })
1818    }
1819
1820    #[test]
1821    fn gdt_font_scale_is_scene_height_divided_by_calibration_height() {
1822        let scale_at_calibrated_height = gdt_font_scale_for_height_mm(GDT_FONT_SCALE_1_HEIGHT_MM);
1823        assert!((scale_at_calibrated_height - 1.0).abs() < f32::EPSILON);
1824
1825        let double_height_scale = gdt_font_scale_for_height_mm(GDT_FONT_SCALE_1_HEIGHT_MM * 2.0);
1826        assert!((double_height_scale - 2.0).abs() < f32::EPSILON);
1827
1828        let inch_in_mm = 25.4;
1829        let inch_scale = gdt_font_scale_for_height_mm(inch_in_mm);
1830        assert!((inch_scale - (inch_in_mm / GDT_FONT_SCALE_1_HEIGHT_MM) as f32).abs() < f32::EPSILON);
1831    }
1832
1833    const GDT_FLATNESS_LEADER_KCL_TEMPLATE: &str = r#"
1834@settings(defaultLengthUnit = mm, kclVersion = 2)
1835
1836blockProfile = sketch(on = XY) {
1837  edge1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
1838  edge2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
1839  edge3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
1840  edge4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
1841  coincident([edge1.end, edge2.start])
1842  coincident([edge2.end, edge3.start])
1843  coincident([edge3.end, edge4.start])
1844  coincident([edge4.end, edge1.start])
1845  parallel([edge2, edge4])
1846  parallel([edge3, edge1])
1847  perpendicular([edge1, edge2])
1848  horizontal(edge3)
1849}
1850
1851region001 = region(point = [5mm, 5mm], sketch = blockProfile)
1852extrude001 = extrude(region001, length = 10mm, tagEnd = $top)
1853gdt::flatness(
1854  faces = [top],
1855  tolerance = 0.1mm,
1856  framePosition = [10mm, 0mm],
1857  framePlane = XZ,
1858  fontSize = __FONT_SIZE__
1859  __LEADER_SCALE__
1860)
1861"#;
1862
1863    fn gdt_flatness_leader_kcl(font_size: &str, leader_scale: Option<&str>) -> String {
1864        GDT_FLATNESS_LEADER_KCL_TEMPLATE
1865            .replace("__FONT_SIZE__", font_size)
1866            .replace(
1867                "__LEADER_SCALE__",
1868                leader_scale
1869                    .map(|scale| format!(",\n  leaderScale = {scale}"))
1870                    .unwrap_or_default()
1871                    .as_str(),
1872            )
1873    }
1874
1875    async fn gdt_flatness_feature_control(
1876        font_size: &str,
1877        leader_scale: Option<&str>,
1878    ) -> Result<AnnotationFeatureControl, KclError> {
1879        let code = gdt_flatness_leader_kcl(font_size, leader_scale);
1880        let commands = gdt_commands(&code).await;
1881        let annotation_index = new_annotation_command_index(&commands)?;
1882        Ok(feature_control(&commands[annotation_index])?.clone())
1883    }
1884
1885    #[tokio::test(flavor = "multi_thread")]
1886    async fn gdt_dot_leader_scale_is_normalized_against_font_scale() -> Result<(), KclError> {
1887        let tiny = gdt_flatness_feature_control("1mm", None).await?;
1888        let large = gdt_flatness_feature_control("100mm", None).await?;
1889
1890        assert_close(f64::from(tiny.font_scale), gdt_font_scale_for_height_mm(1.0).into());
1891        assert_close(f64::from(large.font_scale), gdt_font_scale_for_height_mm(100.0).into());
1892        assert_close(f64::from(tiny.leader_scale), 50.0);
1893        assert_close(f64::from(large.leader_scale), 0.5);
1894
1895        assert_close(
1896            f64::from(tiny.font_scale) * f64::from(tiny.leader_scale),
1897            f64::from(gdt_dot_leader_normal_size()),
1898        );
1899        assert_close(
1900            f64::from(large.font_scale) * f64::from(large.leader_scale),
1901            f64::from(gdt_dot_leader_normal_size()),
1902        );
1903        Ok(())
1904    }
1905
1906    #[tokio::test(flavor = "multi_thread")]
1907    async fn explicit_gdt_dot_leader_scale_multiplies_normal_size() -> Result<(), KclError> {
1908        let tiny = gdt_flatness_feature_control("1mm", Some("2")).await?;
1909        let large = gdt_flatness_feature_control("100mm", Some("2")).await?;
1910
1911        let expected_scaled_dot_size = f64::from(gdt_dot_leader_normal_size()) * 2.0;
1912        assert_close(
1913            f64::from(tiny.font_scale) * f64::from(tiny.leader_scale),
1914            expected_scaled_dot_size,
1915        );
1916        assert_close(
1917            f64::from(large.font_scale) * f64::from(large.leader_scale),
1918            expected_scaled_dot_size,
1919        );
1920        Ok(())
1921    }
1922
1923    #[tokio::test(flavor = "multi_thread")]
1924    async fn gdt_flatness_uses_scene_units_for_control_frame_tolerance() -> Result<(), KclError> {
1925        let cases = [
1926            ("in", "0.1in", "[10, -10]", 0.1, 254.0, -254.0),
1927            ("cm", "10mm", "[1, -1]", 1.0, 10.0, -10.0),
1928        ];
1929
1930        for (default_unit, tolerance, frame_position, expected_tolerance, expected_x, expected_y) in cases {
1931            let code = gdt_flatness_kcl(default_unit, tolerance, frame_position);
1932            let commands = gdt_commands(&code).await;
1933            let annotation_index = new_annotation_command_index(&commands)?;
1934            let feature_control = feature_control(&commands[annotation_index])?;
1935            let control_frame = feature_control.control_frame.as_ref().ok_or_else(|| {
1936                KclError::new_internal(KclErrorDetails::new(
1937                    "expected feature_control to have a control_frame".to_owned(),
1938                    vec![SourceRange::default()],
1939                ))
1940            })?;
1941
1942            assert_close(control_frame.tolerance, expected_tolerance);
1943            assert_close(feature_control.offset.x, expected_x);
1944            assert_close(feature_control.offset.y, expected_y);
1945            assert_close(
1946                f64::from(feature_control.font_scale),
1947                gdt_font_scale_for_height_mm(50.8).into(),
1948            );
1949        }
1950        Ok(())
1951    }
1952
1953    #[tokio::test(flavor = "multi_thread")]
1954    async fn gdt_distance_sets_units() -> Result<(), KclError> {
1955        let cases = [
1956            (
1957                "in",
1958                "2.54mm",
1959                "[10, -10]",
1960                kcmc::units::UnitLength::Inches,
1961                0.1,
1962                254.0,
1963                -254.0,
1964            ),
1965            (
1966                "cm",
1967                "10mm",
1968                "[1, -1]",
1969                kcmc::units::UnitLength::Centimeters,
1970                1.0,
1971                10.0,
1972                -10.0,
1973            ),
1974            (
1975                "mm",
1976                "2.54mm",
1977                "[10, -10]",
1978                kcmc::units::UnitLength::Millimeters,
1979                2.54,
1980                10.0,
1981                -10.0,
1982            ),
1983        ];
1984
1985        for (default_unit, tolerance, frame_position, scene_unit, expected_tolerance, expected_x, expected_y) in cases {
1986            let code = gdt_distance_kcl(default_unit, tolerance, frame_position);
1987            let commands = gdt_commands(&code).await;
1988            let annotation_index = new_annotation_command_index(&commands)?;
1989            let options = annotation_options(&commands[annotation_index])?;
1990
1991            assert_eq!(options.units, Some(scene_unit));
1992
1993            let dimension = options
1994                .dimension
1995                .as_ref()
1996                .expect("expected new_annotation command to have a dimension");
1997            assert_close(dimension.dimension.tolerance, expected_tolerance);
1998            assert_close(dimension.offset.x, expected_x);
1999            assert_close(dimension.offset.y, expected_y);
2000            assert_close(
2001                f64::from(dimension.font_scale),
2002                gdt_font_scale_for_height_mm(50.8).into(),
2003            );
2004        }
2005        Ok(())
2006    }
2007
2008    const GDT_DATUM_KCL: &str = r#"
2009blockProfile = sketch(on = XY) {
2010  edge1 = line(start = [var 0mm, var 0mm], end = [var 8mm, var 0mm])
2011  edge2 = line(start = [var 8mm, var 0mm], end = [var 8mm, var 5mm])
2012  edge3 = line(start = [var 8mm, var 5mm], end = [var 0mm, var 5mm])
2013  edge4 = line(start = [var 0mm, var 5mm], end = [var 0mm, var 0mm])
2014  coincident([edge1.end, edge2.start])
2015  coincident([edge2.end, edge3.start])
2016  coincident([edge3.end, edge4.start])
2017  coincident([edge4.end, edge1.start])
2018  horizontal(edge1)
2019  vertical(edge2)
2020  horizontal(edge3)
2021  vertical(edge4)
2022}
2023
2024block = extrude(region(point = [4mm, 2mm], sketch = blockProfile), length = 4mm, tagEnd = $top)
2025
2026gdt::datum(face = top, name = "A", framePosition = [10mm, 0mm], framePlane = XZ)
2027"#;
2028
2029    async fn gdt_artifact_count(skip_artifact_graph: bool) -> usize {
2030        let settings = ExecutorSettings {
2031            skip_artifact_graph,
2032            ..Default::default()
2033        };
2034        let ctx = ExecutorContext::new_mock(Some(settings)).await;
2035        let program = crate::Program::parse_no_errs(GDT_DATUM_KCL).unwrap();
2036        let mock_config = MockConfig {
2037            use_prev_memory: false,
2038            ..Default::default()
2039        };
2040        let outcome = ctx.run_mock(&program, &mock_config).await.unwrap();
2041        ctx.close().await;
2042
2043        outcome
2044            .artifact_graph
2045            .values()
2046            .filter(|artifact| matches!(artifact, Artifact::GdtAnnotation(_)))
2047            .count()
2048    }
2049
2050    #[tokio::test(flavor = "multi_thread")]
2051    async fn gdt_annotations_do_not_follow_runtime_artifact_graph_setting() {
2052        assert_eq!(gdt_artifact_count(false).await, 1);
2053        assert_eq!(gdt_artifact_count(true).await, 1);
2054    }
2055
2056    // Mirrors the gdt::circularity doc examples: annotate a cylinder's circular
2057    // edge and its curved wall. Runs in mock mode, so it validates parsing, name
2058    // resolution, and that the control frame uses the Roundness (circularity)
2059    // symbol without datums. The doc examples additionally render against the
2060    // engine in kcl_test_examples.
2061    const GDT_CIRCULARITY_EDGE_KCL: &str = r#"
2062@settings(defaultLengthUnit = mm, kclVersion = 2)
2063
2064cylinderSketch = sketch(on = XY) {
2065  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2066}
2067
2068cylinderRegion = region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch)
2069hide(cylinderSketch)
2070cylinder = extrude(cylinderRegion, length = 10mm)
2071gdt::circularity(edges = [cylinderRegion.tags.perimeter], tolerance = 0.05mm)
2072"#;
2073
2074    const GDT_CIRCULARITY_WALL_KCL: &str = r#"
2075@settings(defaultLengthUnit = mm, kclVersion = 2)
2076
2077cylinderSketch = sketch(on = XY) {
2078  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2079}
2080
2081cylinder = extrude(region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch), length = 10mm)
2082gdt::circularity(faces = [cylinder.sketch.tags.perimeter], tolerance = 0.02mm, framePosition = [12mm, 8mm], framePlane = XZ)
2083"#;
2084
2085    const GDT_CIRCULARITY_COMMON_EDGE_KCL: &str = r#"
2086@settings(defaultLengthUnit = mm, kclVersion = 2)
2087
2088cylinderSketch = sketch(on = XY) {
2089  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2090}
2091
2092cylinder = extrude(region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch), length = 10mm, tagEnd = $top)
2093topEdge = getCommonEdge(faces = [cylinder.sketch.tags.perimeter, top])
2094gdt::circularity(edges = [topEdge], tolerance = 0.05mm, framePosition = [12mm, 8mm], framePlane = XZ)
2095"#;
2096
2097    #[tokio::test(flavor = "multi_thread")]
2098    async fn gdt_circularity_uses_roundness_symbol_without_datums() -> Result<(), KclError> {
2099        let cases = [
2100            ("circular edge", GDT_CIRCULARITY_EDGE_KCL, 0.05),
2101            ("cylinder wall", GDT_CIRCULARITY_WALL_KCL, 0.02),
2102            ("common edge", GDT_CIRCULARITY_COMMON_EDGE_KCL, 0.05),
2103        ];
2104
2105        for (label, code, expected_tolerance) in cases {
2106            let commands = gdt_commands(code).await;
2107            let annotation_index = new_annotation_command_index(&commands)?;
2108            let feature_control = feature_control(&commands[annotation_index])?;
2109            let control_frame = feature_control.control_frame.as_ref().ok_or_else(|| {
2110                KclError::new_internal(KclErrorDetails::new(
2111                    format!("expected {label} feature_control to have a control_frame"),
2112                    vec![SourceRange::default()],
2113                ))
2114            })?;
2115
2116            assert_eq!(control_frame.symbol, MbdSymbol::Roundness, "case: {label}");
2117            assert_close(control_frame.tolerance, expected_tolerance);
2118            // Circularity is a form tolerance and never references datums.
2119            assert!(control_frame.primary_datum.is_none(), "case: {label}");
2120            assert!(control_frame.secondary_datum.is_none(), "case: {label}");
2121            assert!(control_frame.tertiary_datum.is_none(), "case: {label}");
2122        }
2123        Ok(())
2124    }
2125
2126    // Mirrors the gdt::cylindricity doc examples: annotate a cylinder's curved
2127    // wall and its circular edge. Runs in mock mode, so it validates parsing,
2128    // name resolution, and that the control frame uses the Cylindricity symbol
2129    // without datums. The doc examples additionally render against the engine in
2130    // kcl_test_examples.
2131    const GDT_CYLINDRICITY_WALL_KCL: &str = r#"
2132@settings(defaultLengthUnit = mm, kclVersion = 2)
2133
2134cylinderSketch = sketch(on = XY) {
2135  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2136}
2137
2138cylinder = extrude(region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch), length = 10mm)
2139gdt::cylindricity(faces = [cylinder.sketch.tags.perimeter], tolerance = 0.02mm, framePosition = [-12mm, 8mm], framePlane = XZ)
2140"#;
2141
2142    const GDT_CYLINDRICITY_EDGE_KCL: &str = r#"
2143@settings(defaultLengthUnit = mm, kclVersion = 2)
2144
2145cylinderSketch = sketch(on = XY) {
2146  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2147}
2148
2149cylinderRegion = region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch)
2150hide(cylinderSketch)
2151cylinder = extrude(cylinderRegion, length = 10mm)
2152gdt::cylindricity(edges = [cylinderRegion.tags.perimeter], tolerance = 0.05mm, framePosition = [-12mm, 8mm])
2153"#;
2154
2155    const GDT_CYLINDRICITY_COMMON_EDGE_KCL: &str = r#"
2156@settings(defaultLengthUnit = mm, kclVersion = 2)
2157
2158cylinderSketch = sketch(on = XY) {
2159  perimeter = circle(start = [var 5mm, var 0mm], center = [var 0mm, var 0mm])
2160}
2161
2162cylinder = extrude(region(point = cylinderSketch.perimeter.center, sketch = cylinderSketch), length = 10mm, tagEnd = $top)
2163topEdge = getCommonEdge(faces = [cylinder.sketch.tags.perimeter, top])
2164gdt::cylindricity(edges = [topEdge], tolerance = 0.05mm, framePosition = [-12mm, 8mm], framePlane = XZ)
2165"#;
2166
2167    #[tokio::test(flavor = "multi_thread")]
2168    async fn gdt_cylindricity_uses_cylindricity_symbol_without_datums() -> Result<(), KclError> {
2169        let cases = [
2170            ("cylinder wall", GDT_CYLINDRICITY_WALL_KCL, 0.02),
2171            ("circular edge", GDT_CYLINDRICITY_EDGE_KCL, 0.05),
2172            ("common edge", GDT_CYLINDRICITY_COMMON_EDGE_KCL, 0.05),
2173        ];
2174
2175        for (label, code, expected_tolerance) in cases {
2176            let commands = gdt_commands(code).await;
2177            let annotation_index = new_annotation_command_index(&commands)?;
2178            let feature_control = feature_control(&commands[annotation_index])?;
2179            let control_frame = feature_control.control_frame.as_ref().ok_or_else(|| {
2180                KclError::new_internal(KclErrorDetails::new(
2181                    format!("expected {label} feature_control to have a control_frame"),
2182                    vec![SourceRange::default()],
2183                ))
2184            })?;
2185
2186            assert_eq!(control_frame.symbol, MbdSymbol::Cylindricity, "case: {label}");
2187            assert_close(control_frame.tolerance, expected_tolerance);
2188            // Cylindricity is a form tolerance and never references datums.
2189            assert!(control_frame.primary_datum.is_none(), "case: {label}");
2190            assert!(control_frame.secondary_datum.is_none(), "case: {label}");
2191            assert!(control_frame.tertiary_datum.is_none(), "case: {label}");
2192        }
2193        Ok(())
2194    }
2195}