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 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 .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 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 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 .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 #[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 #[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 #[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 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 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 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}