Skip to main content

kcl_lib/std/
chamfer.rs

1//! Standard library chamfers.
2
3use anyhow::Result;
4use kcmc::ModelingCmd;
5use kcmc::each_cmd as mcmd;
6use kcmc::length_unit::LengthUnit;
7use kcmc::shared::Angle;
8use kcmc::shared::CutStrategy;
9use kcmc::shared::CutTypeV2;
10use kcmc::shared::EdgeCutVersion;
11use kittycad_modeling_cmds::{self as kcmc};
12
13use super::args::TyF64;
14use crate::errors::KclError;
15use crate::errors::KclErrorDetails;
16use crate::execution::ChamferSurface;
17use crate::execution::EdgeCut;
18use crate::execution::ExecState;
19use crate::execution::ExtrudeSurface;
20use crate::execution::GeoMeta;
21use crate::execution::KclValue;
22use crate::execution::KclVersion;
23use crate::execution::ModelingCmdMeta;
24use crate::execution::Sketch;
25use crate::execution::Solid;
26use crate::execution::types::RuntimeType;
27use crate::parsing::ast::types::TagNode;
28use crate::std::Args;
29use crate::std::csg::CsgAlgorithm;
30use crate::std::fillet::EdgeReference;
31use crate::std::fillet::default_edge_cut_version;
32
33pub(crate) const DEFAULT_TOLERANCE: f64 = 0.0000001;
34
35/// Create chamfers on tagged paths.
36pub async fn chamfer(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
37    let solid: Box<Solid> = args.get_unlabeled_kw_arg("solid", &RuntimeType::solid(), exec_state)?;
38    let length: TyF64 = args.get_kw_arg("length", &RuntimeType::length(), exec_state)?;
39    let second_length = args.get_kw_arg_opt("secondLength", &RuntimeType::length(), exec_state)?;
40    let angle = args.get_kw_arg_opt("angle", &RuntimeType::angle(), exec_state)?;
41    let legacy_csg: Option<bool> = args.get_kw_arg_opt("legacyMethod", &RuntimeType::bool(), exec_state)?;
42    let csg_algorithm = CsgAlgorithm::legacy(legacy_csg.unwrap_or_default());
43    // KCL 3.0 removes the version parameter (`removed_in` in solid.kcl),
44    // so from 3.0 on this is always the default: the newest edge cut
45    // algorithm, which cuts all edges at once in a single engine command.
46    let edge_cut_number: Option<u32> = args.get_kw_arg_opt("version", &RuntimeType::count(), exec_state)?;
47    let tangent_chain: Option<bool> = args.get_kw_arg_opt("tangentChain", &RuntimeType::bool(), exec_state)?;
48    let tangent_chain = tangent_chain.unwrap_or(exec_state.kcl_version() >= KclVersion::V3Preview);
49    let edge_cut_version: EdgeCutVersion = edge_cut_number
50        .map(|num| {
51            num.try_into().map_err(|()| {
52                KclError::new_semantic(KclErrorDetails::new(
53                    format!("{} is not a version of the Zoo edge cut algorithm", num),
54                    vec![args.source_range],
55                ))
56            })
57        })
58        .transpose()?
59        .unwrap_or_else(|| default_edge_cut_version(exec_state.kcl_version()));
60
61    let tag = args.get_kw_arg_opt("tag", &RuntimeType::tag_decl(), exec_state)?;
62
63    // Edge specifiers are object-shaped payloads, so there is no narrow RuntimeType for them yet.
64    // Keep this broad at the boundary and validate the shape in parse_tagged_edge_inputs.
65    let edge_refs = args.get_kw_arg_opt("edges", &RuntimeType::any_array(), exec_state)?;
66    let tags = args.kw_arg_edge_array_and_source_opt("tags")?;
67
68    let edge_inputs = super::fillet::parse_tagged_edge_inputs(
69        edge_refs,
70        tags,
71        Some(solid.as_ref()),
72        exec_state,
73        &args,
74        "You must provide either 'tags' or 'edges' to chamfer edges",
75        "You must provide either 'tags' or 'edges' to chamfer edges, not both",
76    )
77    .await?;
78
79    match edge_inputs {
80        super::fillet::TaggedEdgeInputs::EngineRefs(edge_refs) => {
81            let value = inner_chamfer_with_engine_refs(
82                solid,
83                length,
84                edge_refs,
85                second_length,
86                angle,
87                csg_algorithm,
88                edge_cut_version,
89                tangent_chain,
90                tag,
91                exec_state,
92                args,
93            )
94            .await?;
95            Ok(KclValue::Solid { value })
96        }
97        super::fillet::TaggedEdgeInputs::Tags(tags) => match edge_cut_version {
98            // TODO: When we change the default algorithm to V2, we need to make
99            // it so that V0 (default) takes the route below.
100            EdgeCutVersion::V0 | EdgeCutVersion::V1 => {
101                let value = inner_chamfer(
102                    solid,
103                    length,
104                    tags,
105                    second_length,
106                    angle,
107                    None,
108                    tag,
109                    csg_algorithm,
110                    edge_cut_version,
111                    tangent_chain,
112                    exec_state,
113                    args,
114                )
115                .await?;
116                Ok(KclValue::Solid { value })
117            }
118            EdgeCutVersion::V2 | _ => {
119                let value = inner_chamfer_v2(
120                    solid,
121                    length,
122                    tags,
123                    second_length,
124                    angle,
125                    None,
126                    tag,
127                    csg_algorithm,
128                    edge_cut_version,
129                    tangent_chain,
130                    exec_state,
131                    args,
132                )
133                .await?;
134                Ok(KclValue::Solid { value })
135            }
136        },
137    }
138}
139
140#[allow(clippy::too_many_arguments)]
141async fn inner_chamfer(
142    solid: Box<Solid>,
143    length: TyF64,
144    tags: Vec<(EdgeReference, crate::SourceRange)>,
145    second_length: Option<TyF64>,
146    angle: Option<TyF64>,
147    custom_profile: Option<Sketch>,
148    tag: Option<TagNode>,
149    csg_algorithm: CsgAlgorithm,
150    edge_cut_version: EdgeCutVersion,
151    tangent_chain: bool,
152    exec_state: &mut ExecState,
153    args: Args,
154) -> Result<Box<Solid>, KclError> {
155    // If you try and tag multiple edges with a tagged chamfer, we want to return an
156    // error to the user that they can only tag one edge at a time.
157    if tag.is_some() && tags.len() > 1 {
158        return Err(KclError::new_type(KclErrorDetails::new(
159            "You can only tag one edge at a time with a tagged chamfer. Either delete the tag for the chamfer fn if you don't need it OR separate into individual chamfer functions for each tag.".to_string(),
160            vec![args.source_range],
161        )));
162    }
163
164    if angle.is_some() && second_length.is_some() {
165        return Err(KclError::new_semantic(KclErrorDetails::new(
166            "Cannot specify both an angle and a second length. Specify only one.".to_string(),
167            vec![args.source_range],
168        )));
169    }
170
171    let strategy = if second_length.is_some() || angle.is_some() || custom_profile.is_some() {
172        CutStrategy::Csg
173    } else {
174        Default::default()
175    };
176
177    let second_distance = second_length.map(|x| LengthUnit(x.to_mm()));
178    let angle = angle.map(|x| Angle::from_degrees(x.to_degrees(exec_state, args.source_range)));
179    if let Some(angle) = angle
180        && (angle.ge(&Angle::quarter_circle()) || angle.le(&Angle::zero()))
181    {
182        return Err(KclError::new_semantic(KclErrorDetails::new(
183            "The angle of a chamfer must be greater than zero and less than 90 degrees.".to_string(),
184            vec![args.source_range],
185        )));
186    }
187
188    let cut_type = if let Some(custom_profile) = custom_profile {
189        // Hide the custom profile since it's no longer its own profile
190        exec_state
191            .batch_modeling_cmd(
192                ModelingCmdMeta::from_args(exec_state, &args),
193                ModelingCmd::from(
194                    mcmd::ObjectVisible::builder()
195                        .object_id(custom_profile.id)
196                        .hidden(true)
197                        .build(),
198                ),
199            )
200            .await?;
201        CutTypeV2::Custom {
202            path: custom_profile.id,
203        }
204    } else {
205        CutTypeV2::Chamfer {
206            distance: LengthUnit(length.to_mm()),
207            second_distance,
208            angle,
209            swap: false,
210        }
211    };
212
213    let mut solid = solid.clone();
214    let mut tag_entries: Vec<crate::execution::DirectTagFilletTagEntry> = Vec::new();
215    for (edge_ref, source_range) in &tags {
216        let edge_id = match edge_ref {
217            EdgeReference::Uuid(u) => *u,
218            EdgeReference::Tag(t) => args.get_tag_engine_info(exec_state, t)?.id,
219        };
220        if crate::runtime_flags::z0006_refactor_metadata_enabled()
221            && let Ok(face_ids) = super::edge::get_face_ids_for_edge(exec_state, solid.id, edge_id, &args).await
222            && let [a, b] = face_ids.as_slice()
223        {
224            let tag_identifier = match edge_ref {
225                EdgeReference::Tag(t) => t.value.clone(),
226                EdgeReference::Uuid(_) => String::new(),
227            };
228            if !tag_identifier.is_empty() {
229                tag_entries.push(crate::execution::DirectTagFilletTagEntry {
230                    tag_identifier,
231                    edge_id,
232                    face_ids: [*a, *b],
233                });
234            } else {
235                exec_state.record_edge_refactor_meta_from_pending(edge_id, *source_range, [*a, *b]);
236            }
237        }
238    }
239    if !tag_entries.is_empty() {
240        exec_state.record_direct_tag_fillet_meta(crate::execution::DirectTagFilletMeta {
241            call_source_range: args.source_range,
242            tags: tag_entries,
243        });
244    }
245    for (edge_tag, _) in tags {
246        let edge_ids = edge_tag.get_all_engine_ids(exec_state, &args)?;
247        for edge_id in edge_ids {
248            let id = exec_state.next_uuid();
249            exec_state
250                .batch_edge_cut_cmd(
251                    ModelingCmdMeta::from_args_id(exec_state, &args, id),
252                    ModelingCmd::from(
253                        mcmd::Solid3dCutEdges::builder()
254                            .use_legacy(csg_algorithm.is_legacy())
255                            .edge_ids(vec![edge_id])
256                            .extra_face_ids(vec![])
257                            .strategy(strategy)
258                            .object_id(solid.id)
259                            // We can let the user set this in the future.
260                            .tolerance(LengthUnit(DEFAULT_TOLERANCE))
261                            .cut_type(cut_type)
262                            .version(edge_cut_version)
263                            .tangent_chain(tangent_chain)
264                            .build(),
265                    ),
266                )
267                .await?;
268
269            solid.edge_cuts.push(EdgeCut::Chamfer {
270                id,
271                edge_id,
272                length: length.clone(),
273                tag: Box::new(tag.clone()),
274            });
275
276            if let Some(ref tag) = tag {
277                solid.value.push(ExtrudeSurface::Chamfer(ChamferSurface {
278                    face_id: id,
279                    tag: Some(tag.clone()),
280                    geo_meta: GeoMeta {
281                        id,
282                        metadata: args.source_range.into(),
283                    },
284                }));
285            }
286        }
287    }
288
289    Ok(solid)
290}
291
292#[allow(clippy::too_many_arguments)]
293async fn inner_chamfer_v2(
294    solid: Box<Solid>,
295    length: TyF64,
296    tags: Vec<(EdgeReference, crate::SourceRange)>,
297    second_length: Option<TyF64>,
298    angle: Option<TyF64>,
299    custom_profile: Option<Sketch>,
300    tag: Option<TagNode>,
301    csg_algorithm: CsgAlgorithm,
302    edge_cut_version: EdgeCutVersion,
303    tangent_chain: bool,
304    exec_state: &mut ExecState,
305    args: Args,
306) -> Result<Box<Solid>, KclError> {
307    // If you try and tag multiple edges with a tagged chamfer, we want to return an
308    // error to the user that they can only tag one edge at a time.
309    if tag.is_some() && tags.len() > 1 {
310        return Err(KclError::new_type(KclErrorDetails::new(
311            "You can only tag one edge at a time with a tagged chamfer. Either delete the tag for the chamfer fn if you don't need it OR separate into individual chamfer functions for each tag.".to_string(),
312            vec![args.source_range],
313        )));
314    }
315    if tags.is_empty() {
316        return Err(KclError::new_semantic(KclErrorDetails {
317            source_ranges: vec![args.source_range],
318            message: "You must chamfer at least one tag".to_owned(),
319            backtrace: Default::default(),
320        }));
321    }
322
323    if angle.is_some() && second_length.is_some() {
324        return Err(KclError::new_semantic(KclErrorDetails::new(
325            "Cannot specify both an angle and a second length. Specify only one.".to_string(),
326            vec![args.source_range],
327        )));
328    }
329
330    let strategy = if second_length.is_some() || angle.is_some() || custom_profile.is_some() {
331        CutStrategy::Csg
332    } else {
333        Default::default()
334    };
335
336    let second_distance = second_length.map(|x| LengthUnit(x.to_mm()));
337    let angle = angle.map(|x| Angle::from_degrees(x.to_degrees(exec_state, args.source_range)));
338    if let Some(angle) = angle
339        && (angle.ge(&Angle::quarter_circle()) || angle.le(&Angle::zero()))
340    {
341        return Err(KclError::new_semantic(KclErrorDetails::new(
342            "The angle of a chamfer must be greater than zero and less than 90 degrees.".to_string(),
343            vec![args.source_range],
344        )));
345    }
346
347    let cut_type = if let Some(custom_profile) = custom_profile {
348        // Hide the custom profile since it's no longer its own profile
349        exec_state
350            .batch_modeling_cmd(
351                ModelingCmdMeta::from_args(exec_state, &args),
352                ModelingCmd::from(
353                    mcmd::ObjectVisible::builder()
354                        .object_id(custom_profile.id)
355                        .hidden(true)
356                        .build(),
357                ),
358            )
359            .await?;
360        CutTypeV2::Custom {
361            path: custom_profile.id,
362        }
363    } else {
364        CutTypeV2::Chamfer {
365            distance: LengthUnit(length.to_mm()),
366            second_distance,
367            angle,
368            swap: false,
369        }
370    };
371
372    let mut solid = solid.clone();
373    let mut edge_ids = Vec::new();
374    let mut tag_entries: Vec<crate::execution::DirectTagFilletTagEntry> = Vec::new();
375    for (edge_ref, source_range) in &tags {
376        let ids = edge_ref.get_all_engine_ids(exec_state, &args)?;
377        edge_ids.extend(ids.iter().copied());
378        let tag_identifier = match edge_ref {
379            EdgeReference::Tag(t) => t.value.clone(),
380            EdgeReference::Uuid(_) => String::new(),
381        };
382        for edge_id in ids {
383            if crate::runtime_flags::z0006_refactor_metadata_enabled()
384                && let Ok(face_ids) = super::edge::get_face_ids_for_edge(exec_state, solid.id, edge_id, &args).await
385                && let [a, b] = face_ids.as_slice()
386            {
387                if !tag_identifier.is_empty() {
388                    tag_entries.push(crate::execution::DirectTagFilletTagEntry {
389                        tag_identifier: tag_identifier.clone(),
390                        edge_id,
391                        face_ids: [*a, *b],
392                    });
393                } else {
394                    exec_state.record_edge_refactor_meta_from_pending(edge_id, *source_range, [*a, *b]);
395                }
396            }
397        }
398    }
399    if !tag_entries.is_empty() {
400        exec_state.record_direct_tag_fillet_meta(crate::execution::DirectTagFilletMeta {
401            call_source_range: args.source_range,
402            tags: tag_entries,
403        });
404    }
405
406    let id = exec_state.next_uuid();
407    let num_extra_ids = edge_ids.len().saturating_sub(1);
408    let mut extra_face_ids = Vec::with_capacity(num_extra_ids);
409    for _ in 0..num_extra_ids {
410        extra_face_ids.push(exec_state.next_uuid());
411    }
412    exec_state
413        .batch_edge_cut_cmd(
414            ModelingCmdMeta::from_args_id(exec_state, &args, id),
415            ModelingCmd::from(
416                mcmd::Solid3dCutEdges::builder()
417                    .use_legacy(csg_algorithm.is_legacy())
418                    .edge_ids(edge_ids.clone())
419                    .extra_face_ids(extra_face_ids)
420                    .strategy(strategy)
421                    .object_id(solid.id)
422                    // We can let the user set this in the future.
423                    .tolerance(LengthUnit(DEFAULT_TOLERANCE))
424                    .cut_type(cut_type)
425                    .version(edge_cut_version)
426                    .tangent_chain(tangent_chain)
427                    .build(),
428            ),
429        )
430        .await?;
431
432    let new_edge_cuts = edge_ids.into_iter().map(|edge_id| EdgeCut::Chamfer {
433        id,
434        edge_id,
435        length: length.clone(),
436        tag: Box::new(tag.clone()),
437    });
438    solid.edge_cuts.extend(new_edge_cuts);
439
440    if let Some(ref tag) = tag {
441        solid.value.push(ExtrudeSurface::Chamfer(ChamferSurface {
442            face_id: id,
443            tag: Some(tag.clone()),
444            geo_meta: GeoMeta {
445                id,
446                metadata: args.source_range.into(),
447            },
448        }));
449    }
450
451    Ok(solid)
452}
453
454#[expect(clippy::too_many_arguments)]
455async fn inner_chamfer_with_engine_refs(
456    solid: Box<Solid>,
457    length: TyF64,
458    edge_references: Vec<kcmc::shared::EdgeSpecifier>,
459    second_length: Option<TyF64>,
460    angle: Option<TyF64>,
461    csg_algorithm: CsgAlgorithm,
462    edge_cut_version: EdgeCutVersion,
463    tangent_chain: bool,
464    tag: Option<TagNode>,
465    exec_state: &mut ExecState,
466    args: Args,
467) -> Result<Box<Solid>, KclError> {
468    if tag.is_some() && edge_references.len() > 1 {
469        return Err(KclError::new_type(KclErrorDetails::new(
470            "You can only tag one edge at a time with a tagged chamfer. Either delete the tag for the chamfer fn if you don't need it OR separate into individual chamfer functions for each edgeRef.".to_string(),
471            vec![args.source_range],
472        )));
473    }
474
475    if angle.is_some() && second_length.is_some() {
476        return Err(KclError::new_semantic(KclErrorDetails::new(
477            "Cannot specify both an angle and a second length. Specify only one.".to_string(),
478            vec![args.source_range],
479        )));
480    }
481
482    let strategy = if second_length.is_some() || angle.is_some() {
483        CutStrategy::Csg
484    } else {
485        Default::default()
486    };
487
488    let second_distance = second_length.map(|x| LengthUnit(x.to_mm()));
489    let angle = angle.map(|x| Angle::from_degrees(x.to_degrees(exec_state, args.source_range)));
490    if let Some(angle) = angle
491        && (angle.ge(&Angle::quarter_circle()) || angle.le(&Angle::zero()))
492    {
493        return Err(KclError::new_semantic(KclErrorDetails::new(
494            "The angle of a chamfer must be greater than zero and less than 90 degrees.".to_string(),
495            vec![args.source_range],
496        )));
497    }
498
499    let cut_type = CutTypeV2::Chamfer {
500        distance: LengthUnit(length.to_mm()),
501        second_distance,
502        angle,
503        swap: false,
504    };
505
506    let id = exec_state.next_uuid();
507    let num_extra_ids = edge_references.len().saturating_sub(1);
508    let mut extra_face_ids = Vec::with_capacity(num_extra_ids);
509    for _ in 0..num_extra_ids {
510        extra_face_ids.push(exec_state.next_uuid());
511    }
512
513    let mut solid = solid.clone();
514    exec_state
515        .batch_edge_cut_cmd(
516            ModelingCmdMeta::from_args_id(exec_state, &args, id),
517            ModelingCmd::from(
518                mcmd::Solid3dCutEdgeReferences::builder()
519                    .object_id(solid.id)
520                    .edges_references(edge_references)
521                    .cut_type(cut_type)
522                    .tolerance(LengthUnit(DEFAULT_TOLERANCE))
523                    .strategy(strategy)
524                    .extra_face_ids(extra_face_ids)
525                    .use_legacy(csg_algorithm.is_legacy())
526                    .version(edge_cut_version)
527                    .tangent_chain(tangent_chain)
528                    .build(),
529            ),
530        )
531        .await?;
532
533    solid.pending_edge_cut_ids.push(id);
534
535    if let Some(ref tag) = tag {
536        solid.value.push(ExtrudeSurface::Chamfer(ChamferSurface {
537            face_id: id,
538            tag: Some(tag.clone()),
539            geo_meta: GeoMeta {
540                id,
541                metadata: args.source_range.into(),
542            },
543        }));
544    }
545
546    Ok(solid)
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552    use crate::execution::ExecTestResults;
553    use crate::execution::parse_execute;
554
555    /// Test what version of chamfer each KCL version uses by default.
556    #[tokio::test(flavor = "multi_thread")]
557    async fn chamfer_default_depends_on_kcl_version() {
558        assert_eq!(emitted_chamfer_version("2.0", None).await, EdgeCutVersion::V1);
559        assert_eq!(
560            emitted_chamfer_version("\"3.0-preview\"", None).await,
561            EdgeCutVersion::V2
562        );
563    }
564
565    /// If the user chooses a chamfer algorithm version, KCL should respect it,
566    /// and not use that KCL version's default chamfer algorithm version.
567    #[tokio::test(flavor = "multi_thread")]
568    async fn explicit_chamfer_version_overrides_kcl_default() {
569        assert_eq!(emitted_chamfer_version("2.0", Some(2)).await, EdgeCutVersion::V2);
570    }
571
572    /// KCL 3.0 removed `chamfer(version = )`. Passing it is reported like any
573    /// other unknown argument, and the default algorithm is used.
574    #[tokio::test(flavor = "multi_thread")]
575    async fn chamfer_version_is_removed_in_kcl_3() {
576        let result = run_chamfer("\"3.0-preview\"", Some(1)).await;
577        assert!(
578            result
579                .issues()
580                .iter()
581                .any(|issue| {
582                    issue.message
583                        == "`version` is not an argument of `chamfer`; it was removed in KCL 3.0, but this program uses KCL 3.0-preview"
584                }),
585            "issues: {:#?}",
586            result.issues()
587        );
588        assert_eq!(emitted_cut_edges_version(&result), EdgeCutVersion::V2);
589    }
590
591    /// For a given KCL version, and optional `chamfer(version = )` version,
592    /// show what chamfer algorithm version the runtime sent to the engine.
593    async fn emitted_chamfer_version(kcl_version: &str, explicit_version: Option<u32>) -> EdgeCutVersion {
594        emitted_cut_edges_version(&run_chamfer(kcl_version, explicit_version).await)
595    }
596
597    /// Chamfer one edge of a box under the given KCL version, optionally
598    /// passing `chamfer(version = )`.
599    async fn run_chamfer(kcl_version: &str, explicit_version: Option<u32>) -> ExecTestResults {
600        let version_arg = explicit_version
601            .map(|version| format!(", version = {version}"))
602            .unwrap_or_default();
603        let code = format!(
604            r#"@settings(kclVersion = {kcl_version}, experimentalFeatures = allow)
605
606profile = sketch(on = XY) {{
607  edge1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
608  edge2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
609  edge3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
610  edge4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
611  coincident([edge1.end, edge2.start])
612  coincident([edge2.end, edge3.start])
613  coincident([edge3.end, edge4.start])
614  coincident([edge4.end, edge1.start])
615}}
616profileRegion = region(point = [5mm, 5mm], sketch = profile)
617solid = extrude(profileRegion, length = 10mm, tagEnd = $top)
618chamfer(solid, tags = [getCommonEdge(faces = [profileRegion.tags.edge1, top])], length = 1mm{version_arg})
619"#
620        );
621
622        parse_execute(&code).await.unwrap()
623    }
624
625    /// The chamfer algorithm version the runtime sent to the engine.
626    fn emitted_cut_edges_version(result: &ExecTestResults) -> EdgeCutVersion {
627        result
628            .root_module_artifact_commands()
629            .iter()
630            .find_map(|artifact_command| match &artifact_command.command {
631                ModelingCmd::Solid3dCutEdges(command) => Some(command.version),
632                _ => None,
633            })
634            .expect("chamfer should emit a Solid3dCutEdges command")
635    }
636
637    #[tokio::test(flavor = "multi_thread")]
638    async fn tangent_chain_requires_kcl_3_and_is_sent_to_engine() {
639        let body = r#"
640profile = sketch(on = XY) {
641  edge1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
642  edge2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
643  edge3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
644  edge4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
645  coincident([edge1.end, edge2.start])
646  coincident([edge2.end, edge3.start])
647  coincident([edge3.end, edge4.start])
648  coincident([edge4.end, edge1.start])
649}
650profileRegion = region(point = [5mm, 5mm], sketch = profile)
651solid = extrude(profileRegion, length = 10mm, tagEnd = $top)
652chamfer(solid, tags = [getCommonEdge(faces = [profileRegion.tags.edge1, top])], length = 1mm, tangentChain = true)
653"#;
654
655        let result = parse_execute(&format!("@settings(kclVersion = 2.0)\n{body}"))
656            .await
657            .unwrap();
658        assert!(result.issues().iter().any(|issue| {
659            issue.message
660                == "`tangentChain` is not an argument of `chamfer`; it was added in KCL 3.0, but this program uses KCL 2.0"
661        }));
662
663        let result = parse_execute(&format!("@settings(kclVersion = \"3.0-preview\")\n{body}"))
664            .await
665            .unwrap();
666        let tangent_chain = result
667            .root_module_artifact_commands()
668            .iter()
669            .find_map(|artifact_command| match &artifact_command.command {
670                ModelingCmd::Solid3dCutEdges(command) => Some(command.tangent_chain),
671                _ => None,
672            })
673            .expect("chamfer should emit a Solid3dCutEdges command");
674        assert!(tangent_chain);
675
676        let default_body = body.replace(", tangentChain = true", "");
677        let result = parse_execute(&format!("@settings(kclVersion = \"3.0-preview\")\n{default_body}"))
678            .await
679            .unwrap();
680        let tangent_chain = result
681            .root_module_artifact_commands()
682            .iter()
683            .find_map(|artifact_command| match &artifact_command.command {
684                ModelingCmd::Solid3dCutEdges(command) => Some(command.tangent_chain),
685                _ => None,
686            })
687            .expect("chamfer should emit a Solid3dCutEdges command");
688        assert!(tangent_chain, "tangentChain should default to true after KCL 2");
689
690        let disabled_body = body.replace("tangentChain = true", "tangentChain = false");
691        let result = parse_execute(&format!("@settings(kclVersion = \"3.0-preview\")\n{disabled_body}"))
692            .await
693            .unwrap();
694        let tangent_chain = result
695            .root_module_artifact_commands()
696            .iter()
697            .find_map(|artifact_command| match &artifact_command.command {
698                ModelingCmd::Solid3dCutEdges(command) => Some(command.tangent_chain),
699                _ => None,
700            })
701            .expect("chamfer should emit a Solid3dCutEdges command");
702        assert!(!tangent_chain, "an explicit false should override the default");
703    }
704}