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
298pub async fn profile(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
299    let edges: Vec<EdgeReference> = args.get_kw_arg(
300        "edges",
301        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
302        exec_state,
303    )?;
304    let datums: Option<Vec<String>> = args.get_kw_arg_opt(
305        "datums",
306        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
307        exec_state,
308    )?;
309    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
310    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
311    let frame_position: Option<[TyF64; 2]> =
312        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
313    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
314    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
315    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
316
317    let annotations = inner_profile(
318        edges,
319        datums,
320        tolerance,
321        precision,
322        frame_position,
323        frame_plane,
324        leader_scale,
325        font_size,
326        exec_state,
327        &args,
328    )
329    .await?;
330    Ok(annotations.into())
331}
332
333pub async fn position(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
334    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
335        "faces",
336        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
337        exec_state,
338    )?;
339    let edges: Option<Vec<EdgeReference>> = args.get_kw_arg_opt(
340        "edges",
341        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
342        exec_state,
343    )?;
344    let datums: Option<Vec<String>> = args.get_kw_arg_opt(
345        "datums",
346        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
347        exec_state,
348    )?;
349    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
350    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
351    let frame_position: Option<[TyF64; 2]> =
352        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
353    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
354    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
355    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
356
357    let annotations = inner_position(
358        faces.unwrap_or_default(),
359        edges.unwrap_or_default(),
360        tolerance,
361        datums,
362        precision,
363        frame_position,
364        frame_plane,
365        leader_scale,
366        font_size,
367        exec_state,
368        &args,
369    )
370    .await?;
371    Ok(annotations.into())
372}
373
374pub async fn distance(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
375    let from: Option<DistanceEntity> = args.get_kw_arg_opt("from", &distance_entity_type(), exec_state)?;
376    let to: Option<DistanceEntity> = args.get_kw_arg_opt("to", &distance_entity_type(), exec_state)?;
377    let edges: Option<Vec<EdgeReference>> = args.get_kw_arg_opt(
378        "edges",
379        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
380        exec_state,
381    )?;
382    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
383    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
384    let frame_position: Option<[TyF64; 2]> =
385        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
386    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
387    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
388    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
389
390    let annotations = inner_distance(
391        from,
392        to,
393        edges.unwrap_or_default(),
394        tolerance,
395        precision,
396        frame_position,
397        frame_plane,
398        leader_scale,
399        font_size,
400        exec_state,
401        &args,
402    )
403    .await?;
404    Ok(annotations.into())
405}
406
407pub async fn perpendicularity(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
408    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
409        "faces",
410        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
411        exec_state,
412    )?;
413    let edges: Option<Vec<EdgeReference>> = args.get_kw_arg_opt(
414        "edges",
415        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
416        exec_state,
417    )?;
418    let datums: Option<Vec<String>> = args.get_kw_arg_opt(
419        "datums",
420        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
421        exec_state,
422    )?;
423    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
424    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
425    let frame_position: Option<[TyF64; 2]> =
426        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
427    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
428    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
429    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
430
431    let annotations = inner_perpendicularity(
432        faces.unwrap_or_default(),
433        edges.unwrap_or_default(),
434        datums,
435        tolerance,
436        precision,
437        frame_position,
438        frame_plane,
439        leader_scale,
440        font_size,
441        exec_state,
442        &args,
443    )
444    .await?;
445    Ok(annotations.into())
446}
447
448pub async fn parallelism(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
449    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
450        "faces",
451        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
452        exec_state,
453    )?;
454    let edges: Option<Vec<EdgeReference>> = args.get_kw_arg_opt(
455        "edges",
456        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
457        exec_state,
458    )?;
459    let datums: Option<Vec<String>> = args.get_kw_arg_opt(
460        "datums",
461        &RuntimeType::Array(Box::new(RuntimeType::string()), ArrayLen::Minimum(1)),
462        exec_state,
463    )?;
464    let tolerance = args.get_kw_arg("tolerance", &RuntimeType::length(), exec_state)?;
465    let precision = args.get_kw_arg_opt("precision", &RuntimeType::count(), exec_state)?;
466    let frame_position: Option<[TyF64; 2]> =
467        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
468    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
469    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
470    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
471
472    let annotations = inner_parallelism(
473        faces.unwrap_or_default(),
474        edges.unwrap_or_default(),
475        datums,
476        tolerance,
477        precision,
478        frame_position,
479        frame_plane,
480        leader_scale,
481        font_size,
482        exec_state,
483        &args,
484    )
485    .await?;
486    Ok(annotations.into())
487}
488
489pub async fn annotation(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
490    let annotation: String = args.get_kw_arg("annotation", &RuntimeType::string(), exec_state)?;
491    let faces: Option<Vec<TagIdentifier>> = args.get_kw_arg_opt(
492        "faces",
493        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Minimum(1)),
494        exec_state,
495    )?;
496    let edges: Option<Vec<EdgeReference>> = args.get_kw_arg_opt(
497        "edges",
498        &RuntimeType::Array(Box::new(RuntimeType::edge()), ArrayLen::Minimum(1)),
499        exec_state,
500    )?;
501    let frame_position: Option<[TyF64; 2]> =
502        args.get_kw_arg_opt("framePosition", &RuntimeType::point2d(), exec_state)?;
503    let frame_plane: Option<Plane> = args.get_kw_arg_opt("framePlane", &RuntimeType::plane(), exec_state)?;
504    let leader_scale: Option<TyF64> = args.get_kw_arg_opt("leaderScale", &RuntimeType::count(), exec_state)?;
505    let font_size: Option<TyF64> = args.get_kw_arg_opt("fontSize", &RuntimeType::length(), exec_state)?;
506
507    let annotations = inner_annotation(
508        annotation,
509        faces.unwrap_or_default(),
510        edges.unwrap_or_default(),
511        frame_position,
512        frame_plane,
513        leader_scale,
514        font_size,
515        exec_state,
516        &args,
517    )
518    .await?;
519    Ok(annotations.into())
520}
521
522#[allow(clippy::too_many_arguments)]
523async fn inner_perpendicularity(
524    faces: Vec<TagIdentifier>,
525    edges: Vec<EdgeReference>,
526    datums: Option<Vec<String>>,
527    tolerance: TyF64,
528    precision: Option<TyF64>,
529    frame_position: Option<[TyF64; 2]>,
530    frame_plane: Option<Plane>,
531    leader_scale: Option<TyF64>,
532    font_size: Option<TyF64>,
533    exec_state: &mut ExecState,
534    args: &Args,
535) -> Result<Vec<GdtAnnotation>, KclError> {
536    if faces.is_empty() && edges.is_empty() {
537        return Err(KclError::new_semantic(KclErrorDetails::new(
538            "Perpendicularity requires at least one face or edge.".to_owned(),
539            vec![args.source_range],
540        )));
541    }
542
543    let precision = resolve_precision(precision, args)?;
544    let datums = resolve_datums(datums, args, "Perpendicularity")?;
545    let mut frame_plane = if let Some(plane) = frame_plane {
546        plane
547    } else {
548        xy_plane(exec_state, args).await?
549    };
550    ensure_sketch_plane_in_engine(
551        &mut frame_plane,
552        exec_state,
553        &args.ctx,
554        args.source_range,
555        args.node_path.clone(),
556    )
557    .await?;
558
559    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
560    for face in &faces {
561        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
562        create_feature_control_annotation(
563            face_id,
564            MbdSymbol::Perpendicularity,
565            &tolerance,
566            &datums,
567            precision,
568            frame_position.as_ref(),
569            frame_plane.id,
570            leader_scale.as_ref(),
571            font_size.as_ref(),
572            exec_state,
573            args,
574            &mut annotations,
575        )
576        .await?;
577    }
578    for edge in &edges {
579        let edge_id = edge.get_engine_id(exec_state, args)?;
580        create_feature_control_annotation(
581            edge_id,
582            MbdSymbol::Perpendicularity,
583            &tolerance,
584            &datums,
585            precision,
586            frame_position.as_ref(),
587            frame_plane.id,
588            leader_scale.as_ref(),
589            font_size.as_ref(),
590            exec_state,
591            args,
592            &mut annotations,
593        )
594        .await?;
595    }
596
597    Ok(annotations)
598}
599
600#[allow(clippy::too_many_arguments)]
601async fn inner_parallelism(
602    faces: Vec<TagIdentifier>,
603    edges: Vec<EdgeReference>,
604    datums: Option<Vec<String>>,
605    tolerance: TyF64,
606    precision: Option<TyF64>,
607    frame_position: Option<[TyF64; 2]>,
608    frame_plane: Option<Plane>,
609    leader_scale: Option<TyF64>,
610    font_size: Option<TyF64>,
611    exec_state: &mut ExecState,
612    args: &Args,
613) -> Result<Vec<GdtAnnotation>, KclError> {
614    if faces.is_empty() && edges.is_empty() {
615        return Err(KclError::new_semantic(KclErrorDetails::new(
616            "Parallelism requires at least one face or edge.".to_owned(),
617            vec![args.source_range],
618        )));
619    }
620
621    let precision = resolve_precision(precision, args)?;
622    let datums = resolve_datums(datums, args, "Parallelism")?;
623    let mut frame_plane = if let Some(plane) = frame_plane {
624        plane
625    } else {
626        xy_plane(exec_state, args).await?
627    };
628    ensure_sketch_plane_in_engine(
629        &mut frame_plane,
630        exec_state,
631        &args.ctx,
632        args.source_range,
633        args.node_path.clone(),
634    )
635    .await?;
636
637    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
638    for face in &faces {
639        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
640        create_feature_control_annotation(
641            face_id,
642            MbdSymbol::Parallelism,
643            &tolerance,
644            &datums,
645            precision,
646            frame_position.as_ref(),
647            frame_plane.id,
648            leader_scale.as_ref(),
649            font_size.as_ref(),
650            exec_state,
651            args,
652            &mut annotations,
653        )
654        .await?;
655    }
656    for edge in &edges {
657        let edge_id = edge.get_engine_id(exec_state, args)?;
658        create_feature_control_annotation(
659            edge_id,
660            MbdSymbol::Parallelism,
661            &tolerance,
662            &datums,
663            precision,
664            frame_position.as_ref(),
665            frame_plane.id,
666            leader_scale.as_ref(),
667            font_size.as_ref(),
668            exec_state,
669            args,
670            &mut annotations,
671        )
672        .await?;
673    }
674
675    Ok(annotations)
676}
677
678#[allow(clippy::too_many_arguments)]
679async fn inner_annotation(
680    annotation: String,
681    faces: Vec<TagIdentifier>,
682    edges: Vec<EdgeReference>,
683    frame_position: Option<[TyF64; 2]>,
684    frame_plane: Option<Plane>,
685    leader_scale: Option<TyF64>,
686    font_size: Option<TyF64>,
687    exec_state: &mut ExecState,
688    args: &Args,
689) -> Result<Vec<GdtAnnotation>, KclError> {
690    if annotation.is_empty() {
691        return Err(KclError::new_semantic(KclErrorDetails::new(
692            "Annotation text must not be empty.".to_owned(),
693            vec![args.source_range],
694        )));
695    }
696    if faces.is_empty() && edges.is_empty() {
697        return Err(KclError::new_semantic(KclErrorDetails::new(
698            "Annotation requires at least one face or edge.".to_owned(),
699            vec![args.source_range],
700        )));
701    }
702
703    let mut frame_plane = if let Some(plane) = frame_plane {
704        plane
705    } else {
706        xy_plane(exec_state, args).await?
707    };
708    ensure_sketch_plane_in_engine(
709        &mut frame_plane,
710        exec_state,
711        &args.ctx,
712        args.source_range,
713        args.node_path.clone(),
714    )
715    .await?;
716
717    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
718    for face in &faces {
719        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
720        create_annotation(
721            face_id,
722            &annotation,
723            frame_position.as_ref(),
724            frame_plane.id,
725            leader_scale.as_ref(),
726            font_size.as_ref(),
727            exec_state,
728            args,
729            &mut annotations,
730        )
731        .await?;
732    }
733    for edge in &edges {
734        let edge_id = edge.get_engine_id(exec_state, args)?;
735        create_annotation(
736            edge_id,
737            &annotation,
738            frame_position.as_ref(),
739            frame_plane.id,
740            leader_scale.as_ref(),
741            font_size.as_ref(),
742            exec_state,
743            args,
744            &mut annotations,
745        )
746        .await?;
747    }
748
749    Ok(annotations)
750}
751
752#[allow(clippy::too_many_arguments)]
753async fn inner_distance(
754    from: Option<DistanceEntity>,
755    to: Option<DistanceEntity>,
756    edges: Vec<EdgeReference>,
757    tolerance: TyF64,
758    precision: Option<TyF64>,
759    frame_position: Option<[TyF64; 2]>,
760    frame_plane: Option<Plane>,
761    leader_scale: Option<TyF64>,
762    font_size: Option<TyF64>,
763    exec_state: &mut ExecState,
764    args: &Args,
765) -> Result<Vec<GdtAnnotation>, KclError> {
766    let precision = resolve_precision(precision, args)?;
767    let mut frame_plane = if let Some(plane) = frame_plane {
768        plane
769    } else {
770        xy_plane(exec_state, args).await?
771    };
772    ensure_sketch_plane_in_engine(
773        &mut frame_plane,
774        exec_state,
775        &args.ctx,
776        args.source_range,
777        args.node_path.clone(),
778    )
779    .await?;
780
781    if from.is_some() || to.is_some() {
782        if !edges.is_empty() {
783            return Err(KclError::new_semantic(KclErrorDetails::new(
784                "Distance cannot combine `from`/`to` with `edges`.".to_owned(),
785                vec![args.source_range],
786            )));
787        }
788
789        let (Some(from), Some(to)) = (from, to) else {
790            return Err(KclError::new_semantic(KclErrorDetails::new(
791                "Distance requires both `from` and `to` when measuring between entities.".to_owned(),
792                vec![args.source_range],
793            )));
794        };
795
796        let from = from.to_endpoint(exec_state, args).await?;
797        let to = to.to_endpoint(exec_state, args).await?;
798        let mut annotations = Vec::with_capacity(1);
799        create_basic_distance_annotation(
800            from,
801            to,
802            &tolerance,
803            precision,
804            frame_position.as_ref(),
805            frame_plane.id,
806            leader_scale.as_ref(),
807            font_size.as_ref(),
808            exec_state,
809            args,
810            &mut annotations,
811        )
812        .await?;
813        return Ok(annotations);
814    }
815
816    if edges.is_empty() {
817        return Err(KclError::new_semantic(KclErrorDetails::new(
818            "Distance requires either `edges` or both `from` and `to`.".to_owned(),
819            vec![args.source_range],
820        )));
821    }
822
823    let mut annotations = Vec::with_capacity(edges.len());
824    for edge in &edges {
825        let edge_id = edge.get_engine_id(exec_state, args)?;
826        create_basic_distance_annotation(
827            DistanceEndpoint {
828                entity_id: edge_id,
829                entity_pos: KPoint2d { x: 0.0, y: 0.0 },
830            },
831            DistanceEndpoint {
832                entity_id: edge_id,
833                entity_pos: KPoint2d { x: 1.0, y: 0.0 },
834            },
835            &tolerance,
836            precision,
837            frame_position.as_ref(),
838            frame_plane.id,
839            leader_scale.as_ref(),
840            font_size.as_ref(),
841            exec_state,
842            args,
843            &mut annotations,
844        )
845        .await?;
846    }
847    Ok(annotations)
848}
849
850#[allow(clippy::too_many_arguments)]
851async fn inner_profile(
852    edges: Vec<EdgeReference>,
853    datums: Option<Vec<String>>,
854    tolerance: TyF64,
855    precision: Option<TyF64>,
856    frame_position: Option<[TyF64; 2]>,
857    frame_plane: Option<Plane>,
858    leader_scale: Option<TyF64>,
859    font_size: Option<TyF64>,
860    exec_state: &mut ExecState,
861    args: &Args,
862) -> Result<Vec<GdtAnnotation>, KclError> {
863    let precision = resolve_precision(precision, args)?;
864    let datums = resolve_datums(datums, args, "Profile")?;
865    let mut frame_plane = if let Some(plane) = frame_plane {
866        plane
867    } else {
868        xy_plane(exec_state, args).await?
869    };
870    ensure_sketch_plane_in_engine(
871        &mut frame_plane,
872        exec_state,
873        &args.ctx,
874        args.source_range,
875        args.node_path.clone(),
876    )
877    .await?;
878
879    let mut annotations = Vec::with_capacity(edges.len());
880    for edge in &edges {
881        let edge_id = edge.get_engine_id(exec_state, args)?;
882        create_feature_control_annotation(
883            edge_id,
884            MbdSymbol::ProfileOfLine,
885            &tolerance,
886            &datums,
887            precision,
888            frame_position.as_ref(),
889            frame_plane.id,
890            leader_scale.as_ref(),
891            font_size.as_ref(),
892            exec_state,
893            args,
894            &mut annotations,
895        )
896        .await?;
897    }
898    Ok(annotations)
899}
900
901#[allow(clippy::too_many_arguments)]
902async fn inner_position(
903    faces: Vec<TagIdentifier>,
904    edges: Vec<EdgeReference>,
905    tolerance: TyF64,
906    datums: Option<Vec<String>>,
907    precision: Option<TyF64>,
908    frame_position: Option<[TyF64; 2]>,
909    frame_plane: Option<Plane>,
910    leader_scale: Option<TyF64>,
911    font_size: Option<TyF64>,
912    exec_state: &mut ExecState,
913    args: &Args,
914) -> Result<Vec<GdtAnnotation>, KclError> {
915    if faces.is_empty() && edges.is_empty() {
916        return Err(KclError::new_semantic(KclErrorDetails::new(
917            "Position requires at least one face or edge.".to_owned(),
918            vec![args.source_range],
919        )));
920    }
921
922    let precision = resolve_precision(precision, args)?;
923    let datums = resolve_datums(datums, args, "Position")?;
924    let mut frame_plane = if let Some(plane) = frame_plane {
925        plane
926    } else {
927        xy_plane(exec_state, args).await?
928    };
929    ensure_sketch_plane_in_engine(
930        &mut frame_plane,
931        exec_state,
932        &args.ctx,
933        args.source_range,
934        args.node_path.clone(),
935    )
936    .await?;
937
938    let mut annotations = Vec::with_capacity(faces.len() + edges.len());
939    for face in &faces {
940        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
941        create_feature_control_annotation(
942            face_id,
943            MbdSymbol::Position,
944            &tolerance,
945            &datums,
946            precision,
947            frame_position.as_ref(),
948            frame_plane.id,
949            leader_scale.as_ref(),
950            font_size.as_ref(),
951            exec_state,
952            args,
953            &mut annotations,
954        )
955        .await?;
956    }
957    for edge in &edges {
958        let edge_id = edge.get_engine_id(exec_state, args)?;
959        create_feature_control_annotation(
960            edge_id,
961            MbdSymbol::Position,
962            &tolerance,
963            &datums,
964            precision,
965            frame_position.as_ref(),
966            frame_plane.id,
967            leader_scale.as_ref(),
968            font_size.as_ref(),
969            exec_state,
970            args,
971            &mut annotations,
972        )
973        .await?;
974    }
975    Ok(annotations)
976}
977
978#[allow(clippy::too_many_arguments)]
979async fn inner_flatness(
980    faces: Vec<TagIdentifier>,
981    tolerance: TyF64,
982    precision: Option<TyF64>,
983    frame_position: Option<[TyF64; 2]>,
984    frame_plane: Option<Plane>,
985    leader_scale: Option<TyF64>,
986    font_size: Option<TyF64>,
987    exec_state: &mut ExecState,
988    args: &Args,
989) -> Result<Vec<GdtAnnotation>, KclError> {
990    let precision = resolve_precision(precision, args)?;
991    let mut frame_plane = if let Some(plane) = frame_plane {
992        plane
993    } else {
994        // No plane given. Use one of the standard planes.
995        xy_plane(exec_state, args).await?
996    };
997    ensure_sketch_plane_in_engine(
998        &mut frame_plane,
999        exec_state,
1000        &args.ctx,
1001        args.source_range,
1002        args.node_path.clone(),
1003    )
1004    .await?;
1005    let mut annotations = Vec::with_capacity(faces.len());
1006    let display_units = exec_state.length_unit();
1007    for face in &faces {
1008        let face_id = args.get_adjacent_face_to_tag(exec_state, face, false).await?;
1009        let meta = vec![Metadata::from(args.source_range)];
1010        let annotation_id = exec_state.next_uuid();
1011        let feature_control = AnnotationFeatureControl::builder()
1012            .entity_id(face_id)
1013            // Point to the center of the face.
1014            .entity_pos(KPoint2d { x: 0.5, y: 0.5 })
1015            .leader_type(AnnotationLineEnd::Dot)
1016            .control_frame(
1017                AnnotationMbdControlFrame::builder()
1018                    .symbol(MbdSymbol::Flatness)
1019                    .tolerance(tolerance.to_length_units(display_units))
1020                    .build(),
1021            )
1022            .plane_id(frame_plane.id)
1023            .offset(if let Some(offset) = &frame_position {
1024                KPoint2d {
1025                    x: offset[0].to_mm(),
1026                    y: offset[1].to_mm(),
1027                }
1028            } else {
1029                KPoint2d { x: 100.0, y: 100.0 }
1030            })
1031            .precision(precision)
1032            .font_scale(gdt_font_scale(font_size.as_ref(), args)?)
1033            .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
1034            .leader_scale(gdt_dot_leader_scale(leader_scale.as_ref(), font_size.as_ref(), args)?)
1035            .build();
1036        let options = AnnotationOptions::builder().feature_control(feature_control).build();
1037        exec_state
1038            .batch_modeling_cmd(
1039                ModelingCmdMeta::from_args_id(exec_state, args, annotation_id),
1040                ModelingCmd::from(
1041                    mcmd::NewAnnotation::builder()
1042                        .options(options)
1043                        .clobber(false)
1044                        .annotation_type(AnnotationType::T3D)
1045                        .build(),
1046                ),
1047            )
1048            .await?;
1049        add_gdt_annotation_artifact(exec_state, args, annotation_id);
1050        annotations.push(GdtAnnotation {
1051            id: annotation_id,
1052            meta,
1053        });
1054    }
1055    Ok(annotations)
1056}
1057
1058fn resolve_precision(precision: Option<TyF64>, args: &Args) -> Result<u32, KclError> {
1059    if let Some(precision) = precision {
1060        let rounded = precision.n.round();
1061        if !(0.0..=9.0).contains(&rounded) {
1062            return Err(KclError::new_semantic(KclErrorDetails::new(
1063                "Precision must be between 0 and 9".to_owned(),
1064                vec![args.source_range],
1065            )));
1066        }
1067        Ok(rounded as u32)
1068    } else {
1069        Ok(3)
1070    }
1071}
1072
1073#[allow(clippy::too_many_arguments)]
1074async fn create_basic_distance_annotation(
1075    from: DistanceEndpoint,
1076    to: DistanceEndpoint,
1077    tolerance: &TyF64,
1078    precision: u32,
1079    frame_position: Option<&[TyF64; 2]>,
1080    frame_plane_id: uuid::Uuid,
1081    leader_scale: Option<&TyF64>,
1082    font_size: Option<&TyF64>,
1083    exec_state: &mut ExecState,
1084    args: &Args,
1085    annotations: &mut Vec<GdtAnnotation>,
1086) -> Result<(), KclError> {
1087    let meta = vec![Metadata::from(args.source_range)];
1088    let annotation_id = exec_state.next_uuid();
1089    let display_units = exec_state.length_unit();
1090    let dimension = AnnotationBasicDimension::builder()
1091        .from_entity_id(from.entity_id)
1092        .from_entity_pos(from.entity_pos)
1093        .to_entity_id(to.entity_id)
1094        .to_entity_pos(to.entity_pos)
1095        .dimension(
1096            AnnotationMbdBasicDimension::builder()
1097                .tolerance(tolerance.to_length_units(display_units))
1098                .build(),
1099        )
1100        .plane_id(frame_plane_id)
1101        .offset(if let Some(offset) = frame_position {
1102            KPoint2d {
1103                x: offset[0].to_mm(),
1104                y: offset[1].to_mm(),
1105            }
1106        } else {
1107            KPoint2d { x: 100.0, y: 100.0 }
1108        })
1109        .precision(precision)
1110        .font_scale(gdt_font_scale(font_size, args)?)
1111        .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
1112        .arrow_scale(gdt_dimension_leader_scale(leader_scale, args)?)
1113        .build();
1114    let options = AnnotationOptions::builder().dimension(dimension).build();
1115    // The engine formats auto-measured MBD distance labels from its current scene units.
1116    // Queue the unit switch, annotation, and reset together so other module commands
1117    // cannot interleave while the engine's MBD display units are flipped.
1118    let use_display_units = display_units != kcmc::units::UnitLength::Millimeters;
1119    let annotation_cmd = ModelingCmd::from(
1120        mcmd::NewAnnotation::builder()
1121            .options(options)
1122            .clobber(false)
1123            .annotation_type(AnnotationType::T3D)
1124            .build(),
1125    );
1126    let cmd_meta = ModelingCmdMeta::from_args_id(exec_state, args, annotation_id);
1127    if use_display_units {
1128        let set_units_id = exec_state.next_uuid();
1129        let reset_units_id = exec_state.next_uuid();
1130        exec_state
1131            .batch_modeling_cmds(
1132                cmd_meta,
1133                &[
1134                    set_engine_scene_units_cmd(set_units_id, display_units),
1135                    ModelingCmdReq {
1136                        cmd_id: annotation_id.into(),
1137                        cmd: annotation_cmd,
1138                    },
1139                    set_engine_scene_units_cmd(reset_units_id, kcmc::units::UnitLength::Millimeters),
1140                ],
1141            )
1142            .await?;
1143    } else {
1144        exec_state.batch_modeling_cmd(cmd_meta, annotation_cmd).await?;
1145    }
1146    add_gdt_annotation_artifact(exec_state, args, annotation_id);
1147    annotations.push(GdtAnnotation {
1148        id: annotation_id,
1149        meta,
1150    });
1151    Ok(())
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
1213#[allow(clippy::too_many_arguments)]
1214async fn create_annotation(
1215    entity_id: uuid::Uuid,
1216    annotation: &str,
1217    frame_position: Option<&[TyF64; 2]>,
1218    frame_plane_id: uuid::Uuid,
1219    leader_scale: Option<&TyF64>,
1220    font_size: Option<&TyF64>,
1221    exec_state: &mut ExecState,
1222    args: &Args,
1223    annotations: &mut Vec<GdtAnnotation>,
1224) -> Result<(), KclError> {
1225    let meta = vec![Metadata::from(args.source_range)];
1226    let annotation_id = exec_state.next_uuid();
1227    let feature_control = AnnotationFeatureControl::builder()
1228        .entity_id(entity_id)
1229        .entity_pos(KPoint2d { x: 0.5, y: 0.5 })
1230        .leader_type(AnnotationLineEnd::Dot)
1231        .prefix(annotation.to_owned())
1232        .plane_id(frame_plane_id)
1233        .offset(if let Some(offset) = frame_position {
1234            KPoint2d {
1235                x: offset[0].to_mm(),
1236                y: offset[1].to_mm(),
1237            }
1238        } else {
1239            KPoint2d { x: 100.0, y: 100.0 }
1240        })
1241        .precision(0)
1242        .font_scale(gdt_font_scale(font_size, args)?)
1243        .font_point_size(GDT_FONT_TEXTURE_POINT_SIZE)
1244        .leader_scale(gdt_dot_leader_scale(leader_scale, font_size, args)?)
1245        .build();
1246    let options = AnnotationOptions::builder().feature_control(feature_control).build();
1247    exec_state
1248        .batch_modeling_cmd(
1249            ModelingCmdMeta::from_args_id(exec_state, args, annotation_id),
1250            ModelingCmd::from(
1251                mcmd::NewAnnotation::builder()
1252                    .options(options)
1253                    .clobber(false)
1254                    .annotation_type(AnnotationType::T3D)
1255                    .build(),
1256            ),
1257        )
1258        .await?;
1259    add_gdt_annotation_artifact(exec_state, args, annotation_id);
1260    annotations.push(GdtAnnotation {
1261        id: annotation_id,
1262        meta,
1263    });
1264    Ok(())
1265}
1266
1267fn gdt_control_frame(symbol: MbdSymbol, tolerance: f64, datums: &[char]) -> AnnotationMbdControlFrame {
1268    match datums {
1269        [] => AnnotationMbdControlFrame::builder()
1270            .symbol(symbol)
1271            .tolerance(tolerance)
1272            .build(),
1273        [primary] => AnnotationMbdControlFrame::builder()
1274            .symbol(symbol)
1275            .tolerance(tolerance)
1276            .primary_datum(*primary)
1277            .build(),
1278        [primary, secondary] => AnnotationMbdControlFrame::builder()
1279            .symbol(symbol)
1280            .tolerance(tolerance)
1281            .primary_datum(*primary)
1282            .secondary_datum(*secondary)
1283            .build(),
1284        [primary, secondary, tertiary] => AnnotationMbdControlFrame::builder()
1285            .symbol(symbol)
1286            .tolerance(tolerance)
1287            .primary_datum(*primary)
1288            .secondary_datum(*secondary)
1289            .tertiary_datum(*tertiary)
1290            .build(),
1291        _ => unreachable!("resolve_datums rejects more than three datums"),
1292    }
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}