Skip to main content

kcl_lib/std/
gdt.rs

1use kcl_error::SourceRange;
2use kcmc::ModelingCmd;
3use kcmc::each_cmd as mcmd;
4use kcmc::websocket::ModelingCmdReq;
5use kittycad_modeling_cmds::shared::AnnotationBasicDimension;
6use kittycad_modeling_cmds::shared::AnnotationFeatureControl;
7use kittycad_modeling_cmds::shared::AnnotationLineEnd;
8use kittycad_modeling_cmds::shared::AnnotationMbdBasicDimension;
9use kittycad_modeling_cmds::shared::AnnotationMbdControlFrame;
10use kittycad_modeling_cmds::shared::AnnotationOptions;
11use kittycad_modeling_cmds::shared::AnnotationType;
12use kittycad_modeling_cmds::shared::MbdSymbol;
13use kittycad_modeling_cmds::shared::Point2d as KPoint2d;
14use kittycad_modeling_cmds::{self as kcmc};
15
16use crate::ExecState;
17use crate::KclError;
18use crate::errors::KclErrorDetails;
19use crate::exec::KclValue;
20use crate::execution::Artifact;
21use crate::execution::ArtifactId;
22use crate::execution::CodeRef;
23use crate::execution::ControlFlowKind;
24use crate::execution::Face;
25use crate::execution::GdtAnnotation;
26use crate::execution::GdtAnnotationArtifact;
27use crate::execution::Metadata;
28use crate::execution::ModelingCmdMeta;
29use crate::execution::Plane;
30use crate::execution::StatementKind;
31use crate::execution::TagIdentifier;
32use crate::execution::types::ArrayLen;
33use crate::execution::types::RuntimeType;
34use crate::parsing::ast::types as ast;
35use crate::std::Args;
36use crate::std::args::FromKclValue;
37use crate::std::args::TyF64;
38use crate::std::fillet::EdgeReference;
39use crate::std::sketch::ensure_sketch_plane_in_engine;
40
41// The engine exposes two text knobs:
42// - font_point_size controls the FreeType raster/bitmap texture resolution in pixels/points.
43// - font_scale is the unitless model-space multiplier applied to that texture.
44// KCL exposes only fontSize as a Length. Keep the raster quality fixed so changing
45// quality does not resize the text, and map the requested length into font_scale.
46const GDT_FONT_TEXTURE_POINT_SIZE: u32 = 36;
47const DEFAULT_GDT_FONT_SIZE_MM: f64 = 10.0;
48const DEFAULT_GDT_DOT_LEADER_SCALE: f64 = 1.0;
49const DEFAULT_GDT_DIMENSION_LEADER_SCALE: f64 = 1.0;
50const GDT_DOT_LEADER_REFERENCE_FONT_SIZE_MM: f64 = 100.0;
51const GDT_DOT_LEADER_REFERENCE_ENGINE_SCALE: f64 = 0.5;
52
53// Calibration target: measured annotation text/frame height in millimeters when
54// font_scale is 1.0 and GDT_FONT_TEXTURE_POINT_SIZE is fixed. Tune this value from
55// scene measurements, not by exposing engine font_point_size to users.
56const GDT_FONT_SCALE_1_HEIGHT_MM: f64 = 8.0;
57
58fn gdt_font_scale(font_size: Option<&TyF64>, args: &Args) -> Result<f32, KclError> {
59    let requested_height_mm = font_size.map(TyF64::to_mm).unwrap_or(DEFAULT_GDT_FONT_SIZE_MM);
60    if requested_height_mm <= 0.0 {
61        return Err(KclError::new_semantic(KclErrorDetails::new(
62            "fontSize must be greater than 0.".to_owned(),
63            vec![args.source_range],
64        )));
65    }
66    Ok(gdt_font_scale_for_height_mm(requested_height_mm))
67}
68
69fn gdt_font_scale_for_height_mm(requested_height_mm: f64) -> f32 {
70    (requested_height_mm / GDT_FONT_SCALE_1_HEIGHT_MM) as f32
71}
72
73fn gdt_user_leader_scale(leader_scale: Option<&TyF64>, default_scale: f64, args: &Args) -> Result<f32, KclError> {
74    let scale = leader_scale.map(|scale| scale.n).unwrap_or(default_scale);
75    if scale <= 0.0 {
76        return Err(KclError::new_semantic(KclErrorDetails::new(
77            "leaderScale must be greater than 0.".to_owned(),
78            vec![args.source_range],
79        )));
80    }
81    Ok(scale as f32)
82}
83
84fn gdt_dot_leader_scale(leader_scale: Option<&TyF64>, font_size: Option<&TyF64>, args: &Args) -> Result<f32, KclError> {
85    let user_scale = gdt_user_leader_scale(leader_scale, DEFAULT_GDT_DOT_LEADER_SCALE, args)?;
86    // Engine dot leaders are screen-space point sprites after an internal font_scale
87    // multiplier. Divide that out so KCL leaderScale stays stable across fontSize.
88    Ok(user_scale * gdt_dot_leader_normal_size() / gdt_font_scale(font_size, args)?)
89}
90
91fn gdt_dot_leader_normal_size() -> f32 {
92    gdt_font_scale_for_height_mm(GDT_DOT_LEADER_REFERENCE_FONT_SIZE_MM) * GDT_DOT_LEADER_REFERENCE_ENGINE_SCALE as f32
93}
94
95fn gdt_dimension_leader_scale(leader_scale: Option<&TyF64>, args: &Args) -> Result<f32, KclError> {
96    gdt_user_leader_scale(leader_scale, DEFAULT_GDT_DIMENSION_LEADER_SCALE, args)
97}
98
99fn set_engine_scene_units_cmd(cmd_id: uuid::Uuid, units: kcmc::units::UnitLength) -> ModelingCmdReq {
100    ModelingCmdReq {
101        cmd_id: cmd_id.into(),
102        cmd: ModelingCmd::from(mcmd::SetSceneUnits::builder().unit(units).build()),
103    }
104}
105
106#[derive(Debug, Clone)]
107enum DistanceEntity {
108    Face(Box<Face>),
109    TaggedFace(Box<TagIdentifier>),
110    Edge(EdgeReference),
111}
112
113#[derive(Debug, Clone, Copy)]
114struct DistanceEndpoint {
115    entity_id: uuid::Uuid,
116    entity_pos: KPoint2d<f64>,
117}
118
119fn add_gdt_annotation_artifact(exec_state: &mut ExecState, args: &Args, annotation_id: uuid::Uuid) {
120    exec_state.add_artifact(Artifact::GdtAnnotation(GdtAnnotationArtifact {
121        id: ArtifactId::new(annotation_id),
122        code_ref: CodeRef::placeholder(args.source_range),
123    }));
124}
125
126impl DistanceEntity {
127    async fn to_endpoint(&self, exec_state: &mut ExecState, args: &Args) -> Result<DistanceEndpoint, KclError> {
128        match self {
129            DistanceEntity::Face(face) => Ok(DistanceEndpoint {
130                entity_id: face.id,
131                entity_pos: KPoint2d { x: 0.5, y: 0.5 },
132            }),
133            DistanceEntity::TaggedFace(face) => Ok(DistanceEndpoint {
134                entity_id: args.get_adjacent_face_to_tag(exec_state, face, false).await?,
135                entity_pos: KPoint2d { x: 0.5, y: 0.5 },
136            }),
137            DistanceEntity::Edge(edge) => Ok(DistanceEndpoint {
138                entity_id: edge.get_engine_id(exec_state, args)?,
139                entity_pos: KPoint2d { x: 0.5, y: 0.0 },
140            }),
141        }
142    }
143}
144
145impl<'a> FromKclValue<'a> for DistanceEntity {
146    fn from_kcl_val(arg: &'a KclValue) -> Option<Self> {
147        match arg {
148            KclValue::Face { value } => Some(Self::Face(value.to_owned())),
149            KclValue::Uuid { value, .. } => Some(Self::Edge(EdgeReference::Uuid(*value))),
150            KclValue::TagIdentifier(value) => Some(Self::TaggedFace(value.to_owned())),
151            _ => None,
152        }
153    }
154}
155
156fn distance_entity_type() -> RuntimeType {
157    RuntimeType::Union(vec![
158        RuntimeType::face(),
159        RuntimeType::tagged_face(),
160        RuntimeType::edge(),
161    ])
162}
163
164pub async fn datum(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
165    let face: TagIdentifier = args.get_kw_arg("face", &RuntimeType::tagged_face(), exec_state)?;
166    let name: String = args.get_kw_arg("name", &RuntimeType::string(), exec_state)?;
167    let frame_position: Option<[TyF64; 2]> =
168        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
169    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
170    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
171    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
172
173    let annotation = inner_datum(
174        face,
175        name,
176        frame_position,
177        frame_plane,
178        leader_scale,
179        font_size,
180        exec_state,
181        &args,
182    )
183    .await?;
184    Ok(KclValue::GdtAnnotation {
185        value: Box::new(annotation),
186    })
187}
188
189#[allow(clippy::too_many_arguments)]
190async fn inner_datum(
191    face: TagIdentifier,
192    name: String,
193    frame_position: Option<[TyF64; 2]>,
194    frame_plane: Option<Plane>,
195    leader_scale: Option<TyF64>,
196    font_size: Option<TyF64>,
197    exec_state: &mut ExecState,
198    args: &Args,
199) -> Result<GdtAnnotation, KclError> {
200    const DATUM_LENGTH_ERROR: &str = "Datum name must be a single character.";
201    if name.len() > 1 {
202        return Err(KclError::new_semantic(KclErrorDetails::new(
203            DATUM_LENGTH_ERROR.to_owned(),
204            vec![args.source_range],
205        )));
206    }
207    let name_char = name.chars().next().ok_or_else(|| {
208        KclError::new_semantic(KclErrorDetails::new(
209            DATUM_LENGTH_ERROR.to_owned(),
210            vec![args.source_range],
211        ))
212    })?;
213    let mut frame_plane = if let Some(plane) = frame_plane {
214        plane
215    } else {
216        // No plane given. Use one of the standard planes.
217        xy_plane(exec_state, args).await?
218    };
219    ensure_sketch_plane_in_engine(
220        &mut frame_plane,
221        exec_state,
222        &args.ctx,
223        args.source_range,
224        args.node_path.clone(),
225    )
226    .await?;
227    let face_id = args.get_adjacent_face_to_tag(exec_state, &face, false).await?;
228    let meta = vec![Metadata::from(args.source_range)];
229    let annotation_id = exec_state.next_uuid();
230    let feature_control = AnnotationFeatureControl::builder()
231        .entity_id(face_id)
232        // Point to the center of the face.
233        .entity_pos(KPoint2d { x: 0.5, y: 0.5 })
234        .leader_type(AnnotationLineEnd::Dot)
235        .defined_datum(name_char)
236        .plane_id(frame_plane.id)
237        .offset(if let Some(offset) = &frame_position {
238            KPoint2d {
239                x: offset[0].to_mm(),
240                y: offset[1].to_mm(),
241            }
242        } else {
243            KPoint2d { x: 100.0, y: 100.0 }
244        })
245        .precision(0)
246        .font_scale(gdt_font_scale(font_size.as_ref(), args)?)
247        .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
248        .leader_scale(gdt_dot_leader_scale(leader_scale.as_ref(), font_size.as_ref(), args)?)
249        .build();
250    exec_state
251        .batch_modeling_cmd(
252            ModelingCmdMeta::from_args_id(exec_state, args, annotation_id),
253            ModelingCmd::from(
254                mcmd::NewAnnotation::builder()
255                    .options(AnnotationOptions::builder().feature_control(feature_control).build())
256                    .clobber(false)
257                    .annotation_type(AnnotationType::T3D)
258                    .build(),
259            ),
260        )
261        .await?;
262    add_gdt_annotation_artifact(exec_state, args, annotation_id);
263    Ok(GdtAnnotation {
264        id: annotation_id,
265        meta,
266    })
267}
268
269pub async fn flatness(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
270    let faces: Vec<TagIdentifier> = args.get_kw_arg(
271        "faces",
272        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
273        exec_state,
274    )?;
275    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
276    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
277    let frame_position: Option<[TyF64; 2]> =
278        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
279    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
280    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
281    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
282
283    let annotations = inner_flatness(
284        faces,
285        tolerance,
286        precision,
287        frame_position,
288        frame_plane,
289        leader_scale,
290        font_size,
291        exec_state,
292        &args,
293    )
294    .await?;
295    Ok(annotations.into())
296}
297
298#[allow(clippy::too_many_arguments)]
299async fn inner_flatness(
300    faces: Vec<TagIdentifier>,
301    tolerance: TyF64,
302    precision: Option<TyF64>,
303    frame_position: Option<[TyF64; 2]>,
304    frame_plane: Option<Plane>,
305    leader_scale: Option<TyF64>,
306    font_size: Option<TyF64>,
307    exec_state: &mut ExecState,
308    args: &Args,
309) -> Result<Vec<GdtAnnotation>, KclError> {
310    let precision = resolve_precision(precision, args)?;
311    let mut frame_plane = if let Some(plane) = frame_plane {
312        plane
313    } else {
314        // No plane given. Use one of the standard planes.
315        xy_plane(exec_state, args).await?
316    };
317    ensure_sketch_plane_in_engine(
318        &mut frame_plane,
319        exec_state,
320        &args.ctx,
321        args.source_range,
322        args.node_path.clone(),
323    )
324    .await?;
325    let mut annotations = Vec::with_capacity(faces.len());
326    let display_units = exec_state.length_unit();
327    for face in &faces {
328        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
329        let meta = vec![Metadata::from(args.source_range)];
330        let annotation_id = exec_state.next_uuid();
331        let feature_control = AnnotationFeatureControl::builder()
332            .entity_id(face_id)
333            // Point to the center of the face.
334            .entity_pos(KPoint2d { x: 0.5, y: 0.5 })
335            .leader_type(AnnotationLineEnd::Dot)
336            .control_frame(
337                AnnotationMbdControlFrame::builder()
338                    .symbol(MbdSymbol::Flatness)
339                    .tolerance(tolerance.to_length_units(display_units))
340                    .build(),
341            )
342            .plane_id(frame_plane.id)
343            .offset(if let Some(offset) = &frame_position {
344                KPoint2d {
345                    x: offset[0].to_mm(),
346                    y: offset[1].to_mm(),
347                }
348            } else {
349                KPoint2d { x: 100.0, y: 100.0 }
350            })
351            .precision(precision)
352            .font_scale(gdt_font_scale(font_size.as_ref(), args)?)
353            .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
354            .leader_scale(gdt_dot_leader_scale(leader_scale.as_ref(), font_size.as_ref(), args)?)
355            .build();
356        let options = AnnotationOptions::builder().feature_control(feature_control).build();
357        exec_state
358            .batch_modeling_cmd(
359                ModelingCmdMeta::from_args_id(exec_state, args, annotation_id),
360                ModelingCmd::from(
361                    mcmd::NewAnnotation::builder()
362                        .options(options)
363                        .clobber(false)
364                        .annotation_type(AnnotationType::T3D)
365                        .build(),
366                ),
367            )
368            .await?;
369        add_gdt_annotation_artifact(exec_state, args, annotation_id);
370        annotations.push(GdtAnnotation {
371            id: annotation_id,
372            meta,
373        });
374    }
375    Ok(annotations)
376}
377
378pub async fn profile(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
379    let edges: Vec<EdgeReference> = args.get_kw_arg(
380        "edges",
381        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
382        exec_state,
383    )?;
384    let datums: Option<Vec<String>> = args.get_kw_arg_opt(
385        "datums",
386        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
387        exec_state,
388    )?;
389    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
390    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
391    let frame_position: Option<[TyF64; 2]> =
392        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
393    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
394    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
395    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
396
397    let annotations = inner_profile(
398        edges,
399        datums,
400        tolerance,
401        precision,
402        frame_position,
403        frame_plane,
404        leader_scale,
405        font_size,
406        exec_state,
407        &args,
408    )
409    .await?;
410    Ok(annotations.into())
411}
412
413#[allow(clippy::too_many_arguments)]
414async fn inner_profile(
415    edges: Vec<EdgeReference>,
416    datums: Option<Vec<String>>,
417    tolerance: TyF64,
418    precision: Option<TyF64>,
419    frame_position: Option<[TyF64; 2]>,
420    frame_plane: Option<Plane>,
421    leader_scale: Option<TyF64>,
422    font_size: Option<TyF64>,
423    exec_state: &mut ExecState,
424    args: &Args,
425) -> Result<Vec<GdtAnnotation>, KclError> {
426    let precision = resolve_precision(precision, args)?;
427    let datums = resolve_datums(datums, args, "Profile")?;
428    let mut frame_plane = if let Some(plane) = frame_plane {
429        plane
430    } else {
431        xy_plane(exec_state, args).await?
432    };
433    ensure_sketch_plane_in_engine(
434        &mut frame_plane,
435        exec_state,
436        &args.ctx,
437        args.source_range,
438        args.node_path.clone(),
439    )
440    .await?;
441
442    let mut annotations = Vec::with_capacity(edges.len());
443    for edge in &edges {
444        let edge_id = edge.get_engine_id(exec_state, args)?;
445        create_feature_control_annotation(
446            edge_id,
447            MbdSymbol::ProfileOfLine,
448            &tolerance,
449            &datums,
450            precision,
451            frame_position.as_ref(),
452            frame_plane.id,
453            leader_scale.as_ref(),
454            font_size.as_ref(),
455            exec_state,
456            args,
457            &mut annotations,
458        )
459        .await?;
460    }
461    Ok(annotations)
462}
463
464pub async fn position(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
465    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
466        "faces",
467        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
468        exec_state,
469    )?;
470    let edges: Option<Vec<EdgeReference>> = args.get_kw_arg_opt(
471        "edges",
472        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
473        exec_state,
474    )?;
475    let datums: Option<Vec<String>> = args.get_kw_arg_opt(
476        "datums",
477        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
478        exec_state,
479    )?;
480    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
481    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
482    let frame_position: Option<[TyF64; 2]> =
483        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
484    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
485    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
486    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
487
488    let annotations = inner_position(
489        faces.unwrap_or_default(),
490        edges.unwrap_or_default(),
491        tolerance,
492        datums,
493        precision,
494        frame_position,
495        frame_plane,
496        leader_scale,
497        font_size,
498        exec_state,
499        &args,
500    )
501    .await?;
502    Ok(annotations.into())
503}
504
505#[allow(clippy::too_many_arguments)]
506async fn inner_position(
507    faces: Vec<TagIdentifier>,
508    edges: Vec<EdgeReference>,
509    tolerance: TyF64,
510    datums: Option<Vec<String>>,
511    precision: Option<TyF64>,
512    frame_position: Option<[TyF64; 2]>,
513    frame_plane: Option<Plane>,
514    leader_scale: Option<TyF64>,
515    font_size: Option<TyF64>,
516    exec_state: &mut ExecState,
517    args: &Args,
518) -> Result<Vec<GdtAnnotation>, KclError> {
519    if faces.is_empty() && edges.is_empty() {
520        return Err(KclError::new_semantic(KclErrorDetails::new(
521            "Position requires at least one face or edge.".to_owned(),
522            vec![args.source_range],
523        )));
524    }
525
526    let precision = resolve_precision(precision, args)?;
527    let datums = resolve_datums(datums, args, "Position")?;
528    let mut frame_plane = if let Some(plane) = frame_plane {
529        plane
530    } else {
531        xy_plane(exec_state, args).await?
532    };
533    ensure_sketch_plane_in_engine(
534        &mut frame_plane,
535        exec_state,
536        &args.ctx,
537        args.source_range,
538        args.node_path.clone(),
539    )
540    .await?;
541
542    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
543    for face in &faces {
544        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
545        create_feature_control_annotation(
546            face_id,
547            MbdSymbol::Position,
548            &tolerance,
549            &datums,
550            precision,
551            frame_position.as_ref(),
552            frame_plane.id,
553            leader_scale.as_ref(),
554            font_size.as_ref(),
555            exec_state,
556            args,
557            &mut annotations,
558        )
559        .await?;
560    }
561    for edge in &edges {
562        let edge_id = edge.get_engine_id(exec_state, args)?;
563        create_feature_control_annotation(
564            edge_id,
565            MbdSymbol::Position,
566            &tolerance,
567            &datums,
568            precision,
569            frame_position.as_ref(),
570            frame_plane.id,
571            leader_scale.as_ref(),
572            font_size.as_ref(),
573            exec_state,
574            args,
575            &mut annotations,
576        )
577        .await?;
578    }
579    Ok(annotations)
580}
581
582pub async fn distance(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
583    let from: Option<DistanceEntity> = args.get_kw_arg_opt("from", &distance_entity_type(), exec_state)?;
584    let to: Option<DistanceEntity> = args.get_kw_arg_opt("to", &distance_entity_type(), exec_state)?;
585    let edges: Option<Vec<EdgeReference>> = args.get_kw_arg_opt(
586        "edges",
587        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
588        exec_state,
589    )?;
590    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
591    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
592    let frame_position: Option<[TyF64; 2]> =
593        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
594    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
595    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
596    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
597
598    let annotations = inner_distance(
599        from,
600        to,
601        edges.unwrap_or_default(),
602        tolerance,
603        precision,
604        frame_position,
605        frame_plane,
606        leader_scale,
607        font_size,
608        exec_state,
609        &args,
610    )
611    .await?;
612    Ok(annotations.into())
613}
614
615#[allow(clippy::too_many_arguments)]
616async fn inner_distance(
617    from: Option<DistanceEntity>,
618    to: Option<DistanceEntity>,
619    edges: Vec<EdgeReference>,
620    tolerance: TyF64,
621    precision: Option<TyF64>,
622    frame_position: Option<[TyF64; 2]>,
623    frame_plane: Option<Plane>,
624    leader_scale: Option<TyF64>,
625    font_size: Option<TyF64>,
626    exec_state: &mut ExecState,
627    args: &Args,
628) -> Result<Vec<GdtAnnotation>, KclError> {
629    let precision = resolve_precision(precision, args)?;
630    let mut frame_plane = if let Some(plane) = frame_plane {
631        plane
632    } else {
633        xy_plane(exec_state, args).await?
634    };
635    ensure_sketch_plane_in_engine(
636        &mut frame_plane,
637        exec_state,
638        &args.ctx,
639        args.source_range,
640        args.node_path.clone(),
641    )
642    .await?;
643
644    if from.is_some() || to.is_some() {
645        if !edges.is_empty() {
646            return Err(KclError::new_semantic(KclErrorDetails::new(
647                "Distance cannot combine `from`/`to` with `edges`.".to_owned(),
648                vec![args.source_range],
649            )));
650        }
651
652        let (Some(from), Some(to)) = (from, to) else {
653            return Err(KclError::new_semantic(KclErrorDetails::new(
654                "Distance requires both `from` and `to` when measuring between entities.".to_owned(),
655                vec![args.source_range],
656            )));
657        };
658
659        let from = from.to_endpoint(exec_state, args).await?;
660        let to = to.to_endpoint(exec_state, args).await?;
661        let mut annotations = Vec::with_capacity(1);
662        create_basic_distance_annotation(
663            from,
664            to,
665            &tolerance,
666            precision,
667            frame_position.as_ref(),
668            frame_plane.id,
669            leader_scale.as_ref(),
670            font_size.as_ref(),
671            exec_state,
672            args,
673            &mut annotations,
674        )
675        .await?;
676        return Ok(annotations);
677    }
678
679    if edges.is_empty() {
680        return Err(KclError::new_semantic(KclErrorDetails::new(
681            "Distance requires either `edges` or both `from` and `to`.".to_owned(),
682            vec![args.source_range],
683        )));
684    }
685
686    let mut annotations = Vec::with_capacity(edges.len());
687    for edge in &edges {
688        let edge_id = edge.get_engine_id(exec_state, args)?;
689        create_basic_distance_annotation(
690            DistanceEndpoint {
691                entity_id: edge_id,
692                entity_pos: KPoint2d { x: 0.0, y: 0.0 },
693            },
694            DistanceEndpoint {
695                entity_id: edge_id,
696                entity_pos: KPoint2d { x: 1.0, y: 0.0 },
697            },
698            &tolerance,
699            precision,
700            frame_position.as_ref(),
701            frame_plane.id,
702            leader_scale.as_ref(),
703            font_size.as_ref(),
704            exec_state,
705            args,
706            &mut annotations,
707        )
708        .await?;
709    }
710    Ok(annotations)
711}
712
713#[allow(clippy::too_many_arguments)]
714async fn create_basic_distance_annotation(
715    from: DistanceEndpoint,
716    to: DistanceEndpoint,
717    tolerance: &TyF64,
718    precision: u32,
719    frame_position: Option<&[TyF64; 2]>,
720    frame_plane_id: uuid::Uuid,
721    leader_scale: Option<&TyF64>,
722    font_size: Option<&TyF64>,
723    exec_state: &mut ExecState,
724    args: &Args,
725    annotations: &mut Vec<GdtAnnotation>,
726) -> Result<(), KclError> {
727    let meta = vec![Metadata::from(args.source_range)];
728    let annotation_id = exec_state.next_uuid();
729    let display_units = exec_state.length_unit();
730    let dimension = AnnotationBasicDimension::builder()
731        .from_entity_id(from.entity_id)
732        .from_entity_pos(from.entity_pos)
733        .to_entity_id(to.entity_id)
734        .to_entity_pos(to.entity_pos)
735        .dimension(
736            AnnotationMbdBasicDimension::builder()
737                .tolerance(tolerance.to_length_units(display_units))
738                .build(),
739        )
740        .plane_id(frame_plane_id)
741        .offset(if let Some(offset) = frame_position {
742            KPoint2d {
743                x: offset[0].to_mm(),
744                y: offset[1].to_mm(),
745            }
746        } else {
747            KPoint2d { x: 100.0, y: 100.0 }
748        })
749        .precision(precision)
750        .font_scale(gdt_font_scale(font_size, args)?)
751        .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
752        .arrow_scale(gdt_dimension_leader_scale(leader_scale, args)?)
753        .build();
754    let options = AnnotationOptions::builder().dimension(dimension).build();
755    // The engine formats auto-measured MBD distance labels from its current scene units.
756    // Queue the unit switch, annotation, and reset together so other module commands
757    // cannot interleave while the engine's MBD display units are flipped.
758    let use_display_units = display_units != kcmc::units::UnitLength::Millimeters;
759    let annotation_cmd = ModelingCmd::from(
760        mcmd::NewAnnotation::builder()
761            .options(options)
762            .clobber(false)
763            .annotation_type(AnnotationType::T3D)
764            .build(),
765    );
766    let cmd_meta = ModelingCmdMeta::from_args_id(exec_state, args, annotation_id);
767    if use_display_units {
768        let set_units_id = exec_state.next_uuid();
769        let reset_units_id = exec_state.next_uuid();
770        exec_state
771            .batch_modeling_cmds(
772                cmd_meta,
773                &[
774                    set_engine_scene_units_cmd(set_units_id, display_units),
775                    ModelingCmdReq {
776                        cmd_id: annotation_id.into(),
777                        cmd: annotation_cmd,
778                    },
779                    set_engine_scene_units_cmd(reset_units_id, kcmc::units::UnitLength::Millimeters),
780                ],
781            )
782            .await?;
783    } else {
784        exec_state.batch_modeling_cmd(cmd_meta, annotation_cmd).await?;
785    }
786    add_gdt_annotation_artifact(exec_state, args, annotation_id);
787    annotations.push(GdtAnnotation {
788        id: annotation_id,
789        meta,
790    });
791    Ok(())
792}
793
794pub async fn perpendicularity(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
795    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
796        "faces",
797        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
798        exec_state,
799    )?;
800    let edges: Option<Vec<EdgeReference>> = args.get_kw_arg_opt(
801        "edges",
802        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
803        exec_state,
804    )?;
805    let datums: Option<Vec<String>> = args.get_kw_arg_opt(
806        "datums",
807        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
808        exec_state,
809    )?;
810    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
811    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
812    let frame_position: Option<[TyF64; 2]> =
813        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
814    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
815    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
816    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
817
818    let annotations = inner_perpendicularity(
819        faces.unwrap_or_default(),
820        edges.unwrap_or_default(),
821        datums,
822        tolerance,
823        precision,
824        frame_position,
825        frame_plane,
826        leader_scale,
827        font_size,
828        exec_state,
829        &args,
830    )
831    .await?;
832    Ok(annotations.into())
833}
834
835#[allow(clippy::too_many_arguments)]
836async fn inner_perpendicularity(
837    faces: Vec<TagIdentifier>,
838    edges: Vec<EdgeReference>,
839    datums: Option<Vec<String>>,
840    tolerance: TyF64,
841    precision: Option<TyF64>,
842    frame_position: Option<[TyF64; 2]>,
843    frame_plane: Option<Plane>,
844    leader_scale: Option<TyF64>,
845    font_size: Option<TyF64>,
846    exec_state: &mut ExecState,
847    args: &Args,
848) -> Result<Vec<GdtAnnotation>, KclError> {
849    if faces.is_empty() && edges.is_empty() {
850        return Err(KclError::new_semantic(KclErrorDetails::new(
851            "Perpendicularity requires at least one face or edge.".to_owned(),
852            vec![args.source_range],
853        )));
854    }
855
856    let precision = resolve_precision(precision, args)?;
857    let datums = resolve_datums(datums, args, "Perpendicularity")?;
858    let mut frame_plane = if let Some(plane) = frame_plane {
859        plane
860    } else {
861        xy_plane(exec_state, args).await?
862    };
863    ensure_sketch_plane_in_engine(
864        &mut frame_plane,
865        exec_state,
866        &args.ctx,
867        args.source_range,
868        args.node_path.clone(),
869    )
870    .await?;
871
872    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
873    for face in &faces {
874        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
875        create_feature_control_annotation(
876            face_id,
877            MbdSymbol::Perpendicularity,
878            &tolerance,
879            &datums,
880            precision,
881            frame_position.as_ref(),
882            frame_plane.id,
883            leader_scale.as_ref(),
884            font_size.as_ref(),
885            exec_state,
886            args,
887            &mut annotations,
888        )
889        .await?;
890    }
891    for edge in &edges {
892        let edge_id = edge.get_engine_id(exec_state, args)?;
893        create_feature_control_annotation(
894            edge_id,
895            MbdSymbol::Perpendicularity,
896            &tolerance,
897            &datums,
898            precision,
899            frame_position.as_ref(),
900            frame_plane.id,
901            leader_scale.as_ref(),
902            font_size.as_ref(),
903            exec_state,
904            args,
905            &mut annotations,
906        )
907        .await?;
908    }
909
910    Ok(annotations)
911}
912
913pub async fn parallelism(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
914    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
915        "faces",
916        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
917        exec_state,
918    )?;
919    let edges: Option<Vec<EdgeReference>> = args.get_kw_arg_opt(
920        "edges",
921        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
922        exec_state,
923    )?;
924    let datums: Option<Vec<String>> = args.get_kw_arg_opt(
925        "datums",
926        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
927        exec_state,
928    )?;
929    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
930    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
931    let frame_position: Option<[TyF64; 2]> =
932        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
933    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
934    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
935    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
936
937    let annotations = inner_parallelism(
938        faces.unwrap_or_default(),
939        edges.unwrap_or_default(),
940        datums,
941        tolerance,
942        precision,
943        frame_position,
944        frame_plane,
945        leader_scale,
946        font_size,
947        exec_state,
948        &args,
949    )
950    .await?;
951    Ok(annotations.into())
952}
953
954#[allow(clippy::too_many_arguments)]
955async fn inner_parallelism(
956    faces: Vec<TagIdentifier>,
957    edges: Vec<EdgeReference>,
958    datums: Option<Vec<String>>,
959    tolerance: TyF64,
960    precision: Option<TyF64>,
961    frame_position: Option<[TyF64; 2]>,
962    frame_plane: Option<Plane>,
963    leader_scale: Option<TyF64>,
964    font_size: Option<TyF64>,
965    exec_state: &mut ExecState,
966    args: &Args,
967) -> Result<Vec<GdtAnnotation>, KclError> {
968    if faces.is_empty() && edges.is_empty() {
969        return Err(KclError::new_semantic(KclErrorDetails::new(
970            "Parallelism requires at least one face or edge.".to_owned(),
971            vec![args.source_range],
972        )));
973    }
974
975    let precision = resolve_precision(precision, args)?;
976    let datums = resolve_datums(datums, args, "Parallelism")?;
977    let mut frame_plane = if let Some(plane) = frame_plane {
978        plane
979    } else {
980        xy_plane(exec_state, args).await?
981    };
982    ensure_sketch_plane_in_engine(
983        &mut frame_plane,
984        exec_state,
985        &args.ctx,
986        args.source_range,
987        args.node_path.clone(),
988    )
989    .await?;
990
991    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
992    for face in &faces {
993        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
994        create_feature_control_annotation(
995            face_id,
996            MbdSymbol::Parallelism,
997            &tolerance,
998            &datums,
999            precision,
1000            frame_position.as_ref(),
1001            frame_plane.id,
1002            leader_scale.as_ref(),
1003            font_size.as_ref(),
1004            exec_state,
1005            args,
1006            &mut annotations,
1007        )
1008        .await?;
1009    }
1010    for edge in &edges {
1011        let edge_id = edge.get_engine_id(exec_state, args)?;
1012        create_feature_control_annotation(
1013            edge_id,
1014            MbdSymbol::Parallelism,
1015            &tolerance,
1016            &datums,
1017            precision,
1018            frame_position.as_ref(),
1019            frame_plane.id,
1020            leader_scale.as_ref(),
1021            font_size.as_ref(),
1022            exec_state,
1023            args,
1024            &mut annotations,
1025        )
1026        .await?;
1027    }
1028
1029    Ok(annotations)
1030}
1031
1032pub async fn annotation(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
1033    let annotation: String = args.get_kw_arg("annotation", &RuntimeType::string(), exec_state)?;
1034    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
1035        "faces",
1036        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
1037        exec_state,
1038    )?;
1039    let edges: Option<Vec<EdgeReference>> = args.get_kw_arg_opt(
1040        "edges",
1041        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
1042        exec_state,
1043    )?;
1044    let frame_position: Option<[TyF64; 2]> =
1045        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
1046    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
1047    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
1048    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
1049
1050    let annotations = inner_annotation(
1051        annotation,
1052        faces.unwrap_or_default(),
1053        edges.unwrap_or_default(),
1054        frame_position,
1055        frame_plane,
1056        leader_scale,
1057        font_size,
1058        exec_state,
1059        &args,
1060    )
1061    .await?;
1062    Ok(annotations.into())
1063}
1064
1065#[allow(clippy::too_many_arguments)]
1066async fn inner_annotation(
1067    annotation: String,
1068    faces: Vec<TagIdentifier>,
1069    edges: Vec<EdgeReference>,
1070    frame_position: Option<[TyF64; 2]>,
1071    frame_plane: Option<Plane>,
1072    leader_scale: Option<TyF64>,
1073    font_size: Option<TyF64>,
1074    exec_state: &mut ExecState,
1075    args: &Args,
1076) -> Result<Vec<GdtAnnotation>, KclError> {
1077    if annotation.is_empty() {
1078        return Err(KclError::new_semantic(KclErrorDetails::new(
1079            "Annotation text must not be empty.".to_owned(),
1080            vec![args.source_range],
1081        )));
1082    }
1083    if faces.is_empty() && edges.is_empty() {
1084        return Err(KclError::new_semantic(KclErrorDetails::new(
1085            "Annotation requires at least one face or edge.".to_owned(),
1086            vec![args.source_range],
1087        )));
1088    }
1089
1090    let mut frame_plane = if let Some(plane) = frame_plane {
1091        plane
1092    } else {
1093        xy_plane(exec_state, args).await?
1094    };
1095    ensure_sketch_plane_in_engine(
1096        &mut frame_plane,
1097        exec_state,
1098        &args.ctx,
1099        args.source_range,
1100        args.node_path.clone(),
1101    )
1102    .await?;
1103
1104    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
1105    for face in &faces {
1106        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
1107        create_annotation(
1108            face_id,
1109            &annotation,
1110            frame_position.as_ref(),
1111            frame_plane.id,
1112            leader_scale.as_ref(),
1113            font_size.as_ref(),
1114            exec_state,
1115            args,
1116            &mut annotations,
1117        )
1118        .await?;
1119    }
1120    for edge in &edges {
1121        let edge_id = edge.get_engine_id(exec_state, args)?;
1122        create_annotation(
1123            edge_id,
1124            &annotation,
1125            frame_position.as_ref(),
1126            frame_plane.id,
1127            leader_scale.as_ref(),
1128            font_size.as_ref(),
1129            exec_state,
1130            args,
1131            &mut annotations,
1132        )
1133        .await?;
1134    }
1135
1136    Ok(annotations)
1137}
1138
1139fn resolve_precision(precision: Option<TyF64>, args: &Args) -> Result<u32, KclError> {
1140    if let Some(precision) = precision {
1141        let rounded = precision.n.round();
1142        if !(0.0..=9.0).contains(&rounded) {
1143            return Err(KclError::new_semantic(KclErrorDetails::new(
1144                "Precision must be between 0 and 9".to_owned(),
1145                vec![args.source_range],
1146            )));
1147        }
1148        Ok(rounded as u32)
1149    } else {
1150        Ok(3)
1151    }
1152}
1153
1154#[allow(clippy::too_many_arguments)]
1155async fn create_feature_control_annotation(
1156    entity_id: uuid::Uuid,
1157    symbol: MbdSymbol,
1158    tolerance: &TyF64,
1159    datums: &[char],
1160    precision: u32,
1161    frame_position: Option<&[TyF64; 2]>,
1162    frame_plane_id: uuid::Uuid,
1163    leader_scale: Option<&TyF64>,
1164    font_size: Option<&TyF64>,
1165    exec_state: &mut ExecState,
1166    args: &Args,
1167    annotations: &mut Vec<GdtAnnotation>,
1168) -> Result<(), KclError> {
1169    let meta = vec![Metadata::from(args.source_range)];
1170    let annotation_id = exec_state.next_uuid();
1171    let display_units = exec_state.length_unit();
1172    let control_frame = gdt_control_frame(symbol, tolerance.to_length_units(display_units), datums);
1173    let feature_control = AnnotationFeatureControl::builder()
1174        .entity_id(entity_id)
1175        .entity_pos(KPoint2d { x: 0.5, y: 0.5 })
1176        .leader_type(AnnotationLineEnd::Dot)
1177        .control_frame(control_frame)
1178        .plane_id(frame_plane_id)
1179        .offset(if let Some(offset) = frame_position {
1180            KPoint2d {
1181                x: offset[0].to_mm(),
1182                y: offset[1].to_mm(),
1183            }
1184        } else {
1185            KPoint2d { x: 100.0, y: 100.0 }
1186        })
1187        .precision(precision)
1188        .font_scale(gdt_font_scale(font_size, args)?)
1189        .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
1190        .leader_scale(gdt_dot_leader_scale(leader_scale, font_size, args)?)
1191        .build();
1192    let options = AnnotationOptions::builder().feature_control(feature_control).build();
1193    exec_state
1194        .batch_modeling_cmd(
1195            ModelingCmdMeta::from_args_id(exec_state, args, annotation_id),
1196            ModelingCmd::from(
1197                mcmd::NewAnnotation::builder()
1198                    .options(options)
1199                    .clobber(false)
1200                    .annotation_type(AnnotationType::T3D)
1201                    .build(),
1202            ),
1203        )
1204        .await?;
1205    add_gdt_annotation_artifact(exec_state, args, annotation_id);
1206    annotations.push(GdtAnnotation {
1207        id: annotation_id,
1208        meta,
1209    });
1210    Ok(())
1211}
1212
1213fn gdt_control_frame(symbol: MbdSymbol, tolerance: f64, datums: &[char]) -> AnnotationMbdControlFrame {
1214    match datums {
1215        [] => AnnotationMbdControlFrame::builder()
1216            .symbol(symbol)
1217            .tolerance(tolerance)
1218            .build(),
1219        [primary] => AnnotationMbdControlFrame::builder()
1220            .symbol(symbol)
1221            .tolerance(tolerance)
1222            .primary_datum(*primary)
1223            .build(),
1224        [primary, secondary] => AnnotationMbdControlFrame::builder()
1225            .symbol(symbol)
1226            .tolerance(tolerance)
1227            .primary_datum(*primary)
1228            .secondary_datum(*secondary)
1229            .build(),
1230        [primary, secondary, tertiary] => AnnotationMbdControlFrame::builder()
1231            .symbol(symbol)
1232            .tolerance(tolerance)
1233            .primary_datum(*primary)
1234            .secondary_datum(*secondary)
1235            .tertiary_datum(*tertiary)
1236            .build(),
1237        _ => unreachable!("resolve_datums rejects more than three datums"),
1238    }
1239}
1240
1241#[allow(clippy::too_many_arguments)]
1242async fn create_annotation(
1243    entity_id: uuid::Uuid,
1244    annotation: &str,
1245    frame_position: Option<&[TyF64; 2]>,
1246    frame_plane_id: uuid::Uuid,
1247    leader_scale: Option<&TyF64>,
1248    font_size: Option<&TyF64>,
1249    exec_state: &mut ExecState,
1250    args: &Args,
1251    annotations: &mut Vec<GdtAnnotation>,
1252) -> Result<(), KclError> {
1253    let meta = vec![Metadata::from(args.source_range)];
1254    let annotation_id = exec_state.next_uuid();
1255    let feature_control = AnnotationFeatureControl::builder()
1256        .entity_id(entity_id)
1257        .entity_pos(KPoint2d { x: 0.5, y: 0.5 })
1258        .leader_type(AnnotationLineEnd::Dot)
1259        .prefix(annotation.to_owned())
1260        .plane_id(frame_plane_id)
1261        .offset(if let Some(offset) = frame_position {
1262            KPoint2d {
1263                x: offset[0].to_mm(),
1264                y: offset[1].to_mm(),
1265            }
1266        } else {
1267            KPoint2d { x: 100.0, y: 100.0 }
1268        })
1269        .precision(0)
1270        .font_scale(gdt_font_scale(font_size, args)?)
1271        .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
1272        .leader_scale(gdt_dot_leader_scale(leader_scale, font_size, args)?)
1273        .build();
1274    let options = AnnotationOptions::builder().feature_control(feature_control).build();
1275    exec_state
1276        .batch_modeling_cmd(
1277            ModelingCmdMeta::from_args_id(exec_state, args, annotation_id),
1278            ModelingCmd::from(
1279                mcmd::NewAnnotation::builder()
1280                    .options(options)
1281                    .clobber(false)
1282                    .annotation_type(AnnotationType::T3D)
1283                    .build(),
1284            ),
1285        )
1286        .await?;
1287    add_gdt_annotation_artifact(exec_state, args, annotation_id);
1288    annotations.push(GdtAnnotation {
1289        id: annotation_id,
1290        meta,
1291    });
1292    Ok(())
1293}
1294
1295fn resolve_datums(datums: Option<Vec<String>>, args: &Args, annotation_name: &str) -> Result<Vec<char>, KclError> {
1296    let datums = datums.unwrap_or_default();
1297    if datums.len() > 3 {
1298        return Err(KclError::new_semantic(KclErrorDetails::new(
1299            format!("{annotation_name} datums must include at most three names."),
1300            vec![args.source_range],
1301        )));
1302    }
1303
1304    let mut resolved = Vec::with_capacity(datums.len());
1305    for datum in &datums {
1306        let mut chars = datum.chars();
1307        let Some(name) = chars.next() else {
1308            return Err(KclError::new_semantic(KclErrorDetails::new(
1309                format!("{annotation_name} datum names must be a single character."),
1310                vec![args.source_range],
1311            )));
1312        };
1313        if chars.next().is_some() {
1314            return Err(KclError::new_semantic(KclErrorDetails::new(
1315                format!("{annotation_name} datum names must be a single character."),
1316                vec![args.source_range],
1317            )));
1318        }
1319        resolved.push(name);
1320    }
1321
1322    Ok(resolved)
1323}
1324
1325/// Get the XY plane by evaluating the `XY` expression so that it's the same as
1326/// if the user specified `XY`.
1327async fn xy_plane(exec_state: &mut ExecState, args: &Args) -> Result<Plane, KclError> {
1328    let plane_ast = plane_ast("XY", args.source_range);
1329    let metadata = Metadata::from(args.source_range);
1330    let plane_value = args
1331        .ctx
1332        .execute_expr(&plane_ast, exec_state, &metadata, &[], StatementKind::Expression)
1333        .await?;
1334    let plane_value = match plane_value.control {
1335        ControlFlowKind::Continue => plane_value.into_value(),
1336        ControlFlowKind::Exit => {
1337            let message = "Early return inside plane value is currently not supported".to_owned();
1338            debug_assert!(false, "{}", &message);
1339            return Err(KclError::new_internal(KclErrorDetails::new(
1340                message,
1341                vec![args.source_range],
1342            )));
1343        }
1344    };
1345    Ok(plane_value
1346        .as_plane()
1347        .ok_or_else(|| {
1348            KclError::new_internal(KclErrorDetails::new(
1349                "Expected XY plane to be defined".to_owned(),
1350                vec![args.source_range],
1351            ))
1352        })?
1353        .clone())
1354}
1355
1356/// An AST node for a plane with the given name.
1357fn plane_ast(plane_name: &str, range: SourceRange) -> ast::Node<ast::Expr> {
1358    ast::Node::new(
1359        ast::Expr::Name(Box::new(ast::Node::new(
1360            ast::Name {
1361                name: ast::Identifier::new(plane_name),
1362                path: Vec::new(),
1363                // TODO: We may want to set this to true once we implement it to
1364                // prevent it breaking if users redefine the identifier.
1365                abs_path: false,
1366                digest: None,
1367            },
1368            range.start(),
1369            range.end(),
1370            range.module_id(),
1371        ))),
1372        range.start(),
1373        range.end(),
1374        range.module_id(),
1375    )
1376}
1377
1378#[cfg(test)]
1379mod tests {
1380    use super::*;
1381    use crate::ExecutorContext;
1382    use crate::execution::Artifact;
1383    use crate::execution::ExecutorSettings;
1384    use crate::execution::MockConfig;
1385    use crate::execution::parse_execute;
1386
1387    const GDT_DISTANCE_KCL_TEMPLATE: &str = r#"
1388@settings(defaultLengthUnit = __UNIT__, kclVersion = 2)
1389
1390sketch001 = sketch(on = XY) {
1391  line1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
1392  line2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
1393  line3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
1394  line4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
1395  coincident([line1.end, line2.start])
1396  coincident([line2.end, line3.start])
1397  coincident([line3.end, line4.start])
1398  coincident([line4.end, line1.start])
1399  parallel([line2, line4])
1400  parallel([line3, line1])
1401  perpendicular([line1, line2])
1402  horizontal(line3)
1403}
1404
1405region001 = region(point = [5mm, 5mm], sketch = sketch001)
1406extrude001 = extrude(region001, length = 10mm)
1407gdt::distance(
1408  edges = [
1409    getCommonEdge(faces = [
1410      region001.tags.line4,
1411      region001.tags.line1
1412    ])
1413  ],
1414  tolerance = __TOLERANCE__,
1415  framePosition = __FRAME_POSITION__,
1416  fontSize = 2in,
1417)
1418"#;
1419
1420    const GDT_FLATNESS_KCL_TEMPLATE: &str = r#"
1421@settings(defaultLengthUnit = __UNIT__, kclVersion = 2)
1422
1423sketch001 = sketch(on = XY) {
1424  line1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
1425  line2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
1426  line3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
1427  line4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
1428  coincident([line1.end, line2.start])
1429  coincident([line2.end, line3.start])
1430  coincident([line3.end, line4.start])
1431  coincident([line4.end, line1.start])
1432  parallel([line2, line4])
1433  parallel([line3, line1])
1434  perpendicular([line1, line2])
1435  horizontal(line3)
1436}
1437
1438region001 = region(point = [5mm, 5mm], sketch = sketch001)
1439extrude001 = extrude(region001, length = 10mm, tagEnd = $capEnd001)
1440gdt::flatness(
1441  faces = [capEnd001],
1442  tolerance = __TOLERANCE__,
1443  framePosition = __FRAME_POSITION__,
1444  framePlane = XZ,
1445  fontSize = 2in,
1446)
1447"#;
1448
1449    fn gdt_distance_kcl(unit: &str, tolerance: &str, frame_position: &str) -> String {
1450        GDT_DISTANCE_KCL_TEMPLATE
1451            .replace("__UNIT__", unit)
1452            .replace("__TOLERANCE__", tolerance)
1453            .replace("__FRAME_POSITION__", frame_position)
1454    }
1455
1456    fn gdt_flatness_kcl(unit: &str, tolerance: &str, frame_position: &str) -> String {
1457        GDT_FLATNESS_KCL_TEMPLATE
1458            .replace("__UNIT__", unit)
1459            .replace("__TOLERANCE__", tolerance)
1460            .replace("__FRAME_POSITION__", frame_position)
1461    }
1462
1463    async fn gdt_commands(code: &str) -> Vec<ModelingCmd> {
1464        let result = parse_execute(code).await.unwrap();
1465        result
1466            .root_module_artifact_commands()
1467            .iter()
1468            .map(|artifact_command| artifact_command.command.clone())
1469            .collect()
1470    }
1471
1472    fn set_scene_units(command: &ModelingCmd) -> Result<kcmc::units::UnitLength, KclError> {
1473        let ModelingCmd::SetSceneUnits(set_scene_units) = command else {
1474            return Err(KclError::new_internal(KclErrorDetails::new(
1475                format!("expected set_scene_units command, got {command:?}"),
1476                vec![SourceRange::default()],
1477            )));
1478        };
1479        Ok(set_scene_units.unit)
1480    }
1481
1482    fn basic_dimension(command: &ModelingCmd) -> Result<&AnnotationBasicDimension, KclError> {
1483        let ModelingCmd::NewAnnotation(new_annotation) = command else {
1484            return Err(KclError::new_internal(KclErrorDetails::new(
1485                format!("expected new_annotation command, got {command:?}"),
1486                vec![SourceRange::default()],
1487            )));
1488        };
1489        new_annotation.options.dimension.as_ref().ok_or_else(|| {
1490            KclError::new_internal(KclErrorDetails::new(
1491                "expected new_annotation command to have a dimension".to_owned(),
1492                vec![SourceRange::default()],
1493            ))
1494        })
1495    }
1496
1497    fn feature_control(command: &ModelingCmd) -> Result<&AnnotationFeatureControl, KclError> {
1498        let ModelingCmd::NewAnnotation(new_annotation) = command else {
1499            return Err(KclError::new_internal(KclErrorDetails::new(
1500                format!("expected new_annotation command, got {command:?}"),
1501                vec![SourceRange::default()],
1502            )));
1503        };
1504        new_annotation.options.feature_control.as_ref().ok_or_else(|| {
1505            KclError::new_internal(KclErrorDetails::new(
1506                "expected new_annotation command to have a feature_control".to_owned(),
1507                vec![SourceRange::default()],
1508            ))
1509        })
1510    }
1511
1512    #[track_caller]
1513    fn assert_close(actual: f64, expected: f64) {
1514        assert!((actual - expected).abs() < 1e-6, "expected {expected}, got {actual}");
1515    }
1516
1517    fn new_annotation_command_index(commands: &[ModelingCmd]) -> Result<usize, KclError> {
1518        commands
1519            .iter()
1520            .position(|command| matches!(command, ModelingCmd::NewAnnotation(_)))
1521            .ok_or_else(|| {
1522                KclError::new_internal(KclErrorDetails::new(
1523                    "expected commands to contain a new_annotation command".to_owned(),
1524                    vec![SourceRange::default()],
1525                ))
1526            })
1527    }
1528
1529    #[test]
1530    fn gdt_font_scale_is_scene_height_divided_by_calibration_height() {
1531        let scale_at_calibrated_height = gdt_font_scale_for_height_mm(GDT_FONT_SCALE_1_HEIGHT_MM);
1532        assert!((scale_at_calibrated_height - 1.0).abs() < f32::EPSILON);
1533
1534        let double_height_scale = gdt_font_scale_for_height_mm(GDT_FONT_SCALE_1_HEIGHT_MM * 2.0);
1535        assert!((double_height_scale - 2.0).abs() < f32::EPSILON);
1536
1537        let inch_in_mm = 25.4;
1538        let inch_scale = gdt_font_scale_for_height_mm(inch_in_mm);
1539        assert!((inch_scale - (inch_in_mm / GDT_FONT_SCALE_1_HEIGHT_MM) as f32).abs() < f32::EPSILON);
1540    }
1541
1542    const GDT_FLATNESS_LEADER_KCL_TEMPLATE: &str = r#"
1543@settings(defaultLengthUnit = mm, kclVersion = 2)
1544
1545blockProfile = sketch(on = XY) {
1546  edge1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
1547  edge2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
1548  edge3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
1549  edge4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
1550  coincident([edge1.end, edge2.start])
1551  coincident([edge2.end, edge3.start])
1552  coincident([edge3.end, edge4.start])
1553  coincident([edge4.end, edge1.start])
1554  parallel([edge2, edge4])
1555  parallel([edge3, edge1])
1556  perpendicular([edge1, edge2])
1557  horizontal(edge3)
1558}
1559
1560region001 = region(point = [5mm, 5mm], sketch = blockProfile)
1561extrude001 = extrude(region001, length = 10mm, tagEnd = $top)
1562gdt::flatness(
1563  faces = [top],
1564  tolerance = 0.1mm,
1565  framePosition = [10mm, 0mm],
1566  framePlane = XZ,
1567  fontSize = __FONT_SIZE__
1568  __LEADER_SCALE__
1569)
1570"#;
1571
1572    fn gdt_flatness_leader_kcl(font_size: &str, leader_scale: Option<&str>) -> String {
1573        GDT_FLATNESS_LEADER_KCL_TEMPLATE
1574            .replace("__FONT_SIZE__", font_size)
1575            .replace(
1576                "__LEADER_SCALE__",
1577                leader_scale
1578                    .map(|scale| format!(",\n  leaderScale = {scale}"))
1579                    .unwrap_or_default()
1580                    .as_str(),
1581            )
1582    }
1583
1584    async fn gdt_flatness_feature_control(
1585        font_size: &str,
1586        leader_scale: Option<&str>,
1587    ) -> Result<AnnotationFeatureControl, KclError> {
1588        let code = gdt_flatness_leader_kcl(font_size, leader_scale);
1589        let commands = gdt_commands(&code).await;
1590        let annotation_index = new_annotation_command_index(&commands)?;
1591        Ok(feature_control(&commands[annotation_index])?.clone())
1592    }
1593
1594    #[tokio::test(flavor = "multi_thread")]
1595    async fn gdt_dot_leader_scale_is_normalized_against_font_scale() -> Result<(), KclError> {
1596        let tiny = gdt_flatness_feature_control("1mm", None).await?;
1597        let large = gdt_flatness_feature_control("100mm", None).await?;
1598
1599        assert_close(f64::from(tiny.font_scale), gdt_font_scale_for_height_mm(1.0).into());
1600        assert_close(f64::from(large.font_scale), gdt_font_scale_for_height_mm(100.0).into());
1601        assert_close(f64::from(tiny.leader_scale), 50.0);
1602        assert_close(f64::from(large.leader_scale), 0.5);
1603
1604        assert_close(
1605            f64::from(tiny.font_scale) * f64::from(tiny.leader_scale),
1606            f64::from(gdt_dot_leader_normal_size()),
1607        );
1608        assert_close(
1609            f64::from(large.font_scale) * f64::from(large.leader_scale),
1610            f64::from(gdt_dot_leader_normal_size()),
1611        );
1612        Ok(())
1613    }
1614
1615    #[tokio::test(flavor = "multi_thread")]
1616    async fn explicit_gdt_dot_leader_scale_multiplies_normal_size() -> Result<(), KclError> {
1617        let tiny = gdt_flatness_feature_control("1mm", Some("2")).await?;
1618        let large = gdt_flatness_feature_control("100mm", Some("2")).await?;
1619
1620        let expected_scaled_dot_size = f64::from(gdt_dot_leader_normal_size()) * 2.0;
1621        assert_close(
1622            f64::from(tiny.font_scale) * f64::from(tiny.leader_scale),
1623            expected_scaled_dot_size,
1624        );
1625        assert_close(
1626            f64::from(large.font_scale) * f64::from(large.leader_scale),
1627            expected_scaled_dot_size,
1628        );
1629        Ok(())
1630    }
1631
1632    #[tokio::test(flavor = "multi_thread")]
1633    async fn gdt_flatness_uses_scene_units_for_control_frame_tolerance() -> Result<(), KclError> {
1634        let cases = [
1635            ("in", "0.1in", "[10, -10]", 0.1, 254.0, -254.0),
1636            ("cm", "10mm", "[1, -1]", 1.0, 10.0, -10.0),
1637        ];
1638
1639        for (default_unit, tolerance, frame_position, expected_tolerance, expected_x, expected_y) in cases {
1640            let code = gdt_flatness_kcl(default_unit, tolerance, frame_position);
1641            let commands = gdt_commands(&code).await;
1642            let annotation_index = new_annotation_command_index(&commands)?;
1643            let feature_control = feature_control(&commands[annotation_index])?;
1644            let control_frame = feature_control.control_frame.as_ref().ok_or_else(|| {
1645                KclError::new_internal(KclErrorDetails::new(
1646                    "expected feature_control to have a control_frame".to_owned(),
1647                    vec![SourceRange::default()],
1648                ))
1649            })?;
1650
1651            assert_close(control_frame.tolerance, expected_tolerance);
1652            assert_close(feature_control.offset.x, expected_x);
1653            assert_close(feature_control.offset.y, expected_y);
1654            assert_close(
1655                f64::from(feature_control.font_scale),
1656                gdt_font_scale_for_height_mm(50.8).into(),
1657            );
1658        }
1659        Ok(())
1660    }
1661
1662    #[tokio::test(flavor = "multi_thread")]
1663    async fn gdt_distance_sets_scene_units_around_non_mm_annotation() -> Result<(), KclError> {
1664        let cases = [
1665            (
1666                "in",
1667                "2.54mm",
1668                "[10, -10]",
1669                kcmc::units::UnitLength::Inches,
1670                0.1,
1671                254.0,
1672                -254.0,
1673            ),
1674            (
1675                "cm",
1676                "10mm",
1677                "[1, -1]",
1678                kcmc::units::UnitLength::Centimeters,
1679                1.0,
1680                10.0,
1681                -10.0,
1682            ),
1683        ];
1684
1685        for (default_unit, tolerance, frame_position, scene_unit, expected_tolerance, expected_x, expected_y) in cases {
1686            let code = gdt_distance_kcl(default_unit, tolerance, frame_position);
1687            let commands = gdt_commands(&code).await;
1688            let annotation_index = new_annotation_command_index(&commands)?;
1689            let dimension = basic_dimension(&commands[annotation_index])?;
1690
1691            assert_eq!(set_scene_units(&commands[annotation_index - 1])?, scene_unit);
1692            assert_eq!(
1693                set_scene_units(&commands[annotation_index + 1])?,
1694                kcmc::units::UnitLength::Millimeters
1695            );
1696
1697            assert_close(dimension.dimension.tolerance, expected_tolerance);
1698            assert_close(dimension.offset.x, expected_x);
1699            assert_close(dimension.offset.y, expected_y);
1700            assert_close(
1701                f64::from(dimension.font_scale),
1702                gdt_font_scale_for_height_mm(50.8).into(),
1703            );
1704        }
1705        Ok(())
1706    }
1707
1708    #[tokio::test(flavor = "multi_thread")]
1709    async fn gdt_distance_keeps_mm_annotation_in_current_scene_units() -> Result<(), KclError> {
1710        let code = gdt_distance_kcl("mm", "2.54mm", "[10, -10]");
1711        let commands = gdt_commands(&code).await;
1712        let annotation_index = new_annotation_command_index(&commands)?;
1713        let dimension = basic_dimension(&commands[annotation_index])?;
1714
1715        assert!(
1716            !commands
1717                .iter()
1718                .any(|command| matches!(command, ModelingCmd::SetSceneUnits(_)))
1719        );
1720        assert_close(dimension.dimension.tolerance, 2.54);
1721        assert_close(dimension.offset.x, 10.0);
1722        assert_close(dimension.offset.y, -10.0);
1723        Ok(())
1724    }
1725
1726    const GDT_DATUM_KCL: &str = r#"
1727blockProfile = sketch(on = XY) {
1728  edge1 = line(start = [var 0mm, var 0mm], end = [var 8mm, var 0mm])
1729  edge2 = line(start = [var 8mm, var 0mm], end = [var 8mm, var 5mm])
1730  edge3 = line(start = [var 8mm, var 5mm], end = [var 0mm, var 5mm])
1731  edge4 = line(start = [var 0mm, var 5mm], end = [var 0mm, var 0mm])
1732  coincident([edge1.end, edge2.start])
1733  coincident([edge2.end, edge3.start])
1734  coincident([edge3.end, edge4.start])
1735  coincident([edge4.end, edge1.start])
1736  horizontal(edge1)
1737  vertical(edge2)
1738  horizontal(edge3)
1739  vertical(edge4)
1740}
1741
1742block = extrude(region(point = [4mm, 2mm], sketch = blockProfile), length = 4mm, tagEnd = $top)
1743
1744gdt::datum(face = top, name = "A", framePosition = [10mm, 0mm], framePlane = XZ)
1745"#;
1746
1747    async fn gdt_artifact_count(skip_artifact_graph: bool) -> usize {
1748        let settings = ExecutorSettings {
1749            skip_artifact_graph,
1750            ..Default::default()
1751        };
1752        let ctx = ExecutorContext::new_mock(Some(settings)).await;
1753        let program = crate::Program::parse_no_errs(GDT_DATUM_KCL).unwrap();
1754        let mock_config = MockConfig {
1755            use_prev_memory: false,
1756            ..Default::default()
1757        };
1758        let outcome = ctx.run_mock(&program, &mock_config).await.unwrap();
1759        ctx.close().await;
1760
1761        outcome
1762            .artifact_graph
1763            .values()
1764            .filter(|artifact| matches!(artifact, Artifact::GdtAnnotation(_)))
1765            .count()
1766    }
1767
1768    #[tokio::test(flavor = "multi_thread")]
1769    async fn gdt_annotations_do_not_follow_runtime_artifact_graph_setting() {
1770        assert_eq!(gdt_artifact_count(false).await, 1);
1771        assert_eq!(gdt_artifact_count(true).await, 1);
1772    }
1773}