1use 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
35pub 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 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 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 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 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 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 .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 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 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 .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 #[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 #[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 #[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 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 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 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}