1use anyhow::Result;
4use indexmap::IndexMap;
5use kcmc::ModelingCmd;
6use kcmc::each_cmd as mcmd;
7use kcmc::length_unit::LengthUnit;
8use kcmc::shared::CutTypeV2;
9use kcmc::shared::EdgeCutVersion;
10use kittycad_modeling_cmds as kcmc;
11use serde::Deserialize;
12use serde::Serialize;
13
14use super::DEFAULT_TOLERANCE_MM;
15use super::args::TyF64;
16use crate::SourceRange;
17use crate::errors::KclError;
18use crate::errors::KclErrorDetails;
19use crate::execution::EdgeCut;
20use crate::execution::ExecState;
21use crate::execution::ExtrudeSurface;
22use crate::execution::FilletSurface;
23use crate::execution::GeoMeta;
24use crate::execution::KclValue;
25use crate::execution::KclVersion;
26use crate::execution::ModelingCmdMeta;
27use crate::execution::Solid;
28use crate::execution::TagIdentifier;
29use crate::execution::types::RuntimeType;
30use crate::parsing::ast::types::TagNode;
31use crate::std::Args;
32use crate::std::csg::CsgAlgorithm;
33
34#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)]
36#[serde(untagged)]
37pub enum EdgeReference {
38 Uuid(uuid::Uuid),
40 Tag(Box<TagIdentifier>),
42}
43
44impl EdgeReference {
45 pub fn get_engine_id(&self, exec_state: &mut ExecState, args: &Args) -> Result<uuid::Uuid, KclError> {
46 match self {
47 EdgeReference::Uuid(uuid) => Ok(*uuid),
48 EdgeReference::Tag(tag) => Ok(args.get_tag_engine_info(exec_state, tag)?.id),
49 }
50 }
51
52 pub fn get_all_engine_ids(&self, exec_state: &mut ExecState, args: &Args) -> Result<Vec<uuid::Uuid>, KclError> {
55 match self {
56 EdgeReference::Uuid(uuid) => Ok(vec![*uuid]),
57 EdgeReference::Tag(tag) => {
58 let infos = tag.get_all_cur_info();
59 if infos.is_empty() {
60 Ok(vec![args.get_tag_engine_info(exec_state, tag)?.id])
62 } else {
63 Ok(infos.iter().map(|i| i.id).collect())
64 }
65 }
66 }
67 }
68}
69
70pub(super) fn validate_unique<T: Eq + std::hash::Hash>(tags: &[(T, SourceRange)]) -> Result<(), KclError> {
71 let mut tag_counts: IndexMap<&T, Vec<SourceRange>> = Default::default();
73 for tag in tags {
74 tag_counts.entry(&tag.0).or_insert(Vec::new()).push(tag.1);
75 }
76 let mut duplicate_tags_source = Vec::new();
77 for (_tag, count) in tag_counts {
78 if count.len() > 1 {
79 duplicate_tags_source.extend(count)
80 }
81 }
82 if !duplicate_tags_source.is_empty() {
83 return Err(KclError::new_type(KclErrorDetails::new(
84 "The same edge ID is being referenced multiple times, which is not allowed. Please select a different edge"
85 .to_string(),
86 duplicate_tags_source,
87 )));
88 }
89 Ok(())
90}
91
92pub(super) enum TaggedEdgeInputs {
93 Tags(Vec<(EdgeReference, SourceRange)>),
94 EngineRefs(Vec<kcmc::shared::EdgeSpecifier>),
95}
96
97pub(super) async fn parse_tagged_edge_inputs(
98 edge_refs: Option<Vec<KclValue>>,
99 tags_with_source: Option<Vec<(EdgeReference, SourceRange)>>,
100 solid: Option<&Solid>,
101 exec_state: &mut ExecState,
102 args: &Args,
103 missing_args_message: &str,
104 both_args_message: &str,
105) -> Result<TaggedEdgeInputs, KclError> {
106 match (edge_refs, tags_with_source) {
107 (Some(_), Some(_)) => Err(KclError::new_semantic(KclErrorDetails::new(
108 both_args_message.to_owned(),
109 vec![args.source_range],
110 ))),
111 (Some(edge_refs), None) => {
112 let edge_refs_parsed =
113 super::edge::parse_edge_refs_to_references(edge_refs, solid, exec_state, args).await?;
114 Ok(TaggedEdgeInputs::EngineRefs(edge_refs_parsed))
115 }
116 (None, Some(tags_with_source)) => {
117 validate_unique(&tags_with_source)?;
118 Ok(TaggedEdgeInputs::Tags(tags_with_source))
119 }
120 (None, None) => Err(KclError::new_semantic(KclErrorDetails::new(
121 missing_args_message.to_owned(),
122 vec![args.source_range],
123 ))),
124 }
125}
126
127pub async fn fillet(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
129 let solid: Box<Solid> = args.get_unlabeled_kw_arg("solid", &RuntimeType::solid(), exec_state)?;
130 let radius: TyF64 = args.get_kw_arg("radius", &RuntimeType::length(), exec_state)?;
131 let tolerance: Option<TyF64> = args.get_kw_arg_opt("tolerance", &RuntimeType::length(), exec_state)?;
132 let tag = args.get_kw_arg_opt("tag", &RuntimeType::tag_decl(), exec_state)?;
133 let legacy_csg: Option<bool> = args.get_kw_arg_opt("legacyMethod", &RuntimeType::bool(), exec_state)?;
134 let csg_algorithm = CsgAlgorithm::legacy(legacy_csg.unwrap_or_default());
135 let edge_cut_number: Option<u32> = args.get_kw_arg_opt("version", &RuntimeType::count(), exec_state)?;
136 let tangent_chain: Option<bool> = args.get_kw_arg_opt("tangentChain", &RuntimeType::bool(), exec_state)?;
137 let tangent_chain = tangent_chain.unwrap_or(exec_state.kcl_version() >= KclVersion::V3Preview);
138 let edge_cut_version: EdgeCutVersion = edge_cut_number
139 .map(|num| {
140 num.try_into().map_err(|()| {
141 KclError::new_semantic(KclErrorDetails::new(
142 format!("{} is not a version of the Zoo edge cut algorithm", num),
143 vec![args.source_range],
144 ))
145 })
146 })
147 .transpose()?
148 .unwrap_or_else(|| default_edge_cut_version(exec_state.kcl_version()));
149
150 let edge_refs: Option<Vec<KclValue>> = args.get_kw_arg_opt("edges", &RuntimeType::any_array(), exec_state)?;
153 let tags = args.kw_arg_edge_array_and_source_opt("tags")?;
154
155 let edge_inputs = parse_tagged_edge_inputs(
156 edge_refs,
157 tags,
158 Some(solid.as_ref()),
159 exec_state,
160 &args,
161 "You must provide either 'tags' or 'edges' to fillet edges",
162 "You must provide either 'tags' or 'edges' to fillet edges, not both",
163 )
164 .await?;
165
166 match edge_inputs {
167 TaggedEdgeInputs::EngineRefs(edge_refs) => {
168 let params = FilletEdgeRefParams {
169 radius,
170 tolerance,
171 csg_algorithm,
172 edge_cut_version,
173 tangent_chain,
174 tag,
175 };
176 let value = inner_fillet_with_engine_refs(solid, edge_refs, params, exec_state, args).await?;
177 Ok(KclValue::Solid { value })
178 }
179 TaggedEdgeInputs::Tags(tags) => {
180 let value = inner_fillet(
181 solid,
182 radius,
183 tags,
184 tolerance,
185 csg_algorithm,
186 tag,
187 edge_cut_version,
188 tangent_chain,
189 exec_state,
190 args,
191 )
192 .await?;
193 Ok(KclValue::Solid { value })
194 }
195 }
196}
197
198pub(super) fn default_edge_cut_version(kcl_version: KclVersion) -> EdgeCutVersion {
200 if kcl_version <= KclVersion::V2 {
201 EdgeCutVersion::V1
202 } else {
203 EdgeCutVersion::V2
204 }
205}
206
207#[allow(clippy::too_many_arguments)]
208async fn inner_fillet(
209 solid: Box<Solid>,
210 radius: TyF64,
211 tags: Vec<(EdgeReference, SourceRange)>,
212 tolerance: Option<TyF64>,
213 csg_algorithm: CsgAlgorithm,
214 tag: Option<TagNode>,
215 edge_cut_version: EdgeCutVersion,
216 tangent_chain: bool,
217 exec_state: &mut ExecState,
218 args: Args,
219) -> Result<Box<Solid>, KclError> {
220 if tag.is_some() && tags.len() > 1 {
223 return Err(KclError::new_type(KclErrorDetails {
224 message: "You can only tag one edge at a time with a tagged fillet. Either delete the tag for the fillet fn if you don't need it OR separate into individual fillet functions for each tag.".to_string(),
225 source_ranges: vec![args.source_range],
226 backtrace: Default::default(),
227 }));
228 }
229 if tags.is_empty() {
230 return Err(KclError::new_semantic(KclErrorDetails {
231 source_ranges: vec![args.source_range],
232 message: "You must fillet at least one tag".to_owned(),
233 backtrace: Default::default(),
234 }));
235 }
236
237 let mut solid = solid.clone();
238 let mut edge_ids = Vec::new();
239 let mut tag_entries: Vec<crate::execution::DirectTagFilletTagEntry> = Vec::new();
240 for (edge_ref, source_range) in &tags {
241 let ids = edge_ref.get_all_engine_ids(exec_state, &args)?;
242 edge_ids.extend(ids.iter().copied());
243 let tag_identifier = match edge_ref {
244 EdgeReference::Tag(t) => t.value.clone(),
245 EdgeReference::Uuid(_) => String::new(),
246 };
247 for edge_id in ids {
248 if crate::runtime_flags::z0006_refactor_metadata_enabled()
249 && let Ok(face_ids) = super::edge::get_face_ids_for_edge(exec_state, solid.id, edge_id, &args).await
250 && let [a, b] = face_ids.as_slice()
251 {
252 if !tag_identifier.is_empty() {
253 tag_entries.push(crate::execution::DirectTagFilletTagEntry {
254 tag_identifier: tag_identifier.clone(),
255 edge_id,
256 face_ids: [*a, *b],
257 });
258 } else {
259 exec_state.record_edge_refactor_meta_from_pending(edge_id, *source_range, [*a, *b]);
260 }
261 }
262 }
263 }
264 if !tag_entries.is_empty() {
265 exec_state.record_direct_tag_fillet_meta(crate::execution::DirectTagFilletMeta {
266 call_source_range: args.source_range,
267 tags: tag_entries,
268 });
269 }
270
271 let id = exec_state.next_uuid();
272 let mut extra_face_ids = Vec::new();
273 let num_extra_ids = edge_ids.len() - 1;
274 for _ in 0..num_extra_ids {
275 extra_face_ids.push(exec_state.next_uuid());
276 }
277 exec_state
278 .batch_edge_cut_cmd(
279 ModelingCmdMeta::from_args_id(exec_state, &args, id),
280 ModelingCmd::from(
281 mcmd::Solid3dCutEdges::builder()
282 .use_legacy(csg_algorithm.is_legacy())
283 .edge_ids(edge_ids.clone())
284 .extra_face_ids(extra_face_ids)
285 .strategy(Default::default())
286 .object_id(solid.id)
287 .version(edge_cut_version)
288 .tangent_chain(tangent_chain)
289 .tolerance(LengthUnit(
290 tolerance.as_ref().map(|t| t.to_mm()).unwrap_or(DEFAULT_TOLERANCE_MM),
291 ))
292 .cut_type(CutTypeV2::Fillet {
293 radius: LengthUnit(radius.to_mm()),
294 second_length: None,
295 })
296 .build(),
297 ),
298 )
299 .await?;
300
301 let new_edge_cuts = edge_ids.into_iter().map(|edge_id| EdgeCut::Fillet {
302 id,
303 edge_id,
304 radius: radius.clone(),
305 tag: Box::new(tag.clone()),
306 });
307 solid.edge_cuts.extend(new_edge_cuts);
308
309 if let Some(ref tag) = tag {
310 solid.value.push(ExtrudeSurface::Fillet(FilletSurface {
311 face_id: id,
312 tag: Some(tag.clone()),
313 geo_meta: GeoMeta {
314 id,
315 metadata: args.source_range.into(),
316 },
317 }));
318 }
319
320 Ok(solid)
321}
322
323struct FilletEdgeRefParams {
324 radius: TyF64,
325 tolerance: Option<TyF64>,
326 csg_algorithm: CsgAlgorithm,
327 edge_cut_version: EdgeCutVersion,
328 tangent_chain: bool,
329 tag: Option<TagNode>,
330}
331
332async fn inner_fillet_with_engine_refs(
333 solid: Box<Solid>,
334 edge_references: Vec<kcmc::shared::EdgeSpecifier>,
335 params: FilletEdgeRefParams,
336 exec_state: &mut ExecState,
337 args: Args,
338) -> Result<Box<Solid>, KclError> {
339 if edge_references.is_empty() {
340 return Err(KclError::new_semantic(KclErrorDetails {
341 source_ranges: vec![args.source_range],
342 message: "You must provide at least one edge".to_owned(),
343 backtrace: Default::default(),
344 }));
345 }
346
347 if params.tag.is_some() && edge_references.len() > 1 {
348 return Err(KclError::new_type(KclErrorDetails {
349 message: "You can only tag one edge at a time with a tagged fillet. Either delete the tag for the fillet fn if you don't need it OR separate into individual fillet functions for each edge.".to_string(),
350 source_ranges: vec![args.source_range],
351 backtrace: Default::default(),
352 }));
353 }
354
355 let mut solid = solid.clone();
356
357 let id = exec_state.next_uuid();
358 let num_extra_ids = edge_references.len().saturating_sub(1);
359 let mut extra_face_ids = Vec::with_capacity(num_extra_ids);
360 for _ in 0..num_extra_ids {
361 extra_face_ids.push(exec_state.next_uuid());
362 }
363
364 exec_state
365 .batch_edge_cut_cmd(
366 ModelingCmdMeta::from_args_id(exec_state, &args, id),
367 ModelingCmd::from(
368 mcmd::Solid3dCutEdgeReferences::builder()
369 .object_id(solid.id)
370 .edges_references(edge_references.clone())
371 .cut_type(CutTypeV2::Fillet {
372 radius: LengthUnit(params.radius.to_mm()),
373 second_length: None,
374 })
375 .tolerance(LengthUnit(
376 params
377 .tolerance
378 .as_ref()
379 .map(|t| t.to_mm())
380 .unwrap_or(DEFAULT_TOLERANCE_MM),
381 ))
382 .strategy(Default::default())
383 .extra_face_ids(extra_face_ids)
384 .use_legacy(params.csg_algorithm.is_legacy())
385 .version(params.edge_cut_version)
386 .tangent_chain(params.tangent_chain)
387 .build(),
388 ),
389 )
390 .await?;
391
392 solid.pending_edge_cut_ids.push(id);
393
394 if let Some(ref tag) = params.tag {
395 solid.value.push(ExtrudeSurface::Fillet(FilletSurface {
396 face_id: id,
397 tag: Some(tag.clone()),
398 geo_meta: GeoMeta {
399 id,
400 metadata: args.source_range.into(),
401 },
402 }));
403 }
404
405 Ok(solid)
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411 use crate::execution::ExecTestResults;
412 use crate::execution::parse_execute;
413
414 #[tokio::test(flavor = "multi_thread")]
416 async fn fillet_default_depends_on_kcl_version() {
417 assert_eq!(emitted_fillet_version("1.0", None).await, EdgeCutVersion::V1);
418 assert_eq!(emitted_fillet_version("2.0", None).await, EdgeCutVersion::V1);
419 assert_eq!(
420 emitted_fillet_version("\"3.0-preview\"", None).await,
421 EdgeCutVersion::V2
422 );
423 }
424
425 #[tokio::test(flavor = "multi_thread")]
428 async fn explicit_fillet_version_overrides_kcl_default() {
429 assert_eq!(emitted_fillet_version("2.0", Some(2)).await, EdgeCutVersion::V2);
430 }
431
432 #[tokio::test(flavor = "multi_thread")]
435 async fn fillet_version_is_removed_in_kcl_3() {
436 let result = run_fillet("\"3.0-preview\"", Some(1)).await;
437 assert!(
438 result
439 .issues()
440 .iter()
441 .any(|issue| {
442 issue.message
443 == "`version` is not an argument of `fillet`; it was removed in KCL 3.0, but this program uses KCL 3.0-preview"
444 }),
445 "issues: {:#?}",
446 result.issues()
447 );
448 assert_eq!(emitted_cut_edges_version(&result), EdgeCutVersion::V2);
449 }
450
451 #[tokio::test(flavor = "multi_thread")]
452 async fn tangent_chain_requires_kcl_3_and_is_sent_to_engine() {
453 let body = r#"
454profile = sketch(on = XY) {
455 edge1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
456 edge2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
457 edge3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
458 edge4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
459 coincident([edge1.end, edge2.start])
460 coincident([edge2.end, edge3.start])
461 coincident([edge3.end, edge4.start])
462 coincident([edge4.end, edge1.start])
463}
464profileRegion = region(point = [5mm, 5mm], sketch = profile)
465solid = extrude(profileRegion, length = 10mm, tagEnd = $top)
466fillet(solid, tags = [getCommonEdge(faces = [profileRegion.tags.edge1, top])], radius = 1mm, tangentChain = true)
467"#;
468
469 let result = parse_execute(&format!("@settings(kclVersion = 2.0)\n{body}"))
470 .await
471 .unwrap();
472 assert!(result.issues().iter().any(|issue| {
473 issue.message
474 == "`tangentChain` is not an argument of `fillet`; it was added in KCL 3.0, but this program uses KCL 2.0"
475 }));
476
477 let result = parse_execute(&format!("@settings(kclVersion = \"3.0-preview\")\n{body}"))
478 .await
479 .unwrap();
480 let tangent_chain = result
481 .root_module_artifact_commands()
482 .iter()
483 .find_map(|artifact_command| match &artifact_command.command {
484 ModelingCmd::Solid3dCutEdges(command) => Some(command.tangent_chain),
485 _ => None,
486 })
487 .expect("fillet should emit a Solid3dCutEdges command");
488 assert!(tangent_chain);
489
490 let default_body = body.replace(", tangentChain = true", "");
491 let result = parse_execute(&format!("@settings(kclVersion = \"3.0-preview\")\n{default_body}"))
492 .await
493 .unwrap();
494 let tangent_chain = result
495 .root_module_artifact_commands()
496 .iter()
497 .find_map(|artifact_command| match &artifact_command.command {
498 ModelingCmd::Solid3dCutEdges(command) => Some(command.tangent_chain),
499 _ => None,
500 })
501 .expect("fillet should emit a Solid3dCutEdges command");
502 assert!(tangent_chain, "tangentChain should default to true after KCL 2");
503
504 let disabled_body = body.replace("tangentChain = true", "tangentChain = false");
505 let result = parse_execute(&format!("@settings(kclVersion = \"3.0-preview\")\n{disabled_body}"))
506 .await
507 .unwrap();
508 let tangent_chain = result
509 .root_module_artifact_commands()
510 .iter()
511 .find_map(|artifact_command| match &artifact_command.command {
512 ModelingCmd::Solid3dCutEdges(command) => Some(command.tangent_chain),
513 _ => None,
514 })
515 .expect("fillet should emit a Solid3dCutEdges command");
516 assert!(!tangent_chain, "an explicit false should override the default");
517 }
518
519 async fn emitted_fillet_version(kcl_version: &str, explicit_version: Option<u32>) -> EdgeCutVersion {
522 emitted_cut_edges_version(&run_fillet(kcl_version, explicit_version).await)
523 }
524
525 async fn run_fillet(kcl_version: &str, explicit_version: Option<u32>) -> ExecTestResults {
528 let version_arg = explicit_version
529 .map(|version| format!(", version = {version}"))
530 .unwrap_or_default();
531 let code = format!(
532 r#"@settings(kclVersion = {kcl_version}, experimentalFeatures = allow)
533
534profile = sketch(on = XY) {{
535 edge1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
536 edge2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
537 edge3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
538 edge4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
539 coincident([edge1.end, edge2.start])
540 coincident([edge2.end, edge3.start])
541 coincident([edge3.end, edge4.start])
542 coincident([edge4.end, edge1.start])
543}}
544profileRegion = region(point = [5mm, 5mm], sketch = profile)
545solid = extrude(profileRegion, length = 10mm, tagEnd = $top)
546fillet(solid, tags = [getCommonEdge(faces = [profileRegion.tags.edge1, top])], radius = 1mm{version_arg})
547"#
548 );
549 parse_execute(&code).await.unwrap()
550 }
551
552 fn emitted_cut_edges_version(result: &ExecTestResults) -> EdgeCutVersion {
554 result
555 .root_module_artifact_commands()
556 .iter()
557 .find_map(|artifact_command| match &artifact_command.command {
558 ModelingCmd::Solid3dCutEdges(command) => Some(command.version),
559 _ => None,
560 })
561 .expect("fillet should emit a Solid3dCutEdges command")
562 }
563
564 #[test]
565 fn test_validate_unique() {
566 let dup_a = SourceRange::from([1, 3, 0]);
567 let dup_b = SourceRange::from([10, 30, 0]);
568 let tags = vec![("abc", dup_a), ("abc", dup_b), ("def", SourceRange::from([2, 4, 0]))];
570 let actual = validate_unique(&tags);
571 let expected = vec![dup_a, dup_b];
575 assert_eq!(actual.err().unwrap().source_ranges(), expected);
576 }
577}