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