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