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 let Ok(face_ids) = super::edge::get_face_ids_for_edge(exec_state, solid.id, edge_id, &args).await
249 && let [a, b] = face_ids.as_slice()
250 {
251 if !tag_identifier.is_empty() {
252 tag_entries.push(crate::execution::DirectTagFilletTagEntry {
253 tag_identifier: tag_identifier.clone(),
254 edge_id,
255 face_ids: [*a, *b],
256 });
257 } else {
258 exec_state.record_edge_refactor_meta_from_pending(edge_id, *source_range, [*a, *b]);
259 }
260 }
261 }
262 }
263 if !tag_entries.is_empty() {
264 exec_state.record_direct_tag_fillet_meta(crate::execution::DirectTagFilletMeta {
265 call_source_range: args.source_range,
266 tags: tag_entries,
267 });
268 }
269
270 let id = exec_state.next_uuid();
271 let mut extra_face_ids = Vec::new();
272 let num_extra_ids = edge_ids.len() - 1;
273 for _ in 0..num_extra_ids {
274 extra_face_ids.push(exec_state.next_uuid());
275 }
276 exec_state
277 .batch_edge_cut_cmd(
278 ModelingCmdMeta::from_args_id(exec_state, &args, id),
279 ModelingCmd::from(
280 mcmd::Solid3dCutEdges::builder()
281 .use_legacy(csg_algorithm.is_legacy())
282 .edge_ids(edge_ids.clone())
283 .extra_face_ids(extra_face_ids)
284 .strategy(Default::default())
285 .object_id(solid.id)
286 .version(edge_cut_version)
287 .tangent_chain(tangent_chain)
288 .tolerance(LengthUnit(
289 tolerance
290 .as_ref()
291 .map(|t| t.unwrap_to_mm())
292 .unwrap_or(DEFAULT_TOLERANCE_MM),
293 ))
294 .cut_type(CutTypeV2::Fillet {
295 radius: LengthUnit(radius.unwrap_to_mm()),
296 second_length: None,
297 })
298 .build(),
299 ),
300 )
301 .await?;
302
303 let new_edge_cuts = edge_ids.into_iter().map(|edge_id| EdgeCut::Fillet {
304 id,
305 edge_id,
306 radius: radius.clone(),
307 tag: Box::new(tag.clone()),
308 });
309 solid.edge_cuts.extend(new_edge_cuts);
310
311 if let Some(ref tag) = tag {
312 solid.value.push(ExtrudeSurface::Fillet(FilletSurface {
313 face_id: id,
314 tag: Some(tag.clone()),
315 geo_meta: GeoMeta {
316 id,
317 metadata: args.source_range.into(),
318 },
319 }));
320 }
321
322 Ok(solid)
323}
324
325struct FilletEdgeRefParams {
326 radius: TyF64,
327 tolerance: Option<TyF64>,
328 csg_algorithm: CsgAlgorithm,
329 edge_cut_version: EdgeCutVersion,
330 tangent_chain: bool,
331 tag: Option<TagNode>,
332}
333
334async fn inner_fillet_with_engine_refs(
335 solid: Box<Solid>,
336 edge_references: Vec<kcmc::shared::EdgeSpecifier>,
337 params: FilletEdgeRefParams,
338 exec_state: &mut ExecState,
339 args: Args,
340) -> Result<Box<Solid>, KclError> {
341 if edge_references.is_empty() {
342 return Err(KclError::new_semantic(KclErrorDetails {
343 source_ranges: vec![args.source_range],
344 message: "You must provide at least one edge".to_owned(),
345 backtrace: Default::default(),
346 }));
347 }
348
349 if params.tag.is_some() && edge_references.len() > 1 {
350 return Err(KclError::new_type(KclErrorDetails {
351 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(),
352 source_ranges: vec![args.source_range],
353 backtrace: Default::default(),
354 }));
355 }
356
357 let mut solid = solid.clone();
358
359 let id = exec_state.next_uuid();
360 let num_extra_ids = edge_references.len().saturating_sub(1);
361 let mut extra_face_ids = Vec::with_capacity(num_extra_ids);
362 for _ in 0..num_extra_ids {
363 extra_face_ids.push(exec_state.next_uuid());
364 }
365
366 exec_state
367 .batch_edge_cut_cmd(
368 ModelingCmdMeta::from_args_id(exec_state, &args, id),
369 ModelingCmd::from(
370 mcmd::Solid3dCutEdgeReferences::builder()
371 .object_id(solid.id)
372 .edges_references(edge_references.clone())
373 .cut_type(CutTypeV2::Fillet {
374 radius: LengthUnit(params.radius.unwrap_to_mm()),
375 second_length: None,
376 })
377 .tolerance(LengthUnit(
378 params
379 .tolerance
380 .as_ref()
381 .map(|t| t.unwrap_to_mm())
382 .unwrap_or(DEFAULT_TOLERANCE_MM),
383 ))
384 .strategy(Default::default())
385 .extra_face_ids(extra_face_ids)
386 .use_legacy(params.csg_algorithm.is_legacy())
387 .version(params.edge_cut_version)
388 .tangent_chain(params.tangent_chain)
389 .build(),
390 ),
391 )
392 .await?;
393
394 solid.pending_edge_cut_ids.push(id);
395
396 if let Some(ref tag) = params.tag {
397 solid.value.push(ExtrudeSurface::Fillet(FilletSurface {
398 face_id: id,
399 tag: Some(tag.clone()),
400 geo_meta: GeoMeta {
401 id,
402 metadata: args.source_range.into(),
403 },
404 }));
405 }
406
407 Ok(solid)
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use crate::execution::ExecTestResults;
414 use crate::execution::parse_execute;
415
416 #[tokio::test(flavor = "multi_thread")]
418 async fn fillet_default_depends_on_kcl_version() {
419 assert_eq!(emitted_fillet_version("1.0", None).await, EdgeCutVersion::V1);
420 assert_eq!(emitted_fillet_version("2.0", None).await, EdgeCutVersion::V1);
421 assert_eq!(
422 emitted_fillet_version("\"3.0-preview\"", None).await,
423 EdgeCutVersion::V2
424 );
425 }
426
427 #[tokio::test(flavor = "multi_thread")]
430 async fn explicit_fillet_version_overrides_kcl_default() {
431 assert_eq!(emitted_fillet_version("2.0", Some(2)).await, EdgeCutVersion::V2);
432 }
433
434 #[tokio::test(flavor = "multi_thread")]
437 async fn fillet_version_is_removed_in_kcl_3() {
438 let result = run_fillet("\"3.0-preview\"", Some(1)).await;
439 assert!(
440 result
441 .issues()
442 .iter()
443 .any(|issue| {
444 issue.message
445 == "`version` is not an argument of `fillet`; it was removed in KCL 3.0, but this program uses KCL 3.0-preview"
446 }),
447 "issues: {:#?}",
448 result.issues()
449 );
450 assert_eq!(emitted_cut_edges_version(&result), EdgeCutVersion::V2);
451 }
452
453 #[tokio::test(flavor = "multi_thread")]
454 async fn tangent_chain_requires_kcl_3_and_is_sent_to_engine() {
455 let body = r#"
456profile = sketch(on = XY) {
457 edge1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
458 edge2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
459 edge3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
460 edge4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
461 coincident([edge1.end, edge2.start])
462 coincident([edge2.end, edge3.start])
463 coincident([edge3.end, edge4.start])
464 coincident([edge4.end, edge1.start])
465}
466profileRegion = region(point = [5mm, 5mm], sketch = profile)
467solid = extrude(profileRegion, length = 10mm, tagEnd = $top)
468fillet(solid, tags = [getCommonEdge(faces = [profileRegion.tags.edge1, top])], radius = 1mm, tangentChain = true)
469"#;
470
471 let result = parse_execute(&format!("@settings(kclVersion = 2.0)\n{body}"))
472 .await
473 .unwrap();
474 assert!(result.issues().iter().any(|issue| {
475 issue.message
476 == "`tangentChain` is not an argument of `fillet`; it was added in KCL 3.0, but this program uses KCL 2.0"
477 }));
478
479 let result = parse_execute(&format!("@settings(kclVersion = \"3.0-preview\")\n{body}"))
480 .await
481 .unwrap();
482 let tangent_chain = result
483 .root_module_artifact_commands()
484 .iter()
485 .find_map(|artifact_command| match &artifact_command.command {
486 ModelingCmd::Solid3dCutEdges(command) => Some(command.tangent_chain),
487 _ => None,
488 })
489 .expect("fillet should emit a Solid3dCutEdges command");
490 assert!(tangent_chain);
491
492 let default_body = body.replace(", tangentChain = true", "");
493 let result = parse_execute(&format!("@settings(kclVersion = \"3.0-preview\")\n{default_body}"))
494 .await
495 .unwrap();
496 let tangent_chain = result
497 .root_module_artifact_commands()
498 .iter()
499 .find_map(|artifact_command| match &artifact_command.command {
500 ModelingCmd::Solid3dCutEdges(command) => Some(command.tangent_chain),
501 _ => None,
502 })
503 .expect("fillet should emit a Solid3dCutEdges command");
504 assert!(tangent_chain, "tangentChain should default to true after KCL 2");
505
506 let disabled_body = body.replace("tangentChain = true", "tangentChain = false");
507 let result = parse_execute(&format!("@settings(kclVersion = \"3.0-preview\")\n{disabled_body}"))
508 .await
509 .unwrap();
510 let tangent_chain = result
511 .root_module_artifact_commands()
512 .iter()
513 .find_map(|artifact_command| match &artifact_command.command {
514 ModelingCmd::Solid3dCutEdges(command) => Some(command.tangent_chain),
515 _ => None,
516 })
517 .expect("fillet should emit a Solid3dCutEdges command");
518 assert!(!tangent_chain, "an explicit false should override the default");
519 }
520
521 async fn emitted_fillet_version(kcl_version: &str, explicit_version: Option<u32>) -> EdgeCutVersion {
524 emitted_cut_edges_version(&run_fillet(kcl_version, explicit_version).await)
525 }
526
527 async fn run_fillet(kcl_version: &str, explicit_version: Option<u32>) -> ExecTestResults {
530 let version_arg = explicit_version
531 .map(|version| format!(", version = {version}"))
532 .unwrap_or_default();
533 let code = format!(
534 r#"@settings(kclVersion = {kcl_version}, experimentalFeatures = allow)
535
536profile = sketch(on = XY) {{
537 edge1 = line(start = [var 0mm, var 0mm], end = [var 10mm, var 0mm])
538 edge2 = line(start = [var 10mm, var 0mm], end = [var 10mm, var 10mm])
539 edge3 = line(start = [var 10mm, var 10mm], end = [var 0mm, var 10mm])
540 edge4 = line(start = [var 0mm, var 10mm], end = [var 0mm, var 0mm])
541 coincident([edge1.end, edge2.start])
542 coincident([edge2.end, edge3.start])
543 coincident([edge3.end, edge4.start])
544 coincident([edge4.end, edge1.start])
545}}
546profileRegion = region(point = [5mm, 5mm], sketch = profile)
547solid = extrude(profileRegion, length = 10mm, tagEnd = $top)
548fillet(solid, tags = [getCommonEdge(faces = [profileRegion.tags.edge1, top])], radius = 1mm{version_arg})
549"#
550 );
551 parse_execute(&code).await.unwrap()
552 }
553
554 fn emitted_cut_edges_version(result: &ExecTestResults) -> EdgeCutVersion {
556 result
557 .root_module_artifact_commands()
558 .iter()
559 .find_map(|artifact_command| match &artifact_command.command {
560 ModelingCmd::Solid3dCutEdges(command) => Some(command.version),
561 _ => None,
562 })
563 .expect("fillet should emit a Solid3dCutEdges command")
564 }
565
566 #[test]
567 fn test_validate_unique() {
568 let dup_a = SourceRange::from([1, 3, 0]);
569 let dup_b = SourceRange::from([10, 30, 0]);
570 let tags = vec![("abc", dup_a), ("abc", dup_b), ("def", SourceRange::from([2, 4, 0]))];
572 let actual = validate_unique(&tags);
573 let expected = vec![dup_a, dup_b];
577 assert_eq!(actual.err().unwrap().source_ranges(), expected);
578 }
579}