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