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