Skip to main content

kcl_lib/std/
chamfer.rs

1//! Standard library chamfers.
2
3use 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::ModelingCmdMeta;
23use crate::execution::Sketch;
24use crate::execution::Solid;
25use crate::execution::types::RuntimeType;
26use crate::parsing::ast::types::TagNode;
27use crate::std::Args;
28use crate::std::csg::CsgAlgorithm;
29use crate::std::fillet::EdgeReference;
30use crate::std::fillet::default_edge_cut_version;
31
32pub(crate) const DEFAULT_TOLERANCE: f64 = 0.0000001;
33
34/// Create chamfers on tagged paths.
35pub async fn chamfer(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
36    let solid: Box<Solid> = args.get_unlabeled_kw_arg("solid", &RuntimeType::solid(), exec_state)?;
37    let length: TyF64 = args.get_kw_arg("length", &RuntimeType::length(), exec_state)?;
38    let second_length = args.get_kw_arg_opt("secondLength", &RuntimeType::length(), exec_state)?;
39    let angle = args.get_kw_arg_opt("angle", &RuntimeType::angle(), exec_state)?;
40    let legacy_csg: Option<bool> = args.get_kw_arg_opt("legacyMethod", &RuntimeType::bool(), exec_state)?;
41    let csg_algorithm = CsgAlgorithm::legacy(legacy_csg.unwrap_or_default());
42    let edge_cut_number: Option<u32> = args.get_kw_arg_opt("version", &RuntimeType::count(), exec_state)?;
43    let edge_cut_version: EdgeCutVersion = edge_cut_number
44        .map(|num| {
45            num.try_into().map_err(|()| {
46                KclError::new_semantic(KclErrorDetails::new(
47                    format!("{} is not a version of the Zoo edge cut algorithm", num),
48                    vec![args.source_range],
49                ))
50            })
51        })
52        .transpose()?
53        .unwrap_or_else(|| default_edge_cut_version(exec_state.kcl_version()));
54
55    let tag = args.get_kw_arg_opt("tag", &RuntimeType::tag_decl(), exec_state)?;
56
57    // Edge specifiers are object-shaped payloads, so there is no narrow RuntimeType for them yet.
58    // Keep this broad at the boundary and validate the shape in parse_tagged_edge_inputs.
59    let edge_refs = args.get_kw_arg_opt("edges", &RuntimeType::any_array(), exec_state)?;
60    let tags = args.kw_arg_edge_array_and_source_opt("tags")?;
61
62    let edge_inputs = super::fillet::parse_tagged_edge_inputs(
63        edge_refs,
64        tags,
65        Some(solid.as_ref()),
66        exec_state,
67        &args,
68        "You must provide either 'tags' or 'edges' to chamfer edges",
69        "You must provide either 'tags' or 'edges' to chamfer edges, not both",
70    )
71    .await?;
72
73    match edge_inputs {
74        super::fillet::TaggedEdgeInputs::EngineRefs(edge_refs) => {
75            let value = inner_chamfer_with_engine_refs(
76                solid,
77                length,
78                edge_refs,
79                second_length,
80                angle,
81                csg_algorithm,
82                edge_cut_version,
83                tag,
84                exec_state,
85                args,
86            )
87            .await?;
88            Ok(KclValue::Solid { value })
89        }
90        super::fillet::TaggedEdgeInputs::Tags(tags) => {
91            match edge_cut_version {
92                EdgeCutVersion::V2 => {
93                    let value = inner_chamfer_v2(
94                        solid,
95                        length,
96                        tags,
97                        second_length,
98                        angle,
99                        None,
100                        tag,
101                        csg_algorithm,
102                        edge_cut_version,
103                        exec_state,
104                        args,
105                    )
106                    .await?;
107                    Ok(KclValue::Solid { value })
108                }
109                // TODO: When we change the default algorithm to V2, we need to make it so that V0 (default) takes the route above
110                _ => {
111                    let value = inner_chamfer(
112                        solid,
113                        length,
114                        tags,
115                        second_length,
116                        angle,
117                        None,
118                        tag,
119                        csg_algorithm,
120                        edge_cut_version,
121                        exec_state,
122                        args,
123                    )
124                    .await?;
125                    Ok(KclValue::Solid { value })
126                }
127            }
128        }
129    }
130}
131
132#[allow(clippy::too_many_arguments)]
133async fn inner_chamfer(
134    solid: Box<Solid>,
135    length: TyF64,
136    tags: Vec<(EdgeReference, crate::SourceRange)>,
137    second_length: Option<TyF64>,
138    angle: Option<TyF64>,
139    custom_profile: Option<Sketch>,
140    tag: Option<TagNode>,
141    csg_algorithm: CsgAlgorithm,
142    edge_cut_version: EdgeCutVersion,
143    exec_state: &mut ExecState,
144    args: Args,
145) -> Result<Box<Solid>, KclError> {
146    // If you try and tag multiple edges with a tagged chamfer, we want to return an
147    // error to the user that they can only tag one edge at a time.
148    if tag.is_some() && tags.len() > 1 {
149        return Err(KclError::new_type(KclErrorDetails::new(
150            "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(),
151            vec![args.source_range],
152        )));
153    }
154
155    if angle.is_some() && second_length.is_some() {
156        return Err(KclError::new_semantic(KclErrorDetails::new(
157            "Cannot specify both an angle and a second length. Specify only one.".to_string(),
158            vec![args.source_range],
159        )));
160    }
161
162    let strategy = if second_length.is_some() || angle.is_some() || custom_profile.is_some() {
163        CutStrategy::Csg
164    } else {
165        Default::default()
166    };
167
168    let second_distance = second_length.map(|x| LengthUnit(x.to_mm()));
169    let angle = angle.map(|x| Angle::from_degrees(x.to_degrees(exec_state, args.source_range)));
170    if let Some(angle) = angle
171        && (angle.ge(&Angle::quarter_circle()) || angle.le(&Angle::zero()))
172    {
173        return Err(KclError::new_semantic(KclErrorDetails::new(
174            "The angle of a chamfer must be greater than zero and less than 90 degrees.".to_string(),
175            vec![args.source_range],
176        )));
177    }
178
179    let cut_type = if let Some(custom_profile) = custom_profile {
180        // Hide the custom profile since it's no longer its own profile
181        exec_state
182            .batch_modeling_cmd(
183                ModelingCmdMeta::from_args(exec_state, &args),
184                ModelingCmd::from(
185                    mcmd::ObjectVisible::builder()
186                        .object_id(custom_profile.id)
187                        .hidden(true)
188                        .build(),
189                ),
190            )
191            .await?;
192        CutTypeV2::Custom {
193            path: custom_profile.id,
194        }
195    } else {
196        CutTypeV2::Chamfer {
197            distance: LengthUnit(length.to_mm()),
198            second_distance,
199            angle,
200            swap: false,
201        }
202    };
203
204    let mut solid = solid.clone();
205    let mut tag_entries: Vec<crate::execution::DirectTagFilletTagEntry> = Vec::new();
206    for (edge_ref, source_range) in &tags {
207        let edge_id = match edge_ref {
208            EdgeReference::Uuid(u) => *u,
209            EdgeReference::Tag(t) => args.get_tag_engine_info(exec_state, t)?.id,
210        };
211        if let Ok(face_ids) = super::edge::get_face_ids_for_edge(exec_state, solid.id, edge_id, &args).await
212            && let [a, b] = face_ids.as_slice()
213        {
214            let tag_identifier = match edge_ref {
215                EdgeReference::Tag(t) => t.value.clone(),
216                EdgeReference::Uuid(_) => String::new(),
217            };
218            if !tag_identifier.is_empty() {
219                tag_entries.push(crate::execution::DirectTagFilletTagEntry {
220                    tag_identifier,
221                    edge_id,
222                    face_ids: [*a, *b],
223                });
224            } else {
225                exec_state.record_edge_refactor_meta_from_pending(edge_id, *source_range, [*a, *b]);
226            }
227        }
228    }
229    if !tag_entries.is_empty() {
230        exec_state.record_direct_tag_fillet_meta(crate::execution::DirectTagFilletMeta {
231            call_source_range: args.source_range,
232            tags: tag_entries,
233        });
234    }
235    for (edge_tag, _) in tags {
236        let edge_ids = edge_tag.get_all_engine_ids(exec_state, &args)?;
237        for edge_id in edge_ids {
238            let id = exec_state.next_uuid();
239            exec_state
240                .batch_end_cmd(
241                    ModelingCmdMeta::from_args_id(exec_state, &args, id),
242                    ModelingCmd::from(
243                        mcmd::Solid3dCutEdges::builder()
244                            .use_legacy(csg_algorithm.is_legacy())
245                            .edge_ids(vec![edge_id])
246                            .extra_face_ids(vec![])
247                            .strategy(strategy)
248                            .object_id(solid.id)
249                            // We can let the user set this in the future.
250                            .tolerance(LengthUnit(DEFAULT_TOLERANCE))
251                            .cut_type(cut_type)
252                            .version(edge_cut_version)
253                            .build(),
254                    ),
255                )
256                .await?;
257
258            solid.edge_cuts.push(EdgeCut::Chamfer {
259                id,
260                edge_id,
261                length: length.clone(),
262                tag: Box::new(tag.clone()),
263            });
264
265            if let Some(ref tag) = tag {
266                solid.value.push(ExtrudeSurface::Chamfer(ChamferSurface {
267                    face_id: id,
268                    tag: Some(tag.clone()),
269                    geo_meta: GeoMeta {
270                        id,
271                        metadata: args.source_range.into(),
272                    },
273                }));
274            }
275        }
276    }
277
278    Ok(solid)
279}
280
281#[allow(clippy::too_many_arguments)]
282async fn inner_chamfer_v2(
283    solid: Box<Solid>,
284    length: TyF64,
285    tags: Vec<(EdgeReference, crate::SourceRange)>,
286    second_length: Option<TyF64>,
287    angle: Option<TyF64>,
288    custom_profile: Option<Sketch>,
289    tag: Option<TagNode>,
290    csg_algorithm: CsgAlgorithm,
291    edge_cut_version: EdgeCutVersion,
292    exec_state: &mut ExecState,
293    args: Args,
294) -> Result<Box<Solid>, KclError> {
295    // If you try and tag multiple edges with a tagged chamfer, we want to return an
296    // error to the user that they can only tag one edge at a time.
297    if tag.is_some() && tags.len() > 1 {
298        return Err(KclError::new_type(KclErrorDetails::new(
299            "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(),
300            vec![args.source_range],
301        )));
302    }
303    if tags.is_empty() {
304        return Err(KclError::new_semantic(KclErrorDetails {
305            source_ranges: vec![args.source_range],
306            message: "You must chamfer at least one tag".to_owned(),
307            backtrace: Default::default(),
308        }));
309    }
310
311    if angle.is_some() && second_length.is_some() {
312        return Err(KclError::new_semantic(KclErrorDetails::new(
313            "Cannot specify both an angle and a second length. Specify only one.".to_string(),
314            vec![args.source_range],
315        )));
316    }
317
318    let strategy = if second_length.is_some() || angle.is_some() || custom_profile.is_some() {
319        CutStrategy::Csg
320    } else {
321        Default::default()
322    };
323
324    let second_distance = second_length.map(|x| LengthUnit(x.to_mm()));
325    let angle = angle.map(|x| Angle::from_degrees(x.to_degrees(exec_state, args.source_range)));
326    if let Some(angle) = angle
327        && (angle.ge(&Angle::quarter_circle()) || angle.le(&Angle::zero()))
328    {
329        return Err(KclError::new_semantic(KclErrorDetails::new(
330            "The angle of a chamfer must be greater than zero and less than 90 degrees.".to_string(),
331            vec![args.source_range],
332        )));
333    }
334
335    let cut_type = if let Some(custom_profile) = custom_profile {
336        // Hide the custom profile since it's no longer its own profile
337        exec_state
338            .batch_modeling_cmd(
339                ModelingCmdMeta::from_args(exec_state, &args),
340                ModelingCmd::from(
341                    mcmd::ObjectVisible::builder()
342                        .object_id(custom_profile.id)
343                        .hidden(true)
344                        .build(),
345                ),
346            )
347            .await?;
348        CutTypeV2::Custom {
349            path: custom_profile.id,
350        }
351    } else {
352        CutTypeV2::Chamfer {
353            distance: LengthUnit(length.to_mm()),
354            second_distance,
355            angle,
356            swap: false,
357        }
358    };
359
360    let mut solid = solid.clone();
361    let mut edge_ids = Vec::new();
362    let mut tag_entries: Vec<crate::execution::DirectTagFilletTagEntry> = Vec::new();
363    for (edge_ref, source_range) in &tags {
364        let ids = edge_ref.get_all_engine_ids(exec_state, &args)?;
365        edge_ids.extend(ids.iter().copied());
366        let tag_identifier = match edge_ref {
367            EdgeReference::Tag(t) => t.value.clone(),
368            EdgeReference::Uuid(_) => String::new(),
369        };
370        for edge_id in ids {
371            if let Ok(face_ids) = super::edge::get_face_ids_for_edge(exec_state, solid.id, edge_id, &args).await
372                && let [a, b] = face_ids.as_slice()
373            {
374                if !tag_identifier.is_empty() {
375                    tag_entries.push(crate::execution::DirectTagFilletTagEntry {
376                        tag_identifier: tag_identifier.clone(),
377                        edge_id,
378                        face_ids: [*a, *b],
379                    });
380                } else {
381                    exec_state.record_edge_refactor_meta_from_pending(edge_id, *source_range, [*a, *b]);
382                }
383            }
384        }
385    }
386    if !tag_entries.is_empty() {
387        exec_state.record_direct_tag_fillet_meta(crate::execution::DirectTagFilletMeta {
388            call_source_range: args.source_range,
389            tags: tag_entries,
390        });
391    }
392
393    let id = exec_state.next_uuid();
394    let num_extra_ids = edge_ids.len().saturating_sub(1);
395    let mut extra_face_ids = Vec::with_capacity(num_extra_ids);
396    for _ in 0..num_extra_ids {
397        extra_face_ids.push(exec_state.next_uuid());
398    }
399    exec_state
400        .batch_end_cmd(
401            ModelingCmdMeta::from_args_id(exec_state, &args, id),
402            ModelingCmd::from(
403                mcmd::Solid3dCutEdges::builder()
404                    .use_legacy(csg_algorithm.is_legacy())
405                    .edge_ids(edge_ids.clone())
406                    .extra_face_ids(extra_face_ids)
407                    .strategy(strategy)
408                    .object_id(solid.id)
409                    // We can let the user set this in the future.
410                    .tolerance(LengthUnit(DEFAULT_TOLERANCE))
411                    .cut_type(cut_type)
412                    .version(edge_cut_version)
413                    .build(),
414            ),
415        )
416        .await?;
417
418    let new_edge_cuts = edge_ids.into_iter().map(|edge_id| EdgeCut::Chamfer {
419        id,
420        edge_id,
421        length: length.clone(),
422        tag: Box::new(tag.clone()),
423    });
424    solid.edge_cuts.extend(new_edge_cuts);
425
426    if let Some(ref tag) = tag {
427        solid.value.push(ExtrudeSurface::Chamfer(ChamferSurface {
428            face_id: id,
429            tag: Some(tag.clone()),
430            geo_meta: GeoMeta {
431                id,
432                metadata: args.source_range.into(),
433            },
434        }));
435    }
436
437    Ok(solid)
438}
439
440#[expect(clippy::too_many_arguments)]
441async fn inner_chamfer_with_engine_refs(
442    solid: Box<Solid>,
443    length: TyF64,
444    edge_references: Vec<kcmc::shared::EdgeSpecifier>,
445    second_length: Option<TyF64>,
446    angle: Option<TyF64>,
447    csg_algorithm: CsgAlgorithm,
448    edge_cut_version: EdgeCutVersion,
449    tag: Option<TagNode>,
450    exec_state: &mut ExecState,
451    args: Args,
452) -> Result<Box<Solid>, KclError> {
453    if tag.is_some() && edge_references.len() > 1 {
454        return Err(KclError::new_type(KclErrorDetails::new(
455            "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(),
456            vec![args.source_range],
457        )));
458    }
459
460    if angle.is_some() && second_length.is_some() {
461        return Err(KclError::new_semantic(KclErrorDetails::new(
462            "Cannot specify both an angle and a second length. Specify only one.".to_string(),
463            vec![args.source_range],
464        )));
465    }
466
467    let strategy = if second_length.is_some() || angle.is_some() {
468        CutStrategy::Csg
469    } else {
470        Default::default()
471    };
472
473    let second_distance = second_length.map(|x| LengthUnit(x.to_mm()));
474    let angle = angle.map(|x| Angle::from_degrees(x.to_degrees(exec_state, args.source_range)));
475    if let Some(angle) = angle
476        && (angle.ge(&Angle::quarter_circle()) || angle.le(&Angle::zero()))
477    {
478        return Err(KclError::new_semantic(KclErrorDetails::new(
479            "The angle of a chamfer must be greater than zero and less than 90 degrees.".to_string(),
480            vec![args.source_range],
481        )));
482    }
483
484    let cut_type = CutTypeV2::Chamfer {
485        distance: LengthUnit(length.to_mm()),
486        second_distance,
487        angle,
488        swap: false,
489    };
490
491    let id = exec_state.next_uuid();
492    let num_extra_ids = edge_references.len().saturating_sub(1);
493    let mut extra_face_ids = Vec::with_capacity(num_extra_ids);
494    for _ in 0..num_extra_ids {
495        extra_face_ids.push(exec_state.next_uuid());
496    }
497
498    let mut solid = solid.clone();
499    exec_state
500        .batch_end_cmd(
501            ModelingCmdMeta::from_args_id(exec_state, &args, id),
502            ModelingCmd::from(
503                mcmd::Solid3dCutEdgeReferences::builder()
504                    .object_id(solid.id)
505                    .edges_references(edge_references)
506                    .cut_type(cut_type)
507                    .tolerance(LengthUnit(DEFAULT_TOLERANCE))
508                    .strategy(strategy)
509                    .extra_face_ids(extra_face_ids)
510                    .use_legacy(csg_algorithm.is_legacy())
511                    .version(edge_cut_version)
512                    .build(),
513            ),
514        )
515        .await?;
516
517    solid.pending_edge_cut_ids.push(id);
518
519    if let Some(ref tag) = tag {
520        solid.value.push(ExtrudeSurface::Chamfer(ChamferSurface {
521            face_id: id,
522            tag: Some(tag.clone()),
523            geo_meta: GeoMeta {
524                id,
525                metadata: args.source_range.into(),
526            },
527        }));
528    }
529
530    Ok(solid)
531}