Skip to main content

kcl_lib/std/
edge.rs

1//! Edge helper functions.
2
3use anyhow::Result;
4use kcmc::ModelingCmd;
5use kcmc::each_cmd as mcmd;
6use kcmc::ok_response::OkModelingCmdResponse;
7use kcmc::websocket::OkWebSocketResponseData;
8use kittycad_modeling_cmds as kcmc;
9use serde::Deserialize;
10use serde::Serialize;
11use uuid::Uuid;
12
13use crate::SourceRange;
14use crate::errors::KclError;
15use crate::errors::KclErrorDetails;
16use crate::execution::BoundedEdge;
17use crate::execution::ExecState;
18use crate::execution::ExtrudeSurface;
19use crate::execution::KclObjectFields;
20use crate::execution::KclValue;
21use crate::execution::ModelingCmdMeta;
22use crate::execution::Solid;
23use crate::execution::TagIdentifier;
24use crate::execution::types::ArrayLen;
25use crate::execution::types::RuntimeType;
26use crate::std::Args;
27use crate::std::args::TyF64;
28use crate::std::fillet::EdgeReference;
29use crate::std::sketch::FaceTag;
30
31/// Tag or UUID for use in an unresolved edge specifier (resolved to face UUIDs in blend).
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, ts_rs::TS)]
33#[serde(untagged)]
34pub enum TagOrUuid {
35    Uuid(Uuid),
36    Tag(Box<TagIdentifier>),
37}
38
39/// Edge specifier payload (sideFaces, endFaces, index) as passed from KCL. Stored in BoundedEdge and resolved to `kcmc::shared::EdgeSpecifier` in blend().
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ts_rs::TS)]
41#[serde(rename_all = "camelCase")]
42pub struct UnresolvedEdgeSpecifier {
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub side_faces: Vec<TagOrUuid>,
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub end_faces: Vec<TagOrUuid>,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub index: Option<u32>,
49}
50
51/// Fetch the face ID(s) for an edge via Solid3dGetAllEdgeFaces.
52/// Returns 1 face for boundary edges (e.g. on surfaces) or 2 for interior edges.
53/// Used for refactor metadata (artifact-graph), fillet/chamfer, and blend edge specifier resolution.
54pub(crate) async fn get_face_ids_for_edge(
55    exec_state: &mut ExecState,
56    object_id: Uuid,
57    edge_id: Uuid,
58    args: &Args,
59) -> Result<Vec<Uuid>, KclError> {
60    if args.ctx.no_engine_commands().await {
61        // Return two so that anything that is expecting an edge on a solid
62        // works.
63        return Ok(vec![exec_state.next_uuid(), exec_state.next_uuid()]);
64    }
65
66    let resp = exec_state
67        .send_modeling_cmd(
68            ModelingCmdMeta::from_args(exec_state, args),
69            ModelingCmd::from(
70                mcmd::Solid3dGetAllEdgeFaces::builder()
71                    .object_id(object_id)
72                    .edge_id(edge_id)
73                    .build(),
74            ),
75        )
76        .await?;
77    let OkWebSocketResponseData::Modeling {
78        modeling_response: OkModelingCmdResponse::Solid3dGetAllEdgeFaces(info),
79    } = &resp
80    else {
81        return Err(KclError::new_engine(KclErrorDetails::new(
82            format!("Solid3dGetAllEdgeFaces response was not as expected: {resp:?}"),
83            vec![args.source_range],
84        )));
85    };
86    if info.faces.is_empty() || info.faces.len() > 2 {
87        return Err(KclError::new_engine(KclErrorDetails::new(
88            format!(
89                "Solid3dGetAllEdgeFaces returned {} face(s) for edge {edge_id}, expected 1 or 2",
90                info.faces.len()
91            ),
92            vec![args.source_range],
93        )));
94    }
95    Ok(info.faces.clone())
96}
97
98/// Check that a tag does not map to multiple edges (ambiguous region mapping).
99pub(super) fn check_tag_not_ambiguous(tag: &TagIdentifier, args: &Args) -> Result<(), KclError> {
100    let all_infos = tag.get_all_cur_info();
101    if all_infos.len() > 1 {
102        return Err(KclError::new_semantic(KclErrorDetails::new(
103            format!(
104                "Tag `{}` is ambiguous: it maps to {} edges in the region. Use a more specific reference.",
105                tag.value,
106                all_infos.len()
107            ),
108            vec![args.source_range],
109        )));
110    }
111    Ok(())
112}
113
114/// Get the opposite edge to the edge given.
115pub async fn get_opposite_edge(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
116    let input_edge = args.get_unlabeled_kw_arg("edge", &RuntimeType::tagged_edge(), exec_state)?;
117
118    let edge = inner_get_opposite_edge(input_edge, exec_state, args.clone()).await?;
119    Ok(KclValue::Uuid {
120        value: edge,
121        meta: vec![args.source_range.into()],
122    })
123}
124
125async fn inner_get_opposite_edge(
126    edge: TagIdentifier,
127    exec_state: &mut ExecState,
128    args: Args,
129) -> Result<Uuid, KclError> {
130    check_tag_not_ambiguous(&edge, &args)?;
131    if args.ctx.no_engine_commands().await {
132        return Ok(exec_state.next_uuid());
133    }
134    let face_id = args.get_adjacent_face_to_tag(exec_state, &edge, false).await?;
135
136    let tagged_path = args.get_tag_engine_info(exec_state, &edge)?;
137    let tagged_path_id = tagged_path.id;
138    let sketch_id = tagged_path.geometry.id();
139
140    let resp = exec_state
141        .send_modeling_cmd(
142            ModelingCmdMeta::from_args(exec_state, &args),
143            ModelingCmd::from(
144                mcmd::Solid3dGetOppositeEdge::builder()
145                    .edge_id(tagged_path_id)
146                    .object_id(sketch_id)
147                    .face_id(face_id)
148                    .build(),
149            ),
150        )
151        .await?;
152    let OkWebSocketResponseData::Modeling {
153        modeling_response: OkModelingCmdResponse::Solid3dGetOppositeEdge(opposite_edge),
154    } = &resp
155    else {
156        return Err(KclError::new_engine(KclErrorDetails::new(
157            format!("mcmd::Solid3dGetOppositeEdge response was not as expected: {resp:?}"),
158            vec![args.source_range],
159        )));
160    };
161
162    Ok(opposite_edge.edge)
163}
164
165/// Get the next adjacent edge to the edge given.
166pub async fn get_next_adjacent_edge(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
167    let input_edge = args.get_unlabeled_kw_arg("edge", &RuntimeType::tagged_edge(), exec_state)?;
168
169    let edge = inner_get_next_adjacent_edge(input_edge, exec_state, args.clone()).await?;
170    Ok(KclValue::Uuid {
171        value: edge,
172        meta: vec![args.source_range.into()],
173    })
174}
175
176async fn inner_get_next_adjacent_edge(
177    edge: TagIdentifier,
178    exec_state: &mut ExecState,
179    args: Args,
180) -> Result<Uuid, KclError> {
181    check_tag_not_ambiguous(&edge, &args)?;
182    if args.ctx.no_engine_commands().await {
183        return Ok(exec_state.next_uuid());
184    }
185    let face_id = args.get_adjacent_face_to_tag(exec_state, &edge, false).await?;
186
187    let tagged_path = args.get_tag_engine_info(exec_state, &edge)?;
188    let tagged_path_id = tagged_path.id;
189    let sketch_id = tagged_path.geometry.id();
190
191    let resp = exec_state
192        .send_modeling_cmd(
193            ModelingCmdMeta::from_args(exec_state, &args),
194            ModelingCmd::from(
195                mcmd::Solid3dGetNextAdjacentEdge::builder()
196                    .edge_id(tagged_path_id)
197                    .object_id(sketch_id)
198                    .face_id(face_id)
199                    .build(),
200            ),
201        )
202        .await?;
203
204    let OkWebSocketResponseData::Modeling {
205        modeling_response: OkModelingCmdResponse::Solid3dGetNextAdjacentEdge(adjacent_edge),
206    } = &resp
207    else {
208        return Err(KclError::new_engine(KclErrorDetails::new(
209            format!("mcmd::Solid3dGetNextAdjacentEdge response was not as expected: {resp:?}"),
210            vec![args.source_range],
211        )));
212    };
213
214    adjacent_edge.edge.ok_or_else(|| {
215        KclError::new_type(KclErrorDetails::new(
216            format!("No edge found next adjacent to tag: `{}`", edge.value),
217            vec![args.source_range],
218        ))
219    })
220}
221
222/// Get the previous adjacent edge to the edge given.
223pub async fn get_previous_adjacent_edge(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
224    let input_edge = args.get_unlabeled_kw_arg("edge", &RuntimeType::tagged_edge(), exec_state)?;
225
226    let edge = inner_get_previous_adjacent_edge(input_edge, exec_state, args.clone()).await?;
227    Ok(KclValue::Uuid {
228        value: edge,
229        meta: vec![args.source_range.into()],
230    })
231}
232
233async fn inner_get_previous_adjacent_edge(
234    edge: TagIdentifier,
235    exec_state: &mut ExecState,
236    args: Args,
237) -> Result<Uuid, KclError> {
238    check_tag_not_ambiguous(&edge, &args)?;
239    if args.ctx.no_engine_commands().await {
240        return Ok(exec_state.next_uuid());
241    }
242    let face_id = args.get_adjacent_face_to_tag(exec_state, &edge, false).await?;
243
244    let tagged_path = args.get_tag_engine_info(exec_state, &edge)?;
245    let tagged_path_id = tagged_path.id;
246    let sketch_id = tagged_path.geometry.id();
247
248    let resp = exec_state
249        .send_modeling_cmd(
250            ModelingCmdMeta::from_args(exec_state, &args),
251            ModelingCmd::from(
252                mcmd::Solid3dGetPrevAdjacentEdge::builder()
253                    .edge_id(tagged_path_id)
254                    .object_id(sketch_id)
255                    .face_id(face_id)
256                    .build(),
257            ),
258        )
259        .await?;
260    let OkWebSocketResponseData::Modeling {
261        modeling_response: OkModelingCmdResponse::Solid3dGetPrevAdjacentEdge(adjacent_edge),
262    } = &resp
263    else {
264        return Err(KclError::new_engine(KclErrorDetails::new(
265            format!("mcmd::Solid3dGetPrevAdjacentEdge response was not as expected: {resp:?}"),
266            vec![args.source_range],
267        )));
268    };
269
270    adjacent_edge.edge.ok_or_else(|| {
271        KclError::new_type(KclErrorDetails::new(
272            format!("No edge found previous adjacent to tag: `{}`", edge.value),
273            vec![args.source_range],
274        ))
275    })
276}
277
278/// Get the shared edge between two faces.
279pub async fn get_common_edge(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
280    let faces: Vec<FaceTag> = args.get_kw_arg(
281        "faces",
282        &RuntimeType::Array(Box::new(RuntimeType::tagged_face()), ArrayLen::Known(2)),
283        exec_state,
284    )?;
285
286    fn into_tag(face: FaceTag, source_range: SourceRange) -> Result<TagIdentifier, KclError> {
287        match face {
288            FaceTag::StartOrEnd(_) => Err(KclError::new_type(KclErrorDetails::new(
289                "getCommonEdge requires a tagged face, it cannot use `START` or `END` faces".to_owned(),
290                vec![source_range],
291            ))),
292            FaceTag::Tag(tag_identifier) => Ok(*tag_identifier),
293        }
294    }
295
296    let [face1, face2]: [FaceTag; 2] = faces.try_into().map_err(|_: Vec<FaceTag>| {
297        KclError::new_type(KclErrorDetails::new(
298            "getCommonEdge requires exactly two tags for faces".to_owned(),
299            vec![args.source_range],
300        ))
301    })?;
302
303    let face1 = into_tag(face1, args.source_range)?;
304    let face2 = into_tag(face2, args.source_range)?;
305
306    let edge = inner_get_common_edge(face1, face2, exec_state, args.clone()).await?;
307    Ok(KclValue::Uuid {
308        value: edge,
309        meta: vec![args.source_range.into()],
310    })
311}
312
313async fn inner_get_common_edge(
314    face1: TagIdentifier,
315    face2: TagIdentifier,
316    exec_state: &mut ExecState,
317    args: Args,
318) -> Result<Uuid, KclError> {
319    check_tag_not_ambiguous(&face1, &args)?;
320    check_tag_not_ambiguous(&face2, &args)?;
321    let id = exec_state.next_uuid();
322    if args.ctx.no_engine_commands().await {
323        return Ok(id);
324    }
325
326    let first_face_id = args.get_adjacent_face_to_tag(exec_state, &face1, false).await?;
327    let second_face_id = args.get_adjacent_face_to_tag(exec_state, &face2, false).await?;
328
329    let first_tagged_path = args.get_tag_engine_info(exec_state, &face1)?.clone();
330    let second_tagged_path = args.get_tag_engine_info(exec_state, &face2)?;
331
332    if first_tagged_path.geometry.id() != second_tagged_path.geometry.id() {
333        return Err(KclError::new_type(KclErrorDetails::new(
334            "getCommonEdge requires the faces to be in the same original sketch".to_string(),
335            vec![args.source_range],
336        )));
337    }
338
339    // Flush the batch for our fillets/chamfers if there are any.
340    // If we have a chamfer/fillet, flush the batch.
341    // TODO: we likely want to be a lot more persnickety _which_ fillets we are flushing
342    // but for now, we'll just flush everything.
343    if let Some(ExtrudeSurface::Chamfer { .. } | ExtrudeSurface::Fillet { .. }) = first_tagged_path.surface {
344        exec_state
345            .flush_batch(ModelingCmdMeta::from_args(exec_state, &args), true)
346            .await?;
347    } else if let Some(ExtrudeSurface::Chamfer { .. } | ExtrudeSurface::Fillet { .. }) = second_tagged_path.surface {
348        exec_state
349            .flush_batch(ModelingCmdMeta::from_args(exec_state, &args), true)
350            .await?;
351    }
352
353    let resp = exec_state
354        .send_modeling_cmd(
355            ModelingCmdMeta::from_args_id(exec_state, &args, id),
356            ModelingCmd::from(
357                mcmd::Solid3dGetCommonEdge::builder()
358                    .object_id(first_tagged_path.geometry.id())
359                    .face_ids([first_face_id, second_face_id])
360                    .build(),
361            ),
362        )
363        .await?;
364    let OkWebSocketResponseData::Modeling {
365        modeling_response: OkModelingCmdResponse::Solid3dGetCommonEdge(common_edge),
366    } = &resp
367    else {
368        return Err(KclError::new_engine(KclErrorDetails::new(
369            format!("mcmd::Solid3dGetCommonEdge response was not as expected: {resp:?}"),
370            vec![args.source_range],
371        )));
372    };
373
374    common_edge.edge.ok_or_else(|| {
375        KclError::new_type(KclErrorDetails::new(
376            format!(
377                "No common edge was found between `{}` and `{}`",
378                face1.value, face2.value
379            ),
380            vec![args.source_range],
381        ))
382    })
383}
384
385pub async fn get_bounded_edge(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
386    let face = args.get_unlabeled_kw_arg("solid", &RuntimeType::solid(), exec_state)?;
387    let edge_val = args.get_kw_arg("edge", &RuntimeType::any(), exec_state)?;
388    let lower_bound = args.get_kw_arg_opt("lowerBound", &RuntimeType::num_any(), exec_state)?;
389    let upper_bound = args.get_kw_arg_opt("upperBound", &RuntimeType::num_any(), exec_state)?;
390
391    let bounded_edge = match &edge_val {
392        KclValue::Uuid { value, .. } => {
393            inner_get_bounded_edge_with_id(face, EdgeReference::Uuid(*value), lower_bound, upper_bound, exec_state, args.clone()).await?
394        }
395        KclValue::TagIdentifier(tag) => {
396            inner_get_bounded_edge_with_id(face, EdgeReference::Tag(tag.clone()), lower_bound, upper_bound, exec_state, args.clone()).await?
397        }
398        KclValue::Object { value: obj, .. } => {
399            let spec = parse_edge_specifier_object(obj, &args)?;
400            inner_get_bounded_edge_with_specifier(face, spec, lower_bound, upper_bound, &args)?
401        }
402        _ => {
403            return Err(KclError::new_type(KclErrorDetails::new(
404                "edge must be a tagged edge, edge UUID, or edge specifier object (e.g. { sideFaces = [...], endFaces = [...], index = 0 })".to_owned(),
405                vec![args.source_range],
406            )))
407        }
408    };
409    Ok(KclValue::BoundedEdge {
410        value: bounded_edge,
411        meta: vec![args.source_range.into()],
412    })
413}
414
415fn tag_or_uuid_from_value(value: &KclValue, field_name: &str, args: &Args) -> Result<TagOrUuid, KclError> {
416    match value {
417        KclValue::Uuid { value, .. } => Ok(TagOrUuid::Uuid(*value)),
418        KclValue::TagIdentifier(tag) => Ok(TagOrUuid::Tag(tag.clone())),
419        _ => Err(KclError::new_type(KclErrorDetails::new(
420            format!("{field_name} elements must be tags or UUIDs"),
421            vec![args.source_range],
422        ))),
423    }
424}
425
426fn parse_tag_or_uuid_array(
427    obj: &KclObjectFields,
428    field_name: &str,
429    required: bool,
430    args: &Args,
431) -> Result<Vec<TagOrUuid>, KclError> {
432    let Some(value) = obj.get(field_name) else {
433        return if required {
434            Err(KclError::new_type(KclErrorDetails::new(
435                format!("edge specifier object must have {field_name}"),
436                vec![args.source_range],
437            )))
438        } else {
439            Ok(Vec::new())
440        };
441    };
442    let values = value.as_slice().ok_or_else(|| {
443        KclError::new_type(KclErrorDetails::new(
444            format!("{field_name} must be an array"),
445            vec![args.source_range],
446        ))
447    })?;
448    values
449        .iter()
450        .map(|value| tag_or_uuid_from_value(value, field_name, args))
451        .collect()
452}
453
454fn parse_edge_specifier_index(obj: &KclObjectFields, args: &Args) -> Result<Option<u32>, KclError> {
455    let Some(index) = obj.get("index") else {
456        return Ok(None);
457    };
458    let KclValue::Number { value, .. } = index else {
459        return Err(KclError::new_type(KclErrorDetails::new(
460            "edge specifier 'index' must be a non-negative integer".to_owned(),
461            vec![args.source_range],
462        )));
463    };
464    if !value.is_finite() || value.fract() != 0.0 || *value < 0.0 || *value > u32::MAX as f64 {
465        return Err(KclError::new_type(KclErrorDetails::new(
466            "edge specifier 'index' must be a non-negative integer".to_owned(),
467            vec![args.source_range],
468        )));
469    }
470    Ok(Some(*value as u32))
471}
472
473pub(crate) fn is_edge_specifier_object(value: &KclValue) -> bool {
474    matches!(value, KclValue::Object { value, .. } if value.contains_key("sideFaces"))
475}
476
477pub(crate) fn parse_edge_specifier_value(value: &KclValue, args: &Args) -> Result<UnresolvedEdgeSpecifier, KclError> {
478    let KclValue::Object { value: obj, .. } = value else {
479        return Err(KclError::new_type(KclErrorDetails::new(
480            "edge specifier must be an object with 'sideFaces'".to_owned(),
481            vec![args.source_range],
482        )));
483    };
484    parse_edge_specifier_object(obj, args)
485}
486
487/// Parse a KCL object `{ sideFaces, endFaces?, index? }` into UnresolvedEdgeSpecifier. Used by getBoundedEdge and blend.
488pub(crate) fn parse_edge_specifier_object(
489    obj: &KclObjectFields,
490    args: &Args,
491) -> Result<UnresolvedEdgeSpecifier, KclError> {
492    let side_faces = parse_tag_or_uuid_array(obj, "sideFaces", true, args)?;
493    if side_faces.is_empty() {
494        return Err(KclError::new_semantic(KclErrorDetails::new(
495            "sideFaces must be an array of at least one face, but zero were given".to_owned(),
496            vec![args.source_range],
497        )));
498    }
499    let end_faces = parse_tag_or_uuid_array(obj, "endFaces", false, args)?;
500    let index = parse_edge_specifier_index(obj, args)?;
501    Ok(UnresolvedEdgeSpecifier {
502        side_faces,
503        end_faces,
504        index,
505    })
506}
507
508async fn resolve_as_face_id(value: &TagOrUuid, exec_state: &mut ExecState, args: &Args) -> Result<Uuid, KclError> {
509    match value {
510        TagOrUuid::Uuid(uuid) => Ok(*uuid),
511        TagOrUuid::Tag(tag) => {
512            FaceTag::Tag(tag.clone())
513                .get_face_id_from_tag(exec_state, args, false)
514                .await
515        }
516    }
517}
518
519async fn resolve_as_face_ids(
520    value: &TagOrUuid,
521    solid: Option<&Solid>,
522    exec_state: &mut ExecState,
523    args: &Args,
524) -> Result<Vec<Uuid>, KclError> {
525    match value {
526        TagOrUuid::Uuid(uuid) => Ok(vec![*uuid]),
527        TagOrUuid::Tag(tag) => {
528            let infos = tag.get_all_cur_info();
529            if !infos.is_empty() {
530                let face_ids = infos
531                    .iter()
532                    .map(|info| {
533                        info.surface
534                            .as_ref()
535                            .map(ExtrudeSurface::face_id)
536                            .or_else(|| solid.and_then(|solid| face_id_for_tag_info_from_solid(info.id, solid)))
537                    })
538                    .collect::<Option<Vec<_>>>();
539                if let Some(face_ids) = face_ids {
540                    return Ok(face_ids);
541                }
542            }
543
544            Ok(vec![resolve_as_face_id(value, exec_state, args).await?])
545        }
546    }
547}
548
549fn face_id_for_tag_info_from_solid(tag_info_id: Uuid, solid: &Solid) -> Option<Uuid> {
550    solid
551        .value
552        .iter()
553        .find(|surface| surface.get_id() == tag_info_id)
554        .map(ExtrudeSurface::face_id)
555}
556
557async fn resolve_as_adjacent_face_or_tag_id(
558    value: &TagOrUuid,
559    exec_state: &mut ExecState,
560    args: &Args,
561) -> Result<Uuid, KclError> {
562    match value {
563        TagOrUuid::Uuid(uuid) => Ok(*uuid),
564        TagOrUuid::Tag(tag) => match args.get_adjacent_face_to_tag(exec_state, tag, false).await {
565            Ok(face_id) => Ok(face_id),
566            Err(_) => Ok(args.get_tag_engine_info(exec_state, tag)?.id),
567        },
568    }
569}
570
571async fn resolve_as_edge_faces(
572    value: &TagOrUuid,
573    object_id: Uuid,
574    exec_state: &mut ExecState,
575    args: &Args,
576) -> Result<Vec<Uuid>, KclError> {
577    match value {
578        TagOrUuid::Uuid(uuid) => Ok(vec![*uuid]),
579        TagOrUuid::Tag(tag) => {
580            let edge_id = args.get_tag_engine_info(exec_state, tag)?.id;
581            get_face_ids_for_edge(exec_state, object_id, edge_id, args).await
582        }
583    }
584}
585
586pub(crate) async fn resolve_edge_specifier_with_face_tags(
587    unresolved: &UnresolvedEdgeSpecifier,
588    solid: Option<&Solid>,
589    exec_state: &mut ExecState,
590    args: &Args,
591) -> Result<kcmc::shared::EdgeSpecifier, KclError> {
592    let mut references = resolve_edge_specifiers_with_face_tags(unresolved, solid, exec_state, args).await?;
593    if references.len() != 1 {
594        return Err(KclError::new_semantic(KclErrorDetails::new(
595            "edge specifier resolved to multiple edge references where exactly one was expected".to_owned(),
596            vec![args.source_range],
597        )));
598    }
599    Ok(references.remove(0))
600}
601
602const MAX_EDGE_COMBINATIONS: usize = 256;
603
604/// Multiply group sizes into the number of combinations in their Cartesian
605/// product. Saturates at `usize::MAX` instead of overflowing, so a pathological
606/// product can't wrap around to a small value and slip under the limit check.
607fn combination_count(group_sizes: impl IntoIterator<Item = usize>) -> usize {
608    group_sizes.into_iter().fold(1, usize::saturating_mul)
609}
610
611/// Whether expanding the side/end face groups into concrete edge references
612/// would exceed `MAX_EDGE_COMBINATIONS`. Each axis is checked on its own in
613/// addition to the product: an empty group makes the product zero, which would
614/// otherwise mask a large count on the opposite axis.
615fn edge_combinations_exceed_limit(side_face_count: usize, end_face_count: usize) -> bool {
616    side_face_count > MAX_EDGE_COMBINATIONS
617        || end_face_count > MAX_EDGE_COMBINATIONS
618        || side_face_count.saturating_mul(end_face_count) > MAX_EDGE_COMBINATIONS
619}
620
621async fn resolve_edge_specifiers_with_face_tags(
622    unresolved: &UnresolvedEdgeSpecifier,
623    solid: Option<&Solid>,
624    exec_state: &mut ExecState,
625    args: &Args,
626) -> Result<Vec<kcmc::shared::EdgeSpecifier>, KclError> {
627    let mut side_face_groups = Vec::with_capacity(unresolved.side_faces.len());
628    for value in &unresolved.side_faces {
629        side_face_groups.push(resolve_as_face_ids(value, solid, exec_state, args).await?);
630    }
631    let mut end_face_groups = Vec::with_capacity(unresolved.end_faces.len());
632    for value in &unresolved.end_faces {
633        end_face_groups.push(resolve_as_face_ids(value, solid, exec_state, args).await?);
634    }
635
636    // Before computing all combinations, count them. If there would be too
637    // many, generate a fatal error so that we don't get stuck doing large
638    // work on pathological input.
639    let side_face_count = combination_count(side_face_groups.iter().map(Vec::len));
640    let end_face_count = combination_count(end_face_groups.iter().map(Vec::len));
641    if edge_combinations_exceed_limit(side_face_count, end_face_count) {
642        return Err(KclError::new_semantic(KclErrorDetails::new(
643            "This edge specifier is too ambiguous. The maximum number of effective edges specified has been exceeded. Either specify fewer faces or use faces that have been split fewer times.".to_owned(),
644            vec![args.source_range],
645        )));
646    }
647
648    // TODO(face-api): Once modeling-commands can represent grouped logical face
649    // references, pass these groups through as one engine payload instead of
650    // expanding them into several flat EdgeSpecifier payloads.
651    // See https://github.com/KittyCAD/modeling-api/issues/1252.
652    let side_face_combinations = face_id_combinations(&side_face_groups);
653    let end_face_combinations = face_id_combinations(&end_face_groups);
654    let mut references = Vec::with_capacity(side_face_combinations.len() * end_face_combinations.len());
655    for side_faces in side_face_combinations {
656        for end_faces in &end_face_combinations {
657            references.push(
658                kcmc::shared::EdgeSpecifier::builder()
659                    .side_faces(side_faces.clone())
660                    .end_faces(end_faces.clone())
661                    .maybe_index(unresolved.index)
662                    .build(),
663            );
664        }
665    }
666    // We should never duplicate the index. It should be used once on the engine
667    // side to resolve the entire set.
668    if references.len() > 1 && unresolved.index.is_some() {
669        return Err(KclError::new_semantic(KclErrorDetails::new(
670            "You tried to use an index with sideFaces or endFaces that were split, which isn't supported yet. Please report this to Zoo and include your KCL to help improve this.".to_owned(),
671            vec![args.source_range],
672        )));
673    }
674    Ok(references)
675}
676
677/// Computes the Cartesian product of a list of groups of face UUIDs. Given N
678/// groups, it returns every way of picking exactly one UUID from each group,
679/// preserving positional order.
680///
681/// ```ignore
682/// face_id_combinations([[a, b], [c, d]])  ->  [[a, c], [a, d], [b, c], [b, d]]
683/// ```
684fn face_id_combinations(groups: &[Vec<Uuid>]) -> Vec<Vec<Uuid>> {
685    if groups.is_empty() {
686        // Callers expect at least one element in the outer Vec.
687        return vec![Vec::new()];
688    }
689
690    let mut combinations = vec![Vec::new()];
691    for group in groups {
692        let mut next = Vec::with_capacity(combinations.len() * group.len());
693        for combination in &combinations {
694            for face_id in group {
695                let mut new_combination = combination.clone();
696                new_combination.push(*face_id);
697                next.push(new_combination);
698            }
699        }
700        combinations = next;
701    }
702    combinations
703}
704
705pub(crate) async fn resolve_edge_specifier_with_adjacent_faces_or_tag_ids(
706    unresolved: &UnresolvedEdgeSpecifier,
707    exec_state: &mut ExecState,
708    args: &Args,
709) -> Result<kcmc::shared::EdgeSpecifier, KclError> {
710    let mut side_faces = Vec::with_capacity(unresolved.side_faces.len());
711    for value in &unresolved.side_faces {
712        side_faces.push(resolve_as_adjacent_face_or_tag_id(value, exec_state, args).await?);
713    }
714    let mut end_faces = Vec::with_capacity(unresolved.end_faces.len());
715    for value in &unresolved.end_faces {
716        end_faces.push(resolve_as_adjacent_face_or_tag_id(value, exec_state, args).await?);
717    }
718    Ok(kcmc::shared::EdgeSpecifier::builder()
719        .side_faces(side_faces)
720        .end_faces(end_faces)
721        .maybe_index(unresolved.index)
722        .build())
723}
724
725pub(crate) async fn parse_edge_refs_to_references(
726    edge_refs: Vec<KclValue>,
727    solid: Option<&Solid>,
728    exec_state: &mut ExecState,
729    args: &Args,
730) -> Result<Vec<kcmc::shared::EdgeSpecifier>, KclError> {
731    if edge_refs.is_empty() {
732        return Err(KclError::new_semantic(KclErrorDetails::new(
733            "You must provide at least one edge".to_owned(),
734            vec![args.source_range],
735        )));
736    }
737
738    let mut edge_references = Vec::with_capacity(edge_refs.len());
739    for edge_ref_value in &edge_refs {
740        let spec = parse_edge_specifier_value(edge_ref_value, args)?;
741        edge_references.extend(resolve_edge_specifiers_with_face_tags(&spec, solid, exec_state, args).await?);
742    }
743    Ok(edge_references)
744}
745
746/// Get the face (surface body) id from the first side_face of an unresolved
747/// specifier. Used when building a BoundedEdge from an edge specifier object in
748/// blend().
749pub(super) fn face_id_from_first_side_face(
750    spec: &UnresolvedEdgeSpecifier,
751    exec_state: &mut ExecState,
752    args: &Args,
753) -> Result<Uuid, KclError> {
754    let first = spec.side_faces.first().ok_or_else(|| {
755        KclError::new_type(KclErrorDetails::new(
756            "edge specifier must have at least one sideFace".to_owned(),
757            vec![args.source_range],
758        ))
759    })?;
760    match first {
761        TagOrUuid::Uuid(u) => Ok(*u),
762        TagOrUuid::Tag(t) => {
763            let info = args.get_tag_engine_info(exec_state, t)?;
764            Ok(info.geometry.id())
765        }
766    }
767}
768
769pub(crate) async fn inner_get_bounded_edge_with_id(
770    face: Solid,
771    edge: EdgeReference,
772    lower_bound: Option<TyF64>,
773    upper_bound: Option<TyF64>,
774    exec_state: &mut ExecState,
775    args: Args,
776) -> Result<BoundedEdge, KclError> {
777    let (lb, ub) = bounds_from_opts(lower_bound, upper_bound, &args)?;
778    let edge_id = edge.get_engine_id(exec_state, &args)?;
779    Ok(BoundedEdge {
780        face_id: face.id,
781        edge_id: Some(edge_id),
782        edge_specifier: None,
783        lower_bound: lb,
784        upper_bound: ub,
785    })
786}
787
788fn inner_get_bounded_edge_with_specifier(
789    face: Solid,
790    spec: UnresolvedEdgeSpecifier,
791    lower_bound: Option<TyF64>,
792    upper_bound: Option<TyF64>,
793    args: &Args,
794) -> Result<BoundedEdge, KclError> {
795    let (lb, ub) = bounds_from_opts(lower_bound, upper_bound, args)?;
796    Ok(BoundedEdge {
797        face_id: face.id,
798        edge_id: None,
799        edge_specifier: Some(spec),
800        lower_bound: lb,
801        upper_bound: ub,
802    })
803}
804
805fn bounds_from_opts(
806    lower_bound: Option<TyF64>,
807    upper_bound: Option<TyF64>,
808    args: &Args,
809) -> Result<(f32, f32), KclError> {
810    let lower_bound = if let Some(lower_bound) = lower_bound {
811        let val = lower_bound.n as f32;
812        if !(0.0..=1.0).contains(&val) {
813            return Err(KclError::new_semantic(KclErrorDetails::new(
814                format!(
815                    "Invalid value: lowerBound must be between 0.0 and 1.0, provided {}",
816                    val
817                ),
818                vec![args.source_range],
819            )));
820        }
821        val
822    } else {
823        0.0_f32
824    };
825    let upper_bound = if let Some(upper_bound) = upper_bound {
826        let val = upper_bound.n as f32;
827        if !(0.0..=1.0).contains(&val) {
828            return Err(KclError::new_semantic(KclErrorDetails::new(
829                format!(
830                    "Invalid value: upperBound must be between 0.0 and 1.0, provided {}",
831                    val
832                ),
833                vec![args.source_range],
834            )));
835        }
836        val
837    } else {
838        1.0_f32
839    };
840    Ok((lower_bound, upper_bound))
841}
842
843/// Resolve an unresolved edge specifier (tags/UUIDs) to engine EdgeSpecifier (face UUIDs) for blend. Called from blend().
844pub(crate) async fn resolve_unresolved_edge_specifier(
845    object_id: Uuid,
846    unresolved: &UnresolvedEdgeSpecifier,
847    exec_state: &mut ExecState,
848    args: &Args,
849) -> Result<kcmc::shared::EdgeSpecifier, KclError> {
850    let mut side_faces = Vec::new();
851    for v in &unresolved.side_faces {
852        side_faces.extend(resolve_as_edge_faces(v, object_id, exec_state, args).await?);
853    }
854    let mut end_faces = Vec::new();
855    for v in &unresolved.end_faces {
856        end_faces.extend(resolve_as_edge_faces(v, object_id, exec_state, args).await?);
857    }
858    Ok(kcmc::shared::EdgeSpecifier::builder()
859        .side_faces(side_faces)
860        .end_faces(end_faces)
861        .maybe_index(unresolved.index)
862        .build())
863}
864
865#[cfg(test)]
866mod tests {
867    use uuid::Uuid;
868
869    use super::MAX_EDGE_COMBINATIONS;
870    use super::combination_count;
871    use super::edge_combinations_exceed_limit;
872    use super::face_id_combinations;
873
874    #[test]
875    fn face_id_combinations_empty_input_is_one_empty_combination() {
876        // The product of zero groups is a single empty tuple, not zero tuples.
877        // Callers rely on this so that an absent endFaces still yields one
878        // iteration rather than dropping every reference.
879        assert_eq!(face_id_combinations(&[]), vec![Vec::<Uuid>::new()]);
880    }
881
882    #[test]
883    fn face_id_combinations_empty_group_annihilates() {
884        // A single zero-length group collapses the whole product to nothing.
885        let a = Uuid::from_u128(1);
886        assert_eq!(face_id_combinations(&[vec![a], vec![]]), Vec::<Vec<Uuid>>::new());
887    }
888
889    #[test]
890    fn face_id_combinations_is_ordered_cartesian_product() {
891        let (a, b, c, d) = (
892            Uuid::from_u128(1),
893            Uuid::from_u128(2),
894            Uuid::from_u128(3),
895            Uuid::from_u128(4),
896        );
897        assert_eq!(
898            face_id_combinations(&[vec![a, b], vec![c, d]]),
899            vec![vec![a, c], vec![a, d], vec![b, c], vec![b, d]],
900        );
901    }
902
903    #[test]
904    fn combination_count_is_product_of_group_sizes() {
905        assert_eq!(combination_count(std::iter::empty::<usize>()), 1); // empty product
906        assert_eq!(combination_count([1usize, 1, 1]), 1);
907        assert_eq!(combination_count([2usize, 3, 4]), 24);
908    }
909
910    #[test]
911    fn combination_count_with_empty_group_is_zero() {
912        assert_eq!(combination_count([5usize, 0, 5]), 0);
913    }
914
915    #[test]
916    fn combination_count_saturates_instead_of_overflowing() {
917        // Must pin at usize::MAX rather than wrapping to a small value, which
918        // would let a huge product slip under the limit.
919        assert_eq!(combination_count([usize::MAX, 2]), usize::MAX);
920        assert_eq!(combination_count([usize::MAX, usize::MAX]), usize::MAX);
921    }
922
923    #[test]
924    fn within_limit_is_allowed() {
925        assert!(!edge_combinations_exceed_limit(1, 1));
926        // Exactly at the limit on a single axis is allowed.
927        assert!(!edge_combinations_exceed_limit(MAX_EDGE_COMBINATIONS, 1));
928        assert!(!edge_combinations_exceed_limit(1, MAX_EDGE_COMBINATIONS));
929    }
930
931    #[test]
932    fn one_past_the_limit_on_a_single_axis_is_rejected() {
933        assert!(edge_combinations_exceed_limit(MAX_EDGE_COMBINATIONS + 1, 1));
934        assert!(edge_combinations_exceed_limit(1, MAX_EDGE_COMBINATIONS + 1));
935    }
936
937    #[test]
938    fn product_of_two_in_range_axes_still_exceeds_limit() {
939        // 16 and 17 are each within the limit but 16 * 17 = 272 is not, so the
940        // product check is needed in addition to the per-axis checks.
941        assert!(edge_combinations_exceed_limit(16, 17));
942    }
943
944    #[test]
945    fn large_axis_is_rejected_even_when_the_other_axis_is_zero() {
946        // Regression for the zero case: an empty group zeroes the product, so
947        // without the per-axis checks the huge opposite axis would still be
948        // expanded.
949        assert_eq!((MAX_EDGE_COMBINATIONS + 1).saturating_mul(0), 0);
950        assert!(edge_combinations_exceed_limit(MAX_EDGE_COMBINATIONS + 1, 0));
951        assert!(edge_combinations_exceed_limit(0, MAX_EDGE_COMBINATIONS + 1));
952    }
953
954    #[test]
955    fn saturated_count_is_rejected_without_overflowing() {
956        // A count that already saturated must be over the limit, and the final
957        // multiply must neither panic nor wrap.
958        assert!(edge_combinations_exceed_limit(usize::MAX, 2));
959        assert!(edge_combinations_exceed_limit(usize::MAX, usize::MAX));
960    }
961}