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 let Ok(face_ids) = super::edge::get_face_ids_for_edge(exec_state, solid.id, edge_id, &args).await
221            && let [a, b] = face_ids.as_slice()
222        {
223            let tag_identifier = match edge_ref {
224                EdgeReference::Tag(t) => t.value.clone(),
225                EdgeReference::Uuid(_) => String::new(),
226            };
227            if !tag_identifier.is_empty() {
228                tag_entries.push(crate::execution::DirectTagFilletTagEntry {
229                    tag_identifier,
230                    edge_id,
231                    face_ids: [*a, *b],
232                });
233            } else {
234                exec_state.record_edge_refactor_meta_from_pending(edge_id, *source_range, [*a, *b]);
235            }
236        }
237    }
238    if !tag_entries.is_empty() {
239        exec_state.record_direct_tag_fillet_meta(crate::execution::DirectTagFilletMeta {
240            call_source_range: args.source_range,
241            tags: tag_entries,
242        });
243    }
244    for (edge_tag, _) in tags {
245        let edge_ids = edge_tag.get_all_engine_ids(exec_state, &args)?;
246        for edge_id in edge_ids {
247            let id = exec_state.next_uuid();
248            exec_state
249                .batch_edge_cut_cmd(
250                    ModelingCmdMeta::from_args_id(exec_state, &args, id),
251                    ModelingCmd::from(
252                        mcmd::Solid3dCutEdges::builder()
253                            .use_legacy(csg_algorithm.is_legacy())
254                            .edge_ids(vec![edge_id])
255                            .extra_face_ids(vec![])
256                            .strategy(strategy)
257                            .object_id(solid.id)
258                            // We can let the user set this in the future.
259                            .tolerance(LengthUnit(DEFAULT_TOLERANCE))
260                            .cut_type(cut_type)
261                            .version(edge_cut_version)
262                            .tangent_chain(tangent_chain)
263                            .build(),
264                    ),
265                )
266                .await?;
267
268            solid.edge_cuts.push(EdgeCut::Chamfer {
269                id,
270                edge_id,
271                length: length.clone(),
272                tag: Box::new(tag.clone()),
273            });
274
275            if let Some(ref tag) = tag {
276                solid.value.push(ExtrudeSurface::Chamfer(ChamferSurface {
277                    face_id: id,
278                    tag: Some(tag.clone()),
279                    geo_meta: GeoMeta {
280                        id,
281                        metadata: args.source_range.into(),
282                    },
283                }));
284            }
285        }
286    }
287
288    Ok(solid)
289}
290
291#[allow(clippy::too_many_arguments)]
292async fn inner_chamfer_v2(
293    solid: Box<Solid>,
294    length: TyF64,
295    tags: Vec<(EdgeReference, crate::SourceRange)>,
296    second_length: Option<TyF64>,
297    angle: Option<TyF64>,
298    custom_profile: Option<Sketch>,
299    tag: Option<TagNode>,
300    csg_algorithm: CsgAlgorithm,
301    edge_cut_version: EdgeCutVersion,
302    tangent_chain: bool,
303    exec_state: &mut ExecState,
304    args: Args,
305) -> Result<Box<Solid>, KclError> {
306    // If you try and tag multiple edges with a tagged chamfer, we want to return an
307    // error to the user that they can only tag one edge at a time.
308    if tag.is_some() && tags.len() > 1 {
309        return Err(KclError::new_type(KclErrorDetails::new(
310            "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(),
311            vec![args.source_range],
312        )));
313    }
314    if tags.is_empty() {
315        return Err(KclError::new_semantic(KclErrorDetails {
316            source_ranges: vec![args.source_range],
317            message: "You must chamfer at least one tag".to_owned(),
318            backtrace: Default::default(),
319        }));
320    }
321
322    if angle.is_some() && second_length.is_some() {
323        return Err(KclError::new_semantic(KclErrorDetails::new(
324            "Cannot specify both an angle and a second length. Specify only one.".to_string(),
325            vec![args.source_range],
326        )));
327    }
328
329    let strategy = if second_length.is_some() || angle.is_some() || custom_profile.is_some() {
330        CutStrategy::Csg
331    } else {
332        Default::default()
333    };
334
335    let second_distance = second_length.map(|x| LengthUnit(x.to_mm()));
336    let angle = angle.map(|x| Angle::from_degrees(x.to_degrees(exec_state, args.source_range)));
337    if let Some(angle) = angle
338        && (angle.ge(&Angle::quarter_circle()) || angle.le(&Angle::zero()))
339    {
340        return Err(KclError::new_semantic(KclErrorDetails::new(
341            "The angle of a chamfer must be greater than zero and less than 90 degrees.".to_string(),
342            vec![args.source_range],
343        )));
344    }
345
346    let cut_type = if let Some(custom_profile) = custom_profile {
347        // Hide the custom profile since it's no longer its own profile
348        exec_state
349            .batch_modeling_cmd(
350                ModelingCmdMeta::from_args(exec_state, &args),
351                ModelingCmd::from(
352                    mcmd::ObjectVisible::builder()
353                        .object_id(custom_profile.id)
354                        .hidden(true)
355                        .build(),
356                ),
357            )
358            .await?;
359        CutTypeV2::Custom {
360            path: custom_profile.id,
361        }
362    } else {
363        CutTypeV2::Chamfer {
364            distance: LengthUnit(length.to_mm()),
365            second_distance,
366            angle,
367            swap: false,
368        }
369    };
370
371    let mut solid = solid.clone();
372    let mut edge_ids = Vec::new();
373    let mut tag_entries: Vec<crate::execution::DirectTagFilletTagEntry> = Vec::new();
374    for (edge_ref, source_range) in &tags {
375        let ids = edge_ref.get_all_engine_ids(exec_state, &args)?;
376        edge_ids.extend(ids.iter().copied());
377        let tag_identifier = match edge_ref {
378            EdgeReference::Tag(t) => t.value.clone(),
379            EdgeReference::Uuid(_) => String::new(),
380        };
381        for edge_id in ids {
382            if let Ok(face_ids) = super::edge::get_face_ids_for_edge(exec_state, solid.id, edge_id, &args).await
383                && let [a, b] = face_ids.as_slice()
384            {
385                if !tag_identifier.is_empty() {
386                    tag_entries.push(crate::execution::DirectTagFilletTagEntry {
387                        tag_identifier: tag_identifier.clone(),
388                        edge_id,
389                        face_ids: [*a, *b],
390                    });
391                } else {
392                    exec_state.record_edge_refactor_meta_from_pending(edge_id, *source_range, [*a, *b]);
393                }
394            }
395        }
396    }
397    if !tag_entries.is_empty() {
398        exec_state.record_direct_tag_fillet_meta(crate::execution::DirectTagFilletMeta {
399            call_source_range: args.source_range,
400            tags: tag_entries,
401        });
402    }
403
404    let id = exec_state.next_uuid();
405    let num_extra_ids = edge_ids.len().saturating_sub(1);
406    let mut extra_face_ids = Vec::with_capacity(num_extra_ids);
407    for _ in 0..num_extra_ids {
408        extra_face_ids.push(exec_state.next_uuid());
409    }
410    exec_state
411        .batch_edge_cut_cmd(
412            ModelingCmdMeta::from_args_id(exec_state, &args, id),
413            ModelingCmd::from(
414                mcmd::Solid3dCutEdges::builder()
415                    .use_legacy(csg_algorithm.is_legacy())
416                    .edge_ids(edge_ids.clone())
417                    .extra_face_ids(extra_face_ids)
418                    .strategy(strategy)
419                    .object_id(solid.id)
420                    // We can let the user set this in the future.
421                    .tolerance(LengthUnit(DEFAULT_TOLERANCE))
422                    .cut_type(cut_type)
423                    .version(edge_cut_version)
424                    .tangent_chain(tangent_chain)
425                    .build(),
426            ),
427        )
428        .await?;
429
430    let new_edge_cuts = edge_ids.into_iter().map(|edge_id| EdgeCut::Chamfer {
431        id,
432        edge_id,
433        length: length.clone(),
434        tag: Box::new(tag.clone()),
435    });
436    solid.edge_cuts.extend(new_edge_cuts);
437
438    if let Some(ref tag) = tag {
439        solid.value.push(ExtrudeSurface::Chamfer(ChamferSurface {
440            face_id: id,
441            tag: Some(tag.clone()),
442            geo_meta: GeoMeta {
443                id,
444                metadata: args.source_range.into(),
445            },
446        }));
447    }
448
449    Ok(solid)
450}
451
452#[expect(clippy::too_many_arguments)]
453async fn inner_chamfer_with_engine_refs(
454    solid: Box<Solid>,
455    length: TyF64,
456    edge_references: Vec<kcmc::shared::EdgeSpecifier>,
457    second_length: Option<TyF64>,
458    angle: Option<TyF64>,
459    csg_algorithm: CsgAlgorithm,
460    edge_cut_version: EdgeCutVersion,
461    tangent_chain: bool,
462    tag: Option<TagNode>,
463    exec_state: &mut ExecState,
464    args: Args,
465) -> Result<Box<Solid>, KclError> {
466    if tag.is_some() && edge_references.len() > 1 {
467        return Err(KclError::new_type(KclErrorDetails::new(
468            "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(),
469            vec![args.source_range],
470        )));
471    }
472
473    if angle.is_some() && second_length.is_some() {
474        return Err(KclError::new_semantic(KclErrorDetails::new(
475            "Cannot specify both an angle and a second length. Specify only one.".to_string(),
476            vec![args.source_range],
477        )));
478    }
479
480    let strategy = if second_length.is_some() || angle.is_some() {
481        CutStrategy::Csg
482    } else {
483        Default::default()
484    };
485
486    let second_distance = second_length.map(|x| LengthUnit(x.to_mm()));
487    let angle = angle.map(|x| Angle::from_degrees(x.to_degrees(exec_state, args.source_range)));
488    if let Some(angle) = angle
489        && (angle.ge(&Angle::quarter_circle()) || angle.le(&Angle::zero()))
490    {
491        return Err(KclError::new_semantic(KclErrorDetails::new(
492            "The angle of a chamfer must be greater than zero and less than 90 degrees.".to_string(),
493            vec![args.source_range],
494        )));
495    }
496
497    let cut_type = CutTypeV2::Chamfer {
498        distance: LengthUnit(length.to_mm()),
499        second_distance,
500        angle,
501        swap: false,
502    };
503
504    let id = exec_state.next_uuid();
505    let num_extra_ids = edge_references.len().saturating_sub(1);
506    let mut extra_face_ids = Vec::with_capacity(num_extra_ids);
507    for _ in 0..num_extra_ids {
508        extra_face_ids.push(exec_state.next_uuid());
509    }
510
511    let mut solid = solid.clone();
512    exec_state
513        .batch_edge_cut_cmd(
514            ModelingCmdMeta::from_args_id(exec_state, &args, id),
515            ModelingCmd::from(
516                mcmd::Solid3dCutEdgeReferences::builder()
517                    .object_id(solid.id)
518                    .edges_references(edge_references)
519                    .cut_type(cut_type)
520                    .tolerance(LengthUnit(DEFAULT_TOLERANCE))
521                    .strategy(strategy)
522                    .extra_face_ids(extra_face_ids)
523                    .use_legacy(csg_algorithm.is_legacy())
524                    .version(edge_cut_version)
525                    .tangent_chain(tangent_chain)
526                    .build(),
527            ),
528        )
529        .await?;
530
531    solid.pending_edge_cut_ids.push(id);
532
533    if let Some(ref tag) = tag {
534        solid.value.push(ExtrudeSurface::Chamfer(ChamferSurface {
535            face_id: id,
536            tag: Some(tag.clone()),
537            geo_meta: GeoMeta {
538                id,
539                metadata: args.source_range.into(),
540            },
541        }));
542    }
543
544    Ok(solid)
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550    use crate::execution::ExecTestResults;
551    use crate::execution::parse_execute;
552
553    /// Test what version of chamfer each KCL version uses by default.
554    #[tokio::test(flavor = "multi_thread")]
555    async fn chamfer_default_depends_on_kcl_version() {
556        assert_eq!(emitted_chamfer_version("2.0", None).await, EdgeCutVersion::V1);
557        assert_eq!(
558            emitted_chamfer_version("\"3.0-preview\"", None).await,
559            EdgeCutVersion::V2
560        );
561    }
562
563    /// If the user chooses a chamfer algorithm version, KCL should respect it,
564    /// and not use that KCL version's default chamfer algorithm version.
565    #[tokio::test(flavor = "multi_thread")]
566    async fn explicit_chamfer_version_overrides_kcl_default() {
567        assert_eq!(emitted_chamfer_version("2.0", Some(2)).await, EdgeCutVersion::V2);
568    }
569
570    /// KCL 3.0 removed `chamfer(version = )`. Passing it is reported like any
571    /// other unknown argument, and the default algorithm is used.
572    #[tokio::test(flavor = "multi_thread")]
573    async fn chamfer_version_is_removed_in_kcl_3() {
574        let result = run_chamfer("\"3.0-preview\"", Some(1)).await;
575        assert!(
576            result
577                .issues()
578                .iter()
579                .any(|issue| {
580                    issue.message
581                        == "`version` is not an argument of `chamfer`; it was removed in KCL 3.0, but this program uses KCL 3.0-preview"
582                }),
583            "issues: {:#?}",
584            result.issues()
585        );
586        assert_eq!(emitted_cut_edges_version(&result), EdgeCutVersion::V2);
587    }
588
589    /// For a given KCL version, and optional `chamfer(version = )` version,
590    /// show what chamfer algorithm version the runtime sent to the engine.
591    async fn emitted_chamfer_version(kcl_version: &str, explicit_version: Option<u32>) -> EdgeCutVersion {
592        emitted_cut_edges_version(&run_chamfer(kcl_version, explicit_version).await)
593    }
594
595    /// Chamfer one edge of a box under the given KCL version, optionally
596    /// passing `chamfer(version = )`.
597    async fn run_chamfer(kcl_version: &str, explicit_version: Option<u32>) -> ExecTestResults {
598        let version_arg = explicit_version
599            .map(|version| format!(", version = {version}"))
600            .unwrap_or_default();
601        let code = format!(
602            r#"@settings(kclVersion = {kcl_version}, experimentalFeatures = allow)
603
604profile = sketch(on = XY) {{
605  edge1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
606  edge2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
607  edge3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
608  edge4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
609  coincident([edge1.end, edge2.start])
610  coincident([edge2.end, edge3.start])
611  coincident([edge3.end, edge4.start])
612  coincident([edge4.end, edge1.start])
613}}
614profileRegion = region(point = [5mm, 5mm], sketch = profile)
615solid = extrude(profileRegion, length = 10mm, tagEnd = $top)
616chamfer(solid, tags = [getCommonEdge(faces = [profileRegion.tags.edge1, top])], length = 1mm{version_arg})
617"#
618        );
619
620        parse_execute(&code).await.unwrap()
621    }
622
623    /// The chamfer algorithm version the runtime sent to the engine.
624    fn emitted_cut_edges_version(result: &ExecTestResults) -> EdgeCutVersion {
625        result
626            .root_module_artifact_commands()
627            .iter()
628            .find_map(|artifact_command| match &artifact_command.command {
629                ModelingCmd::Solid3dCutEdges(command) => Some(command.version),
630                _ => None,
631            })
632            .expect("chamfer should emit a Solid3dCutEdges command")
633    }
634
635    #[tokio::test(flavor = "multi_thread")]
636    async fn tangent_chain_requires_kcl_3_and_is_sent_to_engine() {
637        let body = r#"
638profile = sketch(on = XY) {
639  edge1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
640  edge2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
641  edge3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
642  edge4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
643  coincident([edge1.end, edge2.start])
644  coincident([edge2.end, edge3.start])
645  coincident([edge3.end, edge4.start])
646  coincident([edge4.end, edge1.start])
647}
648profileRegion = region(point = [5mm, 5mm], sketch = profile)
649solid = extrude(profileRegion, length = 10mm, tagEnd = $top)
650chamfer(solid, tags = [getCommonEdge(faces = [profileRegion.tags.edge1, top])], length = 1mm, tangentChain = true)
651"#;
652
653        let result = parse_execute(&format!("@settings(kclVersion = 2.0)\n{body}"))
654            .await
655            .unwrap();
656        assert!(result.issues().iter().any(|issue| {
657            issue.message
658                == "`tangentChain` is not an argument of `chamfer`; it was added in KCL 3.0, but this program uses KCL 2.0"
659        }));
660
661        let result = parse_execute(&format!("@settings(kclVersion = \"3.0-preview\")\n{body}"))
662            .await
663            .unwrap();
664        let tangent_chain = result
665            .root_module_artifact_commands()
666            .iter()
667            .find_map(|artifact_command| match &artifact_command.command {
668                ModelingCmd::Solid3dCutEdges(command) => Some(command.tangent_chain),
669                _ => None,
670            })
671            .expect("chamfer should emit a Solid3dCutEdges command");
672        assert!(tangent_chain);
673
674        let default_body = body.replace(", tangentChain = true", "");
675        let result = parse_execute(&format!("@settings(kclVersion = \"3.0-preview\")\n{default_body}"))
676            .await
677            .unwrap();
678        let tangent_chain = result
679            .root_module_artifact_commands()
680            .iter()
681            .find_map(|artifact_command| match &artifact_command.command {
682                ModelingCmd::Solid3dCutEdges(command) => Some(command.tangent_chain),
683                _ => None,
684            })
685            .expect("chamfer should emit a Solid3dCutEdges command");
686        assert!(tangent_chain, "tangentChain should default to true after KCL 2");
687
688        let disabled_body = body.replace("tangentChain = true", "tangentChain = false");
689        let result = parse_execute(&format!("@settings(kclVersion = \"3.0-preview\")\n{disabled_body}"))
690            .await
691            .unwrap();
692        let tangent_chain = result
693            .root_module_artifact_commands()
694            .iter()
695            .find_map(|artifact_command| match &artifact_command.command {
696                ModelingCmd::Solid3dCutEdges(command) => Some(command.tangent_chain),
697                _ => None,
698            })
699            .expect("chamfer should emit a Solid3dCutEdges command");
700        assert!(!tangent_chain, "an explicit false should override the default");
701    }
702}