Skip to main content

kcl_lib/std/
extrude.rs

1//! Functions related to extruding.
2
3use std::collections::HashMap;
4
5use anyhow::Result;
6use indexmap::IndexMap;
7use kcmc::ModelingCmd;
8use kcmc::each_cmd as mcmd;
9use kcmc::length_unit::LengthUnit;
10use kcmc::ok_response::OkModelingCmdResponse;
11use kcmc::output::ExtrusionFaceInfo;
12use kcmc::shared::ExtrudeReference;
13use kcmc::shared::ExtrusionFaceCapType;
14use kcmc::shared::Opposite;
15use kcmc::shared::Point3d as KPoint3d; // Point3d is already defined in this pkg, to impl ts_rs traits.
16use kcmc::websocket::ModelingCmdReq;
17use kcmc::websocket::OkWebSocketResponseData;
18use kittycad_modeling_cmds::shared::Angle;
19use kittycad_modeling_cmds::shared::BodyType;
20use kittycad_modeling_cmds::shared::DirectionType;
21use kittycad_modeling_cmds::shared::EntityReference;
22use kittycad_modeling_cmds::shared::ExtrudeMethod;
23use kittycad_modeling_cmds::shared::Point2d;
24use kittycad_modeling_cmds::{self as kcmc};
25use uuid::Uuid;
26
27use super::DEFAULT_TOLERANCE_MM;
28use super::args::FromKclValue;
29use super::args::TyF64;
30use super::utils::point_to_mm;
31use crate::errors::KclError;
32use crate::errors::KclErrorDetails;
33use crate::execution::ArtifactId;
34use crate::execution::CreatorEdge;
35use crate::execution::CreatorFace;
36use crate::execution::ExecState;
37use crate::execution::ExecutorContext;
38use crate::execution::Extrudable;
39use crate::execution::ExtrudePlane;
40use crate::execution::ExtrudeSurface;
41use crate::execution::GeoMeta;
42use crate::execution::Geometry;
43use crate::execution::KclValue;
44use crate::execution::Metadata;
45use crate::execution::ModelingCmdMeta;
46use crate::execution::Path;
47use crate::execution::ProfileClosed;
48use crate::execution::Segment;
49use crate::execution::SegmentKind;
50use crate::execution::Sketch;
51use crate::execution::SketchSurface;
52use crate::execution::Solid;
53use crate::execution::SolidCreator;
54use crate::execution::TagEngineInfo;
55use crate::execution::TagIdentifier;
56use crate::execution::annotations;
57use crate::execution::types::ArrayLen;
58use crate::execution::types::PrimitiveType;
59use crate::execution::types::RuntimeType;
60use crate::parsing::ast::types::TagDeclarator;
61use crate::parsing::ast::types::TagNode;
62use crate::std::Args;
63use crate::std::axis_or_reference::Point3dAxis3dOrGeometryReference;
64use crate::std::axis_or_reference::Point3dOrEdgeReference;
65use crate::std::edge::{self};
66use crate::std::solver::create_segments_in_engine;
67
68fn must_be_lengths_err(source_range: crate::SourceRange) -> KclError {
69    KclError::new_type(KclErrorDetails::new(
70        "The components of `direction` must be lengths, e.g. `[0, 0, 1]` or `[1mm, 0mm, 1in]`".to_owned(),
71        vec![source_range],
72    ))
73}
74
75/// Extrudes by a given amount.
76pub async fn extrude(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
77    let sketch_values: Vec<KclValue> = args.get_unlabeled_kw_arg(
78        "sketches",
79        &RuntimeType::Array(
80            Box::new(RuntimeType::Primitive(PrimitiveType::Any)),
81            ArrayLen::Minimum(1),
82        ),
83        exec_state,
84    )?;
85
86    let length: Option<TyF64> = args.get_kw_arg_opt("length", &RuntimeType::length(), exec_state)?;
87    let to_raw = args.get_kw_arg_opt(
88        "to",
89        &RuntimeType::Union(vec![
90            RuntimeType::point3d(),
91            RuntimeType::Primitive(PrimitiveType::Axis3d),
92            RuntimeType::Primitive(PrimitiveType::Edge),
93            RuntimeType::plane(),
94            RuntimeType::Primitive(PrimitiveType::Face),
95            RuntimeType::sketch(),
96            RuntimeType::Primitive(PrimitiveType::Solid),
97            RuntimeType::tagged_edge(),
98            RuntimeType::tagged_face(),
99            RuntimeType::Primitive(PrimitiveType::Any),
100        ]),
101        exec_state,
102    )?;
103    let to = match to_raw {
104        None => None,
105        Some(v) => {
106            let inner = if let KclValue::Object { value: ref obj, .. } = v {
107                if edge::is_edge_specifier_object(&v) {
108                    Point3dAxis3dOrGeometryReference::EdgeToReference(edge::parse_edge_specifier_object(obj, &args)?)
109                } else {
110                    Point3dAxis3dOrGeometryReference::from_kcl_val(&v).ok_or_else(|| {
111                        KclError::new_type(KclErrorDetails::new(
112                            "Invalid value for `to`".to_owned(),
113                            vec![args.source_range],
114                        ))
115                    })?
116                }
117            } else {
118                Point3dAxis3dOrGeometryReference::from_kcl_val(&v).ok_or_else(|| {
119                    KclError::new_type(KclErrorDetails::new(
120                        "Invalid value for `to`".to_owned(),
121                        vec![args.source_range],
122                    ))
123                })?
124            };
125            Some(inner)
126        }
127    };
128    let symmetric = args.get_kw_arg_opt("symmetric", &RuntimeType::bool(), exec_state)?;
129    let bidirectional_length: Option<TyF64> =
130        args.get_kw_arg_opt("bidirectionalLength", &RuntimeType::length(), exec_state)?;
131    let direction_raw = args.get_kw_arg_opt("direction", &RuntimeType::any(), exec_state)?;
132    let direction = match direction_raw {
133        None => None,
134        Some(v) => {
135            let inner = if edge::is_edge_specifier_object(&v) {
136                Point3dOrEdgeReference::EdgeSpecifier(edge::parse_edge_specifier_value(&v, &args)?)
137            } else {
138                Point3dOrEdgeReference::from_kcl_val(&v).ok_or_else(|| {
139                    KclError::new_type(KclErrorDetails::new(
140                        "Invalid value for `direction`".to_owned(),
141                        vec![args.source_range],
142                    ))
143                })?
144            };
145            if let Point3dOrEdgeReference::Point(point) = &inner
146                && !point.iter().all(|component| component.is_length_compatible())
147            {
148                return Err(must_be_lengths_err(args.source_range));
149            }
150            Some(inner)
151        }
152    };
153    let tag_start = args.get_kw_arg_opt("tagStart", &RuntimeType::tag_decl(), exec_state)?;
154    let tag_end = args.get_kw_arg_opt("tagEnd", &RuntimeType::tag_decl(), exec_state)?;
155    let draft_angle: Option<TyF64> = args.get_kw_arg_opt("draftAngle", &RuntimeType::degrees(), exec_state)?;
156    let twist_angle: Option<TyF64> = args.get_kw_arg_opt("twistAngle", &RuntimeType::degrees(), exec_state)?;
157    let twist_angle_step: Option<TyF64> = args.get_kw_arg_opt("twistAngleStep", &RuntimeType::degrees(), exec_state)?;
158    let twist_center: Option<[TyF64; 2]> = args.get_kw_arg_opt("twistCenter", &RuntimeType::point2d(), exec_state)?;
159    let tolerance: Option<TyF64> = args.get_kw_arg_opt("tolerance", &RuntimeType::length(), exec_state)?;
160    let method: Option<String> = args.get_kw_arg_opt("method", &RuntimeType::string(), exec_state)?;
161    let hide_seams: Option<bool> = args.get_kw_arg_opt("hideSeams", &RuntimeType::bool(), exec_state)?;
162    let body_type: Option<BodyType> = args.get_kw_arg_opt("bodyType", &RuntimeType::string(), exec_state)?;
163    let target_argument_source_range = args
164        .unlabeled_kw_arg_unconverted()
165        .map(|arg| arg.source_range)
166        .unwrap_or(args.source_range);
167    let direct_target_edges = sketch_values
168        .iter()
169        .filter_map(|value| {
170            let KclValue::TagIdentifier(tag) = value else {
171                return None;
172            };
173            Some(tag.clone())
174        })
175        .collect::<Vec<_>>();
176    let extruding_sketch_segments = sketch_values.iter().all(|value| value.clone().into_segment().is_some());
177    let sketches = coerce_extrude_targets(
178        sketch_values,
179        body_type.unwrap_or_default(),
180        tag_start.as_ref(),
181        tag_end.as_ref(),
182        exec_state,
183        &args.ctx,
184        args.source_range,
185    )
186    .await?;
187
188    if let [tag] = direct_target_edges.as_slice() {
189        edge::record_refactor_meta_for_direct_tag(exec_state, tag, target_argument_source_range, &args).await?;
190    }
191
192    let result = inner_extrude(
193        sketches,
194        length,
195        to,
196        symmetric,
197        direction,
198        bidirectional_length,
199        tag_start,
200        tag_end,
201        draft_angle,
202        twist_angle,
203        twist_angle_step,
204        twist_center,
205        tolerance,
206        method,
207        hide_seams,
208        body_type,
209        extruding_sketch_segments,
210        exec_state,
211        args,
212    )
213    .await?;
214
215    Ok(result.into())
216}
217
218pub async fn coerce_extrude_targets(
219    sketch_values: Vec<KclValue>,
220    body_type: BodyType,
221    tag_start: Option<&TagNode>,
222    tag_end: Option<&TagNode>,
223    exec_state: &mut ExecState,
224    ctx: &ExecutorContext,
225    source_range: crate::SourceRange,
226) -> Result<Vec<Extrudable>, KclError> {
227    let mut extrudables = Vec::new();
228    let mut segments = Vec::new();
229
230    for value in sketch_values {
231        if let Some(segment) = value.clone().into_segment() {
232            segments.push(segment);
233            continue;
234        }
235
236        if edge::is_edge_specifier_object(&value) {
237            extrudables.push(Extrudable::EdgeSpecifier(edge::parse_edge_specifier_value_at(
238                &value,
239                source_range,
240            )?));
241            continue;
242        }
243
244        let Some(extrudable) = Extrudable::from_kcl_val(&value) else {
245            return Err(KclError::new_type(KclErrorDetails::new(
246                "Expected sketches, faces, tagged faces, or solved sketch segments for extrusion.".to_owned(),
247                vec![source_range],
248            )));
249        };
250        extrudables.push(extrudable);
251    }
252
253    if !segments.is_empty() && !extrudables.is_empty() {
254        return Err(KclError::new_semantic(KclErrorDetails::new(
255            "Cannot extrude sketch segments together with sketches or faces in the same call. Use separate `extrude()` calls.".to_owned(),
256            vec![source_range],
257        )));
258    }
259
260    if !segments.is_empty() {
261        if !matches!(body_type, BodyType::Surface) {
262            let kind_of_extrude = match body_type {
263                BodyType::Solid => "solid extrude",
264                BodyType::Surface => "surface extrude",
265                _ => "non-surface extrude",
266            };
267            return Err(KclError::new_semantic(KclErrorDetails::new(
268                format!(
269                    "You're trying to perform a {kind_of_extrude} on an edge, but edges can only be extruded with surface extrudes. To do a solid extrude, select a closed sketch region instead. To extrude these edges, do a surface extrude by using `bodyType = SURFACE` instead."
270                ),
271                vec![source_range],
272            )));
273        }
274
275        if tag_start.is_some() || tag_end.is_some() {
276            return Err(KclError::new_semantic(KclErrorDetails::new(
277                "`tagStart` and `tagEnd` are not supported when extruding sketch segments. Segment surface extrudes do not create start or end caps."
278                    .to_owned(),
279                vec![source_range],
280            )));
281        }
282
283        let synthetic_sketch = build_segment_surface_sketch(segments, exec_state, ctx, source_range).await?;
284        return Ok(vec![Extrudable::from(synthetic_sketch)]);
285    }
286
287    // Edges behave like sketch segments: they can only be surface-extruded, they
288    // don't create caps, and they can't be mixed with sketches or faces. Enforce
289    // the same rules so these cases fail loudly instead of silently producing a
290    // surface (or silently ignoring `tagStart`/`tagEnd`).
291    let has_edge = extrudables.iter().any(|e| {
292        matches!(
293            e,
294            Extrudable::Edge(_) | Extrudable::EdgeTag(_) | Extrudable::EdgeSpecifier(_)
295        )
296    });
297    if has_edge {
298        let has_non_edge = extrudables.iter().any(|e| {
299            !matches!(
300                e,
301                Extrudable::Edge(_) | Extrudable::EdgeTag(_) | Extrudable::EdgeSpecifier(_)
302            )
303        });
304        if has_non_edge {
305            return Err(KclError::new_semantic(KclErrorDetails::new(
306                "Cannot extrude edges together with sketches or faces in the same call. Use separate `extrude()` calls.".to_owned(),
307                vec![source_range],
308            )));
309        }
310
311        if !matches!(body_type, BodyType::Surface) {
312            let kind_of_extrude = match body_type {
313                BodyType::Solid => "solid extrude",
314                BodyType::Surface => "surface extrude",
315                _ => "non-surface extrude",
316            };
317            return Err(KclError::new_semantic(KclErrorDetails::new(
318                format!(
319                    "You're trying to perform a {kind_of_extrude} on an edge, but edges can only be extruded with surface extrudes. To do a solid extrude, select a closed sketch region instead. To extrude these edges, do a surface extrude by using `bodyType = SURFACE` instead."
320                ),
321                vec![source_range],
322            )));
323        }
324
325        if tag_start.is_some() || tag_end.is_some() {
326            return Err(KclError::new_semantic(KclErrorDetails::new(
327                "`tagStart` and `tagEnd` are not supported when extruding edges. Edge surface extrudes do not create start or end caps."
328                    .to_owned(),
329                vec![source_range],
330            )));
331        }
332    }
333
334    Ok(extrudables)
335}
336
337pub(crate) async fn build_segment_surface_sketch(
338    mut segments: Vec<Segment>,
339    exec_state: &mut ExecState,
340    ctx: &ExecutorContext,
341    source_range: crate::SourceRange,
342) -> Result<Sketch, KclError> {
343    let Some(first_segment) = segments.first() else {
344        return Err(KclError::new_semantic(KclErrorDetails::new(
345            "Expected at least one sketch segment.".to_owned(),
346            vec![source_range],
347        )));
348    };
349
350    let sketch_id = first_segment.sketch_id;
351    let sketch_surface = first_segment.surface.clone();
352    for segment in &segments {
353        if segment.sketch_id != sketch_id {
354            return Err(KclError::new_semantic(KclErrorDetails::new(
355                "All sketch segments passed to this operation must come from the same sketch.".to_owned(),
356                vec![source_range],
357            )));
358        }
359
360        if segment.surface != sketch_surface {
361            return Err(KclError::new_semantic(KclErrorDetails::new(
362                "All sketch segments passed to this operation must lie on the same sketch surface.".to_owned(),
363                vec![source_range],
364            )));
365        }
366
367        if matches!(segment.kind, SegmentKind::Point { .. }) {
368            return Err(KclError::new_semantic(KclErrorDetails::new(
369                "Point segments cannot be used here. Select line, arc, or circle segments instead.".to_owned(),
370                vec![source_range],
371            )));
372        }
373
374        if segment.is_construction() {
375            return Err(KclError::new_semantic(KclErrorDetails::new(
376                "Construction segments cannot be used here. Select non-construction sketch segments instead."
377                    .to_owned(),
378                vec![source_range],
379            )));
380        }
381    }
382
383    let synthetic_sketch_id = exec_state.next_uuid();
384    let segment_tags = IndexMap::from_iter(segments.iter().filter_map(|segment| {
385        segment
386            .tag
387            .as_ref()
388            .map(|tag| (segment.object_id, TagDeclarator::new(&tag.value)))
389    }));
390
391    for segment in &mut segments {
392        segment.id = exec_state.next_uuid();
393        segment.sketch_id = synthetic_sketch_id;
394        segment.sketch = None;
395    }
396
397    create_segments_in_engine(
398        &sketch_surface,
399        synthetic_sketch_id,
400        &mut segments,
401        &segment_tags,
402        ctx,
403        exec_state,
404        source_range,
405    )
406    .await?
407    .ok_or_else(|| {
408        KclError::new_semantic(KclErrorDetails::new(
409            "Expected at least one usable sketch segment.".to_owned(),
410            vec![source_range],
411        ))
412    })
413}
414
415#[allow(clippy::too_many_arguments)]
416async fn inner_extrude(
417    extrudables: Vec<Extrudable>,
418    length: Option<TyF64>,
419    to: Option<Point3dAxis3dOrGeometryReference>,
420    symmetric: Option<bool>,
421    direction: Option<Point3dOrEdgeReference>,
422    bidirectional_length: Option<TyF64>,
423    tag_start: Option<TagNode>,
424    tag_end: Option<TagNode>,
425    draft_angle: Option<TyF64>,
426    twist_angle: Option<TyF64>,
427    twist_angle_step: Option<TyF64>,
428    twist_center: Option<[TyF64; 2]>,
429    tolerance: Option<TyF64>,
430    method: Option<String>,
431    hide_seams: Option<bool>,
432    body_type: Option<BodyType>,
433    extruding_sketch_segments: bool,
434    exec_state: &mut ExecState,
435    args: Args,
436) -> Result<Vec<Solid>, KclError> {
437    let body_type = body_type.unwrap_or_default();
438
439    if matches!(body_type, BodyType::Solid) && extrudables.iter().any(|sk| matches!(sk.is_closed(), ProfileClosed::No))
440    {
441        return Err(KclError::new_semantic(KclErrorDetails::new(
442            "Cannot solid extrude an open profile. Either close the profile, or use a surface extrude.".to_owned(),
443            vec![args.source_range],
444        )));
445    }
446
447    if draft_angle.is_some() && twist_angle.is_some() {
448        return Err(KclError::new_semantic(KclErrorDetails::new(
449            "Zoo currently does not support adding both draft angle and twist angle to an extrude simultaneously"
450                .to_owned(),
451            vec![args.source_range],
452        )));
453    }
454
455    if direction.is_some() && twist_angle.is_some() {
456        return Err(KclError::new_semantic(KclErrorDetails::new(
457            "Zoo currently does not support adding both direction and twist angle to an extrude simultaneously"
458                .to_owned(),
459            vec![args.source_range],
460        )));
461    }
462
463    // Extrude the element(s).
464    let mut solids = Vec::new();
465    let tolerance = LengthUnit(
466        tolerance
467            .as_ref()
468            .map(|t| t.unwrap_to_mm())
469            .unwrap_or(DEFAULT_TOLERANCE_MM),
470    );
471
472    let extrude_method = match method.as_deref() {
473        Some("new" | "NEW") => ExtrudeMethod::New,
474        Some("merge" | "MERGE") => ExtrudeMethod::Merge,
475        None => ExtrudeMethod::default(),
476        Some(other) => {
477            return Err(KclError::new_semantic(KclErrorDetails::new(
478                format!("Unknown merge method {other}, try using `MERGE` or `NEW`"),
479                vec![args.source_range],
480            )));
481        }
482    };
483
484    if matches!(method.as_deref(), Some("merge" | "MERGE")) {
485        let parentless_sketches = extrudables
486            .iter()
487            .filter(|extrudable| {
488                matches!(extrudable, Extrudable::Sketch(sketch) if matches!(sketch.on, SketchSurface::Plane(_)))
489            })
490            .count();
491        if parentless_sketches > 0 {
492            let message = if parentless_sketches == 1 {
493                "This plane-based sketch has no parent body for `method = MERGE`, so it will create a separate body. Sketch on an existing face or use `union` if one body is intended."
494                    .to_owned()
495            } else {
496                format!(
497                    "{parentless_sketches} plane-based sketches have no parent body for `method = MERGE`, so each will create a separate body. Sketch on an existing face or use `union` if one body is intended."
498                )
499            };
500            exec_state.warn(
501                crate::CompilationIssue {
502                    source_range: args
503                        .labeled
504                        .get("method")
505                        .map_or(args.source_range, |arg| arg.source_range),
506                    message,
507                    suggestion: None,
508                    severity: crate::errors::Severity::Warning,
509                    tag: crate::errors::Tag::Unnecessary,
510                },
511                annotations::WARN_PARENTLESS_MERGE,
512            );
513        }
514    }
515
516    if symmetric.unwrap_or(false) && bidirectional_length.is_some() {
517        return Err(KclError::new_semantic(KclErrorDetails::new(
518            "You cannot give both `symmetric` and `bidirectional` params, you have to choose one or the other"
519                .to_owned(),
520            vec![args.source_range],
521        )));
522    }
523
524    if (length.is_some() || twist_angle.is_some()) && to.is_some() {
525        return Err(KclError::new_semantic(KclErrorDetails::new(
526            "You cannot give `length` or `twist` params with the `to` param, you have to choose one or the other"
527                .to_owned(),
528            vec![args.source_range],
529        )));
530    }
531
532    let bidirection = bidirectional_length.map(|l| LengthUnit(l.unwrap_to_mm()));
533
534    let opposite = match (symmetric, bidirection) {
535        (Some(true), _) => Opposite::Symmetric,
536        (None, None) => Opposite::None,
537        (Some(false), None) => Opposite::None,
538        (None, Some(length)) => Opposite::Other(length),
539        (Some(false), Some(length)) => Opposite::Other(length),
540    };
541
542    if let Some(Point3dOrEdgeReference::Edge(direction_edge)) = &direction {
543        let edge_id = direction_edge.get_engine_id(exec_state, &args)?;
544        let source_range = args
545            .labeled
546            .get("direction")
547            .map(|arg| arg.source_range)
548            .unwrap_or(args.source_range);
549        edge::record_refactor_meta_for_direct_edge(exec_state, edge_id, source_range, &args).await?;
550    }
551
552    for extrudable in &extrudables {
553        let is_edge = match extrudable {
554            Extrudable::Sketch(..) => false,
555            Extrudable::FaceTag(_) => false,
556            Extrudable::Face(_) => false,
557            Extrudable::EdgeTag(_) => true,
558            Extrudable::Edge(_) => true,
559            Extrudable::EdgeSpecifier(_) => true,
560        };
561        let extrude_cmd_id = exec_state.next_uuid();
562        let (sketch_or_face_id, target_reference) = match extrudable {
563            Extrudable::EdgeSpecifier(spec) => (
564                None,
565                Some(edge::resolve_edge_specifier_with_face_tags(spec, None, exec_state, &args).await?),
566            ),
567            _ => (Some(extrudable.id_to_extrude(exec_state, &args, false).await?), None),
568        };
569        if to.is_some() && sketch_or_face_id.is_none() {
570            return Err(KclError::new_semantic(KclErrorDetails::new(
571                "Edge specifiers cannot be extruded to a reference".to_owned(),
572                vec![args.source_range],
573            )));
574        }
575        let concrete_target = || {
576            sketch_or_face_id.ok_or_else(|| {
577                KclError::new_semantic(KclErrorDetails::new(
578                    "This extrusion requires a concrete target UUID".to_owned(),
579                    vec![args.source_range],
580                ))
581            })
582        };
583        let cmd = match (
584            &twist_angle,
585            &twist_angle_step,
586            &twist_center,
587            length.clone(),
588            &to,
589            &direction,
590        ) {
591            (Some(angle), angle_step, center, Some(length), None, None) => {
592                let center = center.clone().map(point_to_mm).map(Point2d::from).unwrap_or_default();
593                let total_rotation_angle = Angle::from_degrees(angle.unwrap_to_degrees(exec_state, args.source_range));
594                let angle_step_size = Angle::from_degrees(
595                    angle_step
596                        .clone()
597                        .map(|a| a.unwrap_to_degrees(exec_state, args.source_range))
598                        .unwrap_or(15.0),
599                );
600                ModelingCmd::from(
601                    mcmd::TwistExtrude::builder()
602                        .target(
603                            sketch_or_face_id
604                                .ok_or_else(|| {
605                                    KclError::new_semantic(KclErrorDetails::new(
606                                        "Edge specifiers cannot be used with twist extrusion".to_owned(),
607                                        vec![args.source_range],
608                                    ))
609                                })?
610                                .into(),
611                        )
612                        .distance(LengthUnit(length.unwrap_to_mm()))
613                        .center_2d(center)
614                        .total_rotation_angle(total_rotation_angle)
615                        .angle_step_size(angle_step_size)
616                        .tolerance(tolerance)
617                        .body_type(body_type)
618                        .build(),
619                )
620            }
621            (None, None, None, Some(length), None, None) => ModelingCmd::from(
622                mcmd::Extrude::builder()
623                    .maybe_target(sketch_or_face_id.map(Into::into))
624                    .maybe_target_reference(target_reference.clone())
625                    .distance(LengthUnit(length.unwrap_to_mm()))
626                    .opposite(opposite.clone())
627                    .maybe_draft_angle(
628                        draft_angle
629                            .clone()
630                            .map(|a| Angle::from_degrees(a.unwrap_to_degrees(exec_state, args.source_range))),
631                    )
632                    .extrude_method(extrude_method)
633                    .body_type(body_type)
634                    .maybe_merge_coplanar_faces(hide_seams)
635                    .build(),
636            ),
637            (None, None, None, Some(length), None, Some(dir)) => {
638                let direction3d = match dir {
639                    Point3dOrEdgeReference::Point(p) => {
640                        // Only the direction matters, so use the file's unit: a direction that
641                        // doesn't mix units is sent with the same numbers as it was written.
642                        let units = exec_state.length_unit();
643                        Some(DirectionType::Axis {
644                            direction: KPoint3d {
645                                x: p[0]
646                                    .to_length_units(units)
647                                    .ok_or(must_be_lengths_err(args.source_range))?,
648                                y: p[1]
649                                    .to_length_units(units)
650                                    .ok_or(must_be_lengths_err(args.source_range))?,
651                                z: p[2]
652                                    .to_length_units(units)
653                                    .ok_or(must_be_lengths_err(args.source_range))?,
654                            },
655                        })
656                    }
657                    Point3dOrEdgeReference::Edge(edge) => {
658                        let edge_id = match edge {
659                            crate::std::fillet::EdgeReference::Uuid(uuid) => *uuid,
660                            crate::std::fillet::EdgeReference::Tag(tag) => match tag.get_cur_info() {
661                                Some(info) => info.id,
662                                None => {
663                                    return Err(KclError::new_semantic(KclErrorDetails::new(
664                                        "Failed to get current info for tag".to_string(),
665                                        vec![args.source_range],
666                                    )));
667                                }
668                            },
669                        };
670                        Some(DirectionType::Edge { id: edge_id })
671                    }
672                    Point3dOrEdgeReference::EdgeSpecifier(_) => None,
673                };
674                let direction_reference = match dir {
675                    Point3dOrEdgeReference::EdgeSpecifier(spec) => {
676                        Some(edge::resolve_edge_specifier_with_face_tags(spec, None, exec_state, &args).await?)
677                    }
678                    _ => None,
679                };
680                ModelingCmd::from(
681                    mcmd::Extrude::builder()
682                        .maybe_target(sketch_or_face_id.map(Into::into))
683                        .maybe_target_reference(target_reference.clone())
684                        .distance(LengthUnit(length.unwrap_to_mm()))
685                        .opposite(opposite.clone())
686                        .maybe_draft_angle(
687                            draft_angle
688                                .clone()
689                                .map(|a| Angle::from_degrees(a.unwrap_to_degrees(exec_state, args.source_range))),
690                        )
691                        .extrude_method(extrude_method)
692                        .body_type(body_type)
693                        .maybe_merge_coplanar_faces(hide_seams)
694                        .maybe_direction(direction3d)
695                        .maybe_direction_reference(direction_reference)
696                        .build(),
697                )
698            }
699            (None, None, None, None, Some(to), None) => match to {
700                Point3dAxis3dOrGeometryReference::Point(point) => ModelingCmd::from(
701                    mcmd::ExtrudeToReference::builder()
702                        .target(concrete_target()?.into())
703                        .reference(ExtrudeReference::Point {
704                            point: KPoint3d {
705                                x: LengthUnit(point[0].unwrap_to_mm()),
706                                y: LengthUnit(point[1].unwrap_to_mm()),
707                                z: LengthUnit(point[2].unwrap_to_mm()),
708                            },
709                        })
710                        .extrude_method(extrude_method)
711                        .body_type(body_type)
712                        .build(),
713                ),
714                Point3dAxis3dOrGeometryReference::Axis { direction, origin } => ModelingCmd::from(
715                    mcmd::ExtrudeToReference::builder()
716                        .target(concrete_target()?.into())
717                        .reference(ExtrudeReference::Axis {
718                            axis: KPoint3d {
719                                x: direction[0].unwrap_to_mm(),
720                                y: direction[1].unwrap_to_mm(),
721                                z: direction[2].unwrap_to_mm(),
722                            },
723                            point: KPoint3d {
724                                x: LengthUnit(origin[0].unwrap_to_mm()),
725                                y: LengthUnit(origin[1].unwrap_to_mm()),
726                                z: LengthUnit(origin[2].unwrap_to_mm()),
727                            },
728                        })
729                        .extrude_method(extrude_method)
730                        .body_type(body_type)
731                        .build(),
732                ),
733                Point3dAxis3dOrGeometryReference::Plane(plane) => {
734                    let plane_id = if plane.is_uninitialized() {
735                        if plane.info.origin.units.is_none() {
736                            return Err(KclError::new_semantic(KclErrorDetails::new(
737                                "Origin of plane has unknown units".to_string(),
738                                vec![args.source_range],
739                            )));
740                        }
741                        let sketch_plane = crate::std::sketch::make_sketch_plane_from_orientation(
742                            plane.clone().info.into_plane_data(),
743                            exec_state,
744                            &args,
745                        )
746                        .await?;
747                        sketch_plane.id
748                    } else {
749                        plane.id
750                    };
751                    ModelingCmd::from(
752                        mcmd::ExtrudeToReference::builder()
753                            .target(concrete_target()?.into())
754                            .reference(ExtrudeReference::EntityReference {
755                                entity_id: Some(plane_id),
756                                entity_reference: None,
757                            })
758                            .extrude_method(extrude_method)
759                            .body_type(body_type)
760                            .build(),
761                    )
762                }
763                Point3dAxis3dOrGeometryReference::Edge(edge_ref) => {
764                    let edge_id = edge_ref.get_engine_id(exec_state, &args)?;
765                    ModelingCmd::from(
766                        mcmd::ExtrudeToReference::builder()
767                            .target(concrete_target()?.into())
768                            .reference(ExtrudeReference::EntityReference {
769                                entity_id: Some(edge_id),
770                                entity_reference: None,
771                            })
772                            .extrude_method(extrude_method)
773                            .body_type(body_type)
774                            .build(),
775                    )
776                }
777                Point3dAxis3dOrGeometryReference::Face(face_tag) => {
778                    let face_id = face_tag.get_face_id_from_tag(exec_state, &args, false).await?;
779                    ModelingCmd::from(
780                        mcmd::ExtrudeToReference::builder()
781                            .target(concrete_target()?.into())
782                            .reference(ExtrudeReference::EntityReference {
783                                entity_id: Some(face_id),
784                                entity_reference: None,
785                            })
786                            .extrude_method(extrude_method)
787                            .body_type(body_type)
788                            .build(),
789                    )
790                }
791                Point3dAxis3dOrGeometryReference::Sketch(sketch_ref) => ModelingCmd::from(
792                    mcmd::ExtrudeToReference::builder()
793                        .target(concrete_target()?.into())
794                        .reference(ExtrudeReference::EntityReference {
795                            entity_id: Some(sketch_ref.id),
796                            entity_reference: None,
797                        })
798                        .extrude_method(extrude_method)
799                        .body_type(body_type)
800                        .build(),
801                ),
802                Point3dAxis3dOrGeometryReference::Solid(solid) => ModelingCmd::from(
803                    mcmd::ExtrudeToReference::builder()
804                        .target(concrete_target()?.into())
805                        .reference(ExtrudeReference::EntityReference {
806                            entity_id: Some(solid.id),
807                            entity_reference: None,
808                        })
809                        .extrude_method(extrude_method)
810                        .body_type(body_type)
811                        .build(),
812                ),
813                Point3dAxis3dOrGeometryReference::TaggedEdgeOrFace(tag) => {
814                    let tagged_edge_or_face = args.get_tag_engine_info(exec_state, tag)?;
815                    let tagged_edge_or_face_id = tagged_edge_or_face.id;
816                    ModelingCmd::from(
817                        mcmd::ExtrudeToReference::builder()
818                            .target(concrete_target()?.into())
819                            .reference(ExtrudeReference::EntityReference {
820                                entity_id: Some(tagged_edge_or_face_id),
821                                entity_reference: None,
822                            })
823                            .extrude_method(extrude_method)
824                            .body_type(body_type)
825                            .build(),
826                    )
827                }
828                Point3dAxis3dOrGeometryReference::EdgeToReference(spec) => {
829                    let inner = edge::resolve_edge_specifier_with_face_tags(spec, None, exec_state, &args).await?;
830                    ModelingCmd::from(
831                        mcmd::ExtrudeToReference::builder()
832                            .target(concrete_target()?.into())
833                            .reference(ExtrudeReference::EntityReference {
834                                entity_id: None,
835                                entity_reference: Some(EntityReference::Edge {
836                                    inner,
837                                    topology_fallback: None,
838                                }),
839                            })
840                            .extrude_method(extrude_method)
841                            .body_type(body_type)
842                            .build(),
843                    )
844                }
845            },
846            (Some(_), _, _, None, None, None) => {
847                return Err(KclError::new_semantic(KclErrorDetails::new(
848                    "The `length` parameter must be provided when using twist angle for extrusion.".to_owned(),
849                    vec![args.source_range],
850                )));
851            }
852            (_, _, _, None, None, None) => {
853                return Err(KclError::new_semantic(KclErrorDetails::new(
854                    "Either `length` or `to` parameter must be provided for extrusion.".to_owned(),
855                    vec![args.source_range],
856                )));
857            }
858            (_, _, _, Some(_), Some(_), None) => {
859                return Err(KclError::new_semantic(KclErrorDetails::new(
860                    "You cannot give both `length` and `to` params, you have to choose one or the other".to_owned(),
861                    vec![args.source_range],
862                )));
863            }
864            (_, _, _, _, _, _) => {
865                return Err(KclError::new_semantic(KclErrorDetails::new(
866                    "Invalid combination of parameters for extrusion.".to_owned(),
867                    vec![args.source_range],
868                )));
869            }
870        };
871
872        let being_extruded = match extrudable {
873            Extrudable::Sketch(..) if extruding_sketch_segments => BeingExtruded::SketchSegments,
874            Extrudable::Sketch(..) => BeingExtruded::Sketch,
875            Extrudable::FaceTag(face_tag) => {
876                let face_id = concrete_target()?;
877                let solid_id = match face_tag.geometry() {
878                    Some(crate::execution::Geometry::Solid(solid)) => solid.id,
879                    Some(crate::execution::Geometry::Sketch(sketch)) => match sketch.on {
880                        SketchSurface::Face(face) => face.parent_solid.solid_id,
881                        SketchSurface::Plane(_) => sketch.id,
882                    },
883                    None => face_id,
884                };
885                BeingExtruded::Face { face_id, solid_id }
886            }
887            Extrudable::Face(face) => BeingExtruded::Face {
888                face_id: face.id,
889                solid_id: face.parent_solid.solid_id,
890            },
891            Extrudable::EdgeTag(_) => BeingExtruded::Edge,
892            Extrudable::Edge(_) => BeingExtruded::Edge,
893            Extrudable::EdgeSpecifier(_) => BeingExtruded::Edge,
894        };
895        if let Some(post_extr_sketch) = extrudable.as_sketch() {
896            let cmds = post_extr_sketch.build_sketch_mode_cmds(
897                exec_state,
898                ModelingCmdReq {
899                    cmd_id: extrude_cmd_id.into(),
900                    cmd,
901                },
902            );
903            exec_state
904                .batch_modeling_cmds(ModelingCmdMeta::from_args_id(exec_state, &args, extrude_cmd_id), &cmds)
905                .await?;
906            solids.push(
907                do_post_extrude(
908                    &post_extr_sketch,
909                    extrude_cmd_id.into(),
910                    false,
911                    &NamedCapTags {
912                        start: tag_start.as_ref(),
913                        end: tag_end.as_ref(),
914                    },
915                    extrude_method,
916                    exec_state,
917                    &args,
918                    None,
919                    None,
920                    body_type,
921                    being_extruded,
922                )
923                .await?,
924            );
925        } else if is_edge {
926            // Ensure that edges do not use the MERGE method.
927            match extrude_method {
928                ExtrudeMethod::New => {
929                    // This is expected.
930                }
931                ExtrudeMethod::Merge => {
932                    return Err(KclError::new_semantic(KclErrorDetails::new(
933                        "Cannot use method MERGE with surface extrude of an edge".to_owned(),
934                        vec![args.source_range],
935                    )));
936                }
937                _ => {
938                    return Err(KclError::new_internal(KclErrorDetails::new(
939                        format!("Unknown extrude method: {extrude_method:?}"),
940                        vec![args.source_range],
941                    )));
942                }
943            }
944
945            // Surface-extrude an edge.
946            exec_state
947                .batch_modeling_cmd(ModelingCmdMeta::from_args_id(exec_state, &args, extrude_cmd_id), cmd)
948                .await?;
949            // Extract the edge tag.
950            let edge_tag = match extrudable {
951                Extrudable::Sketch(_) => None,
952                Extrudable::FaceTag(_) => None,
953                Extrudable::Face(_) => None,
954                Extrudable::EdgeTag(tag) => Some(TagDeclarator::new(&tag.value)),
955                Extrudable::Edge(_) => None,
956                Extrudable::EdgeSpecifier(_) => None,
957            };
958            solids.push(after_surface_creation(extrude_cmd_id.into(), edge_tag, exec_state, &args).await?);
959        } else {
960            return Err(KclError::new_type(KclErrorDetails::new(
961                "Expected a sketch for extrusion".to_owned(),
962                vec![args.source_range],
963            )));
964        }
965    }
966
967    Ok(solids)
968}
969
970#[derive(Debug, Default)]
971pub(crate) struct NamedCapTags<'a> {
972    pub start: Option<&'a TagNode>,
973    pub end: Option<&'a TagNode>,
974}
975
976#[derive(Debug, Clone, Copy)]
977pub enum BeingExtruded {
978    Sketch,
979    SketchSegments,
980    Face { face_id: Uuid, solid_id: Uuid },
981    Edge,
982}
983
984/// Which edge should we use for querying Solid3dGetExtrusionInfo and GetAdjacencyInfo?
985/// It can be any edge of the body, but if our body is a clone, we should use an edge of
986/// the original body, not the new cloned body.
987fn get_extrusion_info_edge_id(
988    sketch: &Sketch,
989    any_edge_id: Uuid,
990    clone_id_map: Option<&HashMap<Uuid, Uuid>>,
991) -> Option<Uuid> {
992    // If this isn't a clone, there's no old/new body distinction.
993    // So just use the edge.
994    if sketch.clone.is_none() {
995        return Some(any_edge_id);
996    }
997    let Some(clone_map) = clone_id_map else {
998        return Some(any_edge_id);
999    };
1000
1001    // clone_map maps old IDs -> new IDs.
1002    // If the `any_edge_id` is an ID of the OLD body
1003    // (we know this if it's a _key_ of the map)
1004    // we should use it (because that's the old body we're querying).
1005    if clone_map.contains_key(&any_edge_id) {
1006        return Some(any_edge_id);
1007    }
1008
1009    // Otherwise, if the `any_edge_id` is an ID of the NEW body
1010    // (we know this if it's a _value_ of the map),
1011    // we should query the corresponding ID in the OLD body.
1012    // i.e. if it's a hashmap value, find the corresponding key.
1013    if let Some((old_edge_id, _)) = clone_map.iter().find(|(_, new_edge_id)| **new_edge_id == any_edge_id) {
1014        return Some(*old_edge_id);
1015    }
1016
1017    // Fall back to this if the clone_map doesn't have the data we expect.
1018    // Engine will intuit an edge for the relevant calls, but it may mean the clone map was built wrong,
1019    // or KCL and the engine disagree about what geometry exists.
1020    None
1021}
1022
1023/// This is similar to [`do_post_extrude()`], but for surfaces where a sketch
1024/// isn't available.
1025pub(crate) async fn after_surface_creation(
1026    extrude_cmd_id: ArtifactId,
1027    edge_tag: Option<crate::parsing::ast::types::Node<TagDeclarator>>,
1028    exec_state: &mut ExecState,
1029    args: &Args,
1030) -> Result<Solid, KclError> {
1031    let body_id = extrude_cmd_id.into();
1032
1033    // Bring the object to the front of the scene.
1034    // See: https://github.com/KittyCAD/modeling-app/issues/806
1035
1036    exec_state
1037        .batch_modeling_cmd(
1038            ModelingCmdMeta::from_args(exec_state, args),
1039            ModelingCmd::from(mcmd::ObjectBringToFront::builder().object_id(body_id).build()),
1040        )
1041        .await?;
1042
1043    let (face_id, edge_id) = if args.ctx.no_engine_commands().await {
1044        (exec_state.next_uuid(), exec_state.next_uuid())
1045    } else {
1046        // Get the body entity ids.
1047        let response = exec_state
1048            .send_modeling_cmd(
1049                ModelingCmdMeta::from_args(exec_state, args),
1050                ModelingCmd::from(mcmd::EntityGetAllChildUuids::builder().entity_id(body_id).build()),
1051            )
1052            .await?;
1053        let OkWebSocketResponseData::Modeling {
1054            modeling_response: OkModelingCmdResponse::EntityGetAllChildUuids(ref all_child_ids_resp),
1055        } = response
1056        else {
1057            return Err(KclError::new_engine(KclErrorDetails::new(
1058                format!("EntityGetAllChildUuids response was not as expected: {response:?}"),
1059                vec![args.source_range],
1060            )));
1061        };
1062        let entity_ids = &all_child_ids_resp.entity_ids;
1063        let Some(face_id) = entity_ids.first().copied() else {
1064            return Err(KclError::new_internal(KclErrorDetails::new(
1065                format!("Expected EntityGetAllChildUuids response to have at least 1 ID: {response:?}",),
1066                vec![args.source_range],
1067            )));
1068        };
1069        let Some(edge_id) = entity_ids.get(1).copied() else {
1070            return Err(KclError::new_internal(KclErrorDetails::new(
1071                format!("Expected EntityGetAllChildUuids response to have at least 2 IDs: {response:?}"),
1072                vec![args.source_range],
1073            )));
1074        };
1075        (face_id, edge_id)
1076    };
1077
1078    // TODO: Do we need to use ExtrudeArc?
1079    let extrude_surface = ExtrudeSurface::ExtrudePlane(ExtrudePlane {
1080        face_id,
1081        tag: edge_tag,
1082        geo_meta: GeoMeta {
1083            id: face_id,
1084            metadata: args.source_range.into(),
1085        },
1086    });
1087    let new_value = vec![extrude_surface];
1088
1089    Ok(Solid {
1090        id: body_id,
1091        value_id: body_id,
1092        topology_id: body_id,
1093        tag_update_source_artifact_id: None,
1094        pattern_source_artifact_id: None,
1095        best_guess_body_type: Some(BodyType::Surface),
1096        artifact_id: extrude_cmd_id,
1097        value: new_value,
1098        faces: Default::default(),
1099        meta: vec![args.source_range.into()],
1100        // Normally, we would propagate the units of the sketch. But an edge
1101        // doesn't have units. We also don't seem to use this field anywhere.
1102        units: kcl_api::UnitLength::Millimeters,
1103        sectional: false,
1104        creator: SolidCreator::Edge(CreatorEdge { edge_id, body_id }),
1105        start_cap_id: None,
1106        end_cap_id: None,
1107        edge_cuts: Vec::new(),
1108        pending_edge_cut_ids: Vec::new(),
1109    })
1110}
1111
1112#[allow(clippy::too_many_arguments)]
1113pub(crate) async fn do_post_extrude<'a>(
1114    sketch: &Sketch,
1115    extrude_cmd_id: ArtifactId,
1116    sectional: bool,
1117    named_cap_tags: &'a NamedCapTags<'a>,
1118    extrude_method: ExtrudeMethod,
1119    exec_state: &mut ExecState,
1120    args: &Args,
1121    edge_id: Option<Uuid>,
1122    clone_id_map: Option<&HashMap<Uuid, Uuid>>, // old sketch id -> new sketch id
1123    body_type: BodyType,
1124    being_extruded: BeingExtruded,
1125) -> Result<Solid, KclError> {
1126    // Bring the object to the front of the scene.
1127    // See: https://github.com/KittyCAD/modeling-app/issues/806
1128
1129    exec_state
1130        .batch_modeling_cmd(
1131            ModelingCmdMeta::from_args(exec_state, args),
1132            ModelingCmd::from(mcmd::ObjectBringToFront::builder().object_id(sketch.id).build()),
1133        )
1134        .await?;
1135
1136    let any_edge_id = if let Some(edge_id) = sketch.mirror {
1137        edge_id
1138    } else if let Some(id) = edge_id {
1139        id
1140    } else {
1141        // The "get extrusion face info" API call requires *any* edge on the sketch being extruded.
1142        // So, let's just use the first one.
1143        let Some(any_edge_id) = sketch.paths.front().map(|edge| edge.get_base().geo_meta.id) else {
1144            return Err(KclError::new_type(KclErrorDetails::new(
1145                "Expected a non-empty sketch".to_owned(),
1146                vec![args.source_range],
1147            )));
1148        };
1149        any_edge_id
1150    };
1151
1152    // If the sketch is a clone, we will use the original info to get the extrusion face info.
1153    // So let's find an edge of the old body.
1154    let extrusion_info_edge_id = get_extrusion_info_edge_id(sketch, any_edge_id, clone_id_map);
1155
1156    let mut sketch = sketch.clone();
1157    match body_type {
1158        BodyType::Solid => {
1159            sketch.is_closed = ProfileClosed::Explicitly;
1160        }
1161        BodyType::Surface => {}
1162        _other => {
1163            // At some point in the future we'll add sheet metal or something.
1164            // Figure this out then.
1165        }
1166    }
1167
1168    match (extrude_method, being_extruded) {
1169        (ExtrudeMethod::Merge, BeingExtruded::Face { .. }) => {
1170            // Merge the IDs.
1171            // If we were sketching on a face, we need the original face id.
1172            if let SketchSurface::Face(ref face) = sketch.on {
1173                // If we're merging into an existing body, then assign the existing body's ID,
1174                // because the variable binding for this solid won't be its own object, it's just modifying the original one.
1175                sketch.id = face.parent_solid.sketch_or_solid_id();
1176            }
1177        }
1178        (ExtrudeMethod::New, BeingExtruded::Face { .. }) => {
1179            // We're creating a new solid, it's not based on any existing sketch (it's based on a face).
1180            // So we need a new ID, the extrude command ID.
1181            sketch.id = extrude_cmd_id.into();
1182        }
1183        (ExtrudeMethod::New, BeingExtruded::Sketch | BeingExtruded::SketchSegments) => {
1184            // If we are creating a new body we need to preserve its new id.
1185            // The sketch's ID is already correct here, it should be the ID of the sketch.
1186        }
1187        (ExtrudeMethod::Merge, BeingExtruded::Sketch | BeingExtruded::SketchSegments) => {
1188            if let SketchSurface::Face(ref face) = sketch.on {
1189                // If we're merging into an existing body, then assign the existing body's ID,
1190                // because the variable binding for this solid won't be its own object, it's just modifying the original one.
1191                sketch.id = face.parent_solid.sketch_or_solid_id();
1192            }
1193        }
1194        (other, _) => {
1195            // If you ever hit this, you should add a new arm to the match expression, and implement support for the new ExtrudeMethod variant.
1196            return Err(KclError::new_internal(KclErrorDetails::new(
1197                format!("Zoo does not yet support creating bodies via {other:?}"),
1198                vec![args.source_range],
1199            )));
1200        }
1201    }
1202
1203    // Similarly, if the sketch is a clone, we need to use the original sketch id to get the extrusion face info.
1204    let sketch_id = if let Some(cloned_from) = sketch.clone
1205        && clone_id_map.is_some()
1206    {
1207        cloned_from
1208    } else {
1209        sketch.id
1210    };
1211
1212    let solid3d_info = exec_state
1213        .send_modeling_cmd(
1214            ModelingCmdMeta::from_args(exec_state, args),
1215            ModelingCmd::from(
1216                mcmd::Solid3dGetExtrusionFaceInfo::builder()
1217                    .maybe_edge_id(extrusion_info_edge_id)
1218                    .object_id(sketch_id)
1219                    .build(),
1220            ),
1221        )
1222        .await?;
1223
1224    let face_infos = if let OkWebSocketResponseData::Modeling {
1225        modeling_response: OkModelingCmdResponse::Solid3dGetExtrusionFaceInfo(data),
1226    } = solid3d_info
1227    {
1228        data.faces
1229    } else {
1230        vec![]
1231    };
1232
1233    // Only do this if we need the artifact graph.
1234    if !args.ctx.settings.skip_artifact_graph {
1235        // Getting the ids of a sectional sweep does not work well and we cannot guarantee that
1236        // any of these call will not just fail.
1237        if !sectional {
1238            exec_state
1239                .batch_modeling_cmd(
1240                    ModelingCmdMeta::from_args(exec_state, args),
1241                    ModelingCmd::from(
1242                        mcmd::Solid3dGetAdjacencyInfo::builder()
1243                            .object_id(sketch_id)
1244                            .maybe_edge_id(extrusion_info_edge_id)
1245                            .build(),
1246                    ),
1247                )
1248                .await?;
1249        }
1250    }
1251
1252    let Faces {
1253        sides: mut face_id_map,
1254        mut start_cap_id,
1255        mut end_cap_id,
1256    } = analyze_faces(exec_state, args, face_infos).await;
1257
1258    // If this is a clone, we will use the clone_id_map to map the face info from the original sketch to the clone sketch.
1259    if sketch.clone.is_some()
1260        && let Some(clone_id_map) = clone_id_map
1261    {
1262        face_id_map = face_id_map
1263            .into_iter()
1264            .filter_map(|(k, v)| {
1265                let fe_key = clone_id_map.get(&k)?;
1266                let fe_value = clone_id_map.get(&(v?)).copied();
1267                Some((*fe_key, fe_value))
1268            })
1269            .collect::<HashMap<Uuid, Option<Uuid>>>();
1270        // The face info above was queried using the original solid's id, so
1271        // the cap ids belong to the original. Map them to the clone's ids.
1272        start_cap_id = start_cap_id.and_then(|id| clone_id_map.get(&id).copied());
1273        end_cap_id = end_cap_id.and_then(|id| clone_id_map.get(&id).copied());
1274    }
1275
1276    // Iterate over the sketch.value array and add face_id to GeoMeta
1277    let no_engine_commands = args.ctx.no_engine_commands().await;
1278    let mut new_value: Vec<ExtrudeSurface> = Vec::with_capacity(sketch.paths.len() + sketch.inner_paths.len() + 2);
1279    let outer_surfaces = sketch.paths.iter().flat_map(|path| {
1280        if let Some(Some(actual_face_id)) = face_id_map.get(&path.get_base().geo_meta.id) {
1281            surface_of(path, *actual_face_id)
1282        } else if no_engine_commands {
1283            crate::log::logln!(
1284                "No face ID found for path ID {:?}, but in no-engine-commands mode, so faking it",
1285                path.get_base().geo_meta.id
1286            );
1287            // Only pre-populate the extrude surface if we are in mock mode.
1288            fake_extrude_surface(exec_state, path)
1289        } else if sketch.clone.is_some()
1290            && let Some(clone_map) = clone_id_map
1291        {
1292            let new_path = clone_map.get(&(path.get_base().geo_meta.id));
1293
1294            if let Some(new_path) = new_path {
1295                match face_id_map.get(new_path) {
1296                    Some(Some(actual_face_id)) => clone_surface_of(path, *new_path, *actual_face_id),
1297                    _ => {
1298                        let actual_face_id = face_id_map.iter().find_map(|(key, value)| {
1299                            if let Some(value) = value {
1300                                if value == new_path { Some(key) } else { None }
1301                            } else {
1302                                None
1303                            }
1304                        });
1305                        match actual_face_id {
1306                            Some(actual_face_id) => clone_surface_of(path, *new_path, *actual_face_id),
1307                            None => {
1308                                crate::log::logln!("No face ID found for clone path ID {:?}, so skipping it", new_path);
1309                                None
1310                            }
1311                        }
1312                    }
1313                }
1314            } else {
1315                None
1316            }
1317        } else {
1318            crate::log::logln!(
1319                "No face ID found for path ID {:?}, and not in no-engine-commands mode, so skipping it",
1320                path.get_base().geo_meta.id
1321            );
1322            None
1323        }
1324    });
1325
1326    new_value.extend(outer_surfaces);
1327    let inner_surfaces = sketch.inner_paths.iter().flat_map(|path| {
1328        if let Some(Some(actual_face_id)) = face_id_map.get(&path.get_base().geo_meta.id) {
1329            surface_of(path, *actual_face_id)
1330        } else if no_engine_commands {
1331            // Only pre-populate the extrude surface if we are in mock mode.
1332            fake_extrude_surface(exec_state, path)
1333        } else {
1334            None
1335        }
1336    });
1337    new_value.extend(inner_surfaces);
1338
1339    // Add the tags for the start or end caps. A CSG can split or remove a
1340    // canonical cap before a body is cloned or mirrored, so reconstruction
1341    // cannot preserve that tag as one cap when the engine no longer reports it.
1342    if let Some(tag_start) = named_cap_tags.start {
1343        if let Some(start_cap_id) = start_cap_id {
1344            new_value.push(ExtrudeSurface::ExtrudePlane(crate::execution::ExtrudePlane {
1345                face_id: start_cap_id,
1346                tag: Some(tag_start.clone()),
1347                geo_meta: GeoMeta {
1348                    id: start_cap_id,
1349                    metadata: args.source_range.into(),
1350                },
1351            }));
1352        } else if clone_id_map.is_none() {
1353            return Err(KclError::new_type(KclErrorDetails::new(
1354                format!(
1355                    "Expected a start cap ID for tag `{}` for extrusion of sketch {:?}",
1356                    tag_start.name, sketch.id
1357                ),
1358                vec![args.source_range],
1359            )));
1360        }
1361    }
1362    if let Some(tag_end) = named_cap_tags.end {
1363        if let Some(end_cap_id) = end_cap_id {
1364            new_value.push(ExtrudeSurface::ExtrudePlane(crate::execution::ExtrudePlane {
1365                face_id: end_cap_id,
1366                tag: Some(tag_end.clone()),
1367                geo_meta: GeoMeta {
1368                    id: end_cap_id,
1369                    metadata: args.source_range.into(),
1370                },
1371            }));
1372        } else if clone_id_map.is_none() {
1373            return Err(KclError::new_type(KclErrorDetails::new(
1374                format!(
1375                    "Expected an end cap ID for tag `{}` for extrusion of sketch {:?}",
1376                    tag_end.name, sketch.id
1377                ),
1378                vec![args.source_range],
1379            )));
1380        }
1381    }
1382
1383    let meta = sketch.meta.clone();
1384    let units = sketch.units;
1385    let id = sketch.id;
1386    let topology_id = sketch.original_id;
1387    let creator = match being_extruded {
1388        BeingExtruded::Sketch | BeingExtruded::SketchSegments => SolidCreator::Sketch(sketch),
1389        BeingExtruded::Face { face_id, solid_id } => SolidCreator::Face(CreatorFace {
1390            face_id,
1391            solid_id,
1392            sketch,
1393        }),
1394        BeingExtruded::Edge => {
1395            let message = "Expected an edge to have been extruded via another code path";
1396            debug_assert!(false, "{message}");
1397            return Err(KclError::new_internal(KclErrorDetails::new(
1398                message.to_owned(),
1399                vec![args.source_range],
1400            )));
1401        }
1402    };
1403
1404    let mut solid = Solid {
1405        id,
1406        value_id: extrude_cmd_id.into(),
1407        topology_id,
1408        tag_update_source_artifact_id: None,
1409        pattern_source_artifact_id: None,
1410        best_guess_body_type: Some(body_type),
1411        artifact_id: extrude_cmd_id,
1412        value: new_value,
1413        faces: Default::default(),
1414        meta,
1415        units,
1416        sectional,
1417        creator,
1418        start_cap_id,
1419        end_cap_id,
1420        edge_cuts: vec![],
1421        pending_edge_cut_ids: vec![],
1422    };
1423
1424    if matches!(being_extruded, BeingExtruded::SketchSegments) {
1425        let geometry = Geometry::Solid(solid.clone());
1426        for surface in &solid.value {
1427            let Some(tag) = surface.get_tag() else {
1428                continue;
1429            };
1430            solid.faces.insert(
1431                tag.name.clone(),
1432                TagIdentifier {
1433                    value: tag.name.clone(),
1434                    info: vec![(
1435                        exec_state.stack().current_epoch(),
1436                        TagEngineInfo {
1437                            id: surface.get_id(),
1438                            surface: Some(surface.clone()),
1439                            path: None,
1440                            geometry: geometry.clone(),
1441                        },
1442                    )],
1443                    meta: vec![Metadata {
1444                        source_range: tag.clone().into(),
1445                    }],
1446                },
1447            );
1448        }
1449    }
1450
1451    Ok(solid)
1452}
1453
1454#[derive(Debug, Default)]
1455struct Faces {
1456    /// Maps curve ID to face ID for each side.
1457    sides: HashMap<Uuid, Option<Uuid>>,
1458    /// Top face ID.
1459    end_cap_id: Option<Uuid>,
1460    /// Bottom face ID.
1461    start_cap_id: Option<Uuid>,
1462}
1463
1464async fn analyze_faces(exec_state: &mut ExecState, args: &Args, face_infos: Vec<ExtrusionFaceInfo>) -> Faces {
1465    let mut faces = Faces {
1466        sides: HashMap::with_capacity(face_infos.len()),
1467        ..Default::default()
1468    };
1469    if args.ctx.no_engine_commands().await {
1470        // Create fake IDs for start and end caps, to make extrudes mock-execute safe
1471        faces.start_cap_id = Some(exec_state.next_uuid());
1472        faces.end_cap_id = Some(exec_state.next_uuid());
1473    }
1474    for face_info in face_infos {
1475        match face_info.cap {
1476            ExtrusionFaceCapType::Bottom => faces.start_cap_id = face_info.face_id,
1477            ExtrusionFaceCapType::Top => faces.end_cap_id = face_info.face_id,
1478            ExtrusionFaceCapType::Both => {
1479                faces.end_cap_id = face_info.face_id;
1480                faces.start_cap_id = face_info.face_id;
1481            }
1482            ExtrusionFaceCapType::None => {
1483                if let Some(curve_id) = face_info.curve_id {
1484                    faces.sides.insert(curve_id, face_info.face_id);
1485                }
1486            }
1487            other => {
1488                exec_state.warn(
1489                    crate::CompilationIssue {
1490                        source_range: args.source_range,
1491                        message: format!("unknown extrusion face type {other:?}"),
1492                        suggestion: None,
1493                        severity: crate::errors::Severity::Warning,
1494                        tag: crate::errors::Tag::Unnecessary,
1495                    },
1496                    annotations::WARN_NOT_YET_SUPPORTED,
1497                );
1498            }
1499        }
1500    }
1501    faces
1502}
1503fn surface_of(path: &Path, actual_face_id: Uuid) -> Option<ExtrudeSurface> {
1504    match path {
1505        Path::Arc { .. }
1506        | Path::TangentialArc { .. }
1507        | Path::TangentialArcTo { .. }
1508        // TODO: (bc) fix me
1509        | Path::Ellipse { .. }
1510        | Path::Conic {.. }
1511        | Path::Circle { .. }
1512        | Path::CircleThreePoint { .. } => {
1513            let extrude_surface = ExtrudeSurface::ExtrudeArc(crate::execution::ExtrudeArc {
1514                face_id: actual_face_id,
1515                tag: path.get_base().tag.clone(),
1516                geo_meta: GeoMeta {
1517                    id: path.get_base().geo_meta.id,
1518                    metadata: path.get_base().geo_meta.metadata,
1519                },
1520            });
1521            Some(extrude_surface)
1522        }
1523        Path::Base { .. } | Path::ToPoint { .. } | Path::Horizontal { .. } | Path::AngledLineTo { .. } | Path::Bezier { .. } => {
1524            let extrude_surface = ExtrudeSurface::ExtrudePlane(crate::execution::ExtrudePlane {
1525                face_id: actual_face_id,
1526                tag: path.get_base().tag.clone(),
1527                geo_meta: GeoMeta {
1528                    id: path.get_base().geo_meta.id,
1529                    metadata: path.get_base().geo_meta.metadata,
1530                },
1531            });
1532            Some(extrude_surface)
1533        }
1534        Path::ArcThreePoint { .. } => {
1535            let extrude_surface = ExtrudeSurface::ExtrudeArc(crate::execution::ExtrudeArc {
1536                face_id: actual_face_id,
1537                tag: path.get_base().tag.clone(),
1538                geo_meta: GeoMeta {
1539                    id: path.get_base().geo_meta.id,
1540                    metadata: path.get_base().geo_meta.metadata,
1541                },
1542            });
1543            Some(extrude_surface)
1544        }
1545    }
1546}
1547
1548fn clone_surface_of(path: &Path, clone_path_id: Uuid, actual_face_id: Uuid) -> Option<ExtrudeSurface> {
1549    match path {
1550        Path::Arc { .. }
1551        | Path::TangentialArc { .. }
1552        | Path::TangentialArcTo { .. }
1553        // TODO: (gserena) fix me
1554        | Path::Ellipse { .. }
1555        | Path::Conic {.. }
1556        | Path::Circle { .. }
1557        | Path::CircleThreePoint { .. } => {
1558            let extrude_surface = ExtrudeSurface::ExtrudeArc(crate::execution::ExtrudeArc {
1559                face_id: actual_face_id,
1560                tag: path.get_base().tag.clone(),
1561                geo_meta: GeoMeta {
1562                    id: clone_path_id,
1563                    metadata: path.get_base().geo_meta.metadata,
1564                },
1565            });
1566            Some(extrude_surface)
1567        }
1568        Path::Base { .. } | Path::ToPoint { .. } | Path::Horizontal { .. } | Path::AngledLineTo { .. } | Path::Bezier { .. } => {
1569            let extrude_surface = ExtrudeSurface::ExtrudePlane(crate::execution::ExtrudePlane {
1570                face_id: actual_face_id,
1571                tag: path.get_base().tag.clone(),
1572                geo_meta: GeoMeta {
1573                    id: clone_path_id,
1574                    metadata: path.get_base().geo_meta.metadata,
1575                },
1576            });
1577            Some(extrude_surface)
1578        }
1579        Path::ArcThreePoint { .. } => {
1580            let extrude_surface = ExtrudeSurface::ExtrudeArc(crate::execution::ExtrudeArc {
1581                face_id: actual_face_id,
1582                tag: path.get_base().tag.clone(),
1583                geo_meta: GeoMeta {
1584                    id: clone_path_id,
1585                    metadata: path.get_base().geo_meta.metadata,
1586                },
1587            });
1588            Some(extrude_surface)
1589        }
1590    }
1591}
1592
1593/// Create a fake extrude surface to report for mock execution, when there's no engine response.
1594fn fake_extrude_surface(exec_state: &mut ExecState, path: &Path) -> Option<ExtrudeSurface> {
1595    let extrude_surface = ExtrudeSurface::ExtrudePlane(crate::execution::ExtrudePlane {
1596        // pushing this values with a fake face_id to make extrudes mock-execute safe
1597        face_id: exec_state.next_uuid(),
1598        tag: path.get_base().tag.clone(),
1599        geo_meta: GeoMeta {
1600            id: path.get_base().geo_meta.id,
1601            metadata: path.get_base().geo_meta.metadata,
1602        },
1603    });
1604    Some(extrude_surface)
1605}
1606
1607#[cfg(test)]
1608mod tests {
1609    use kcl_api::Expr;
1610    use kcl_api::Number;
1611    use kcl_api::ObjectId;
1612    use kcl_api::Point2d;
1613    use kcl_api::PointCtor;
1614    use kcl_api::UnitLength;
1615
1616    use super::*;
1617    use crate::execution::AbstractSegment;
1618    use crate::execution::Plane;
1619    use crate::execution::SegmentRepr;
1620    use crate::execution::parse_execute;
1621    use crate::execution::types::NumericType;
1622    use crate::execution::types::NumericTypeExt;
1623    use crate::std::sketch::PlaneData;
1624
1625    fn point_expr(x: f64, y: f64) -> Point2d<Expr> {
1626        Point2d {
1627            x: Expr::Var(Number::from((x, UnitLength::Millimeters))),
1628            y: Expr::Var(Number::from((y, UnitLength::Millimeters))),
1629        }
1630    }
1631
1632    fn segment_value(exec_state: &mut ExecState) -> KclValue {
1633        let plane = Plane::from_plane_data_skipping_engine(PlaneData::XY, exec_state).unwrap();
1634        let segment = Segment {
1635            id: exec_state.next_uuid(),
1636            object_id: ObjectId(1),
1637            kind: SegmentKind::Point {
1638                position: [TyF64::new(0.0, NumericType::mm()), TyF64::new(0.0, NumericType::mm())],
1639                ctor: Box::new(PointCtor {
1640                    position: point_expr(0.0, 0.0),
1641                }),
1642                freedom: None,
1643            },
1644            surface: SketchSurface::Plane(Box::new(plane)),
1645            sketch_id: exec_state.next_uuid(),
1646            sketch: None,
1647            tag: None,
1648            node_path: None,
1649            meta: vec![],
1650        };
1651        KclValue::Segment {
1652            value: Box::new(AbstractSegment {
1653                repr: SegmentRepr::Solved {
1654                    segment: Box::new(segment),
1655                },
1656                meta: vec![],
1657            }),
1658        }
1659    }
1660
1661    #[tokio::test(flavor = "multi_thread")]
1662    async fn extrude_accepts_negative_bidirectional_length_in_mock_exec() {
1663        let code = r#"
1664profile001 = startSketchOn(XY)
1665  |> startProfile(at = [0, 0])
1666  |> line(end = [1, 0])
1667  |> line(end = [0, 1])
1668  |> close()
1669
1670extrude(profile001, length = 1, bidirectionalLength = -1)
1671"#;
1672
1673        let result = parse_execute(code).await.unwrap();
1674        let extrude = result
1675            .root_module_artifact_commands()
1676            .iter()
1677            .find_map(|artifact_command| match &artifact_command.command {
1678                ModelingCmd::Extrude(extrude) => Some(extrude),
1679                _ => None,
1680            })
1681            .expect("expected an extrude command");
1682
1683        assert_eq!(extrude.opposite, Opposite::Other(LengthUnit(-1.0)));
1684    }
1685
1686    fn extrude_direction_code(default_unit: &str, direction: &str) -> String {
1687        format!(
1688            r#"@settings(kclVersion = 2.0, defaultLengthUnit = {default_unit})
1689profile = sketch(on = XY) {{
1690  circle1 = circle(center = [10mm, 0mm], start = [11mm, 0mm])
1691}}
1692extrude(profile.circle1, length = 1, direction = {direction}, bodyType = SURFACE)"#
1693        )
1694    }
1695
1696    #[tokio::test(flavor = "multi_thread")]
1697    async fn extrude_converts_direction_to_the_file_units() {
1698        // https://github.com/KittyCAD/modeling-app/issues/14343
1699        // Each component is converted to the file's unit before the direction is sent to the engine.
1700        let cases = [
1701            ("mm", "[-1, 0, 1ft]", [-1.0, 0.0, 304.8]),
1702            ("mm", "[-1mm, 0mm, 304.8mm]", [-1.0, 0.0, 304.8]),
1703            ("mm", "[-1, 0, 1]", [-1.0, 0.0, 1.0]),
1704            ("in", "[-1mm, 0, 1ft]", [-1.0 / 25.4, 0.0, 12.0]),
1705            ("in", "[-1, 0, 1]", [-1.0, 0.0, 1.0]),
1706        ];
1707
1708        for (default_unit, direction, expected) in cases {
1709            let code = extrude_direction_code(default_unit, direction);
1710            let result = parse_execute(&code).await.unwrap();
1711            let direction = result
1712                .root_module_artifact_commands()
1713                .iter()
1714                .find_map(|artifact_command| match &artifact_command.command {
1715                    ModelingCmd::Extrude(extrude) => extrude.direction,
1716                    _ => None,
1717                })
1718                .expect("expected an extrude command with a direction");
1719            let DirectionType::Axis { direction } = direction else {
1720                panic!("expected an axis direction, got {direction:?} for:\n{code}");
1721            };
1722            let actual = [direction.x, direction.y, direction.z];
1723            assert!(
1724                actual.iter().zip(expected).all(|(a, e)| (a - e).abs() < 1e-9),
1725                "expected {expected:?}, got {actual:?} for:\n{code}"
1726            );
1727        }
1728    }
1729
1730    #[tokio::test(flavor = "multi_thread")]
1731    async fn extrude_rejects_direction_that_is_not_lengths() {
1732        for direction in ["[0, 0, 1deg]", "[0, 0, 1mm + 1deg]", "[0, 0, 1_]"] {
1733            let code = extrude_direction_code("mm", direction);
1734            let err = parse_execute(&code).await.unwrap_err();
1735            assert!(matches!(&err, KclError::Type { .. }), "{err:?} for:\n{code}");
1736            let message = err.message();
1737            assert!(
1738                message.contains("The components of `direction` must be lengths"),
1739                "{message} for:\n{code}"
1740            );
1741        }
1742    }
1743
1744    #[tokio::test(flavor = "multi_thread")]
1745    async fn explicit_merge_warns_for_plane_based_sketches() {
1746        let code = r#"
1747@settings(kclVersion = 2.0)
1748
1749profiles = sketch(on = XY) {
1750  circle1 = circle(start = [var 1mm, var 0mm], center = [var 0mm, var 0mm])
1751  circle2 = circle(start = [var 4mm, var 0mm], center = [var 3mm, var 0mm])
1752  circle3 = circle(start = [var 7mm, var 0mm], center = [var 6mm, var 0mm])
1753}
1754
1755leftRegion = region(segments = [profiles.circle1])
1756rightRegion = region(segments = [profiles.circle2])
1757extrude([leftRegion, rightRegion], length = 1mm, method = MERGE)
1758
1759defaultRegion = region(segments = [profiles.circle3])
1760extrude(defaultRegion, length = 1mm)
1761"#;
1762
1763        let result = parse_execute(code).await.unwrap();
1764        let warnings: Vec<_> = result
1765            .issues()
1766            .iter()
1767            .filter(|issue| issue.severity == crate::errors::Severity::Warning)
1768            .collect();
1769
1770        assert_eq!(warnings.len(), 1, "expected one warning, got {warnings:#?}");
1771        assert!(
1772            warnings[0].message.contains(
1773                "2 plane-based sketches have no parent body for `method = MERGE`, so each will create a separate body"
1774            ),
1775            "{}",
1776            warnings[0].message
1777        );
1778    }
1779
1780    #[tokio::test(flavor = "multi_thread")]
1781    async fn extrude_rejects_an_empty_target_array() {
1782        let err = parse_execute("nothing = extrude([], length = 5)").await.unwrap_err();
1783
1784        assert!(matches!(err, KclError::Argument { .. }), "{err:?}");
1785        assert!(err.message().contains("requires one or more"), "{err:?}");
1786    }
1787
1788    #[tokio::test(flavor = "multi_thread")]
1789    async fn edge_extrude_succeeds_in_mock_exec() {
1790        let code = r#"
1791@settings(kclVersion = 2.0)
1792
1793sketch001 = sketch(on = XZ) {
1794  line1 = line(start = [0mm, 0mm], end = [1mm, 0mm])
1795}
1796extrude001 = extrude(sketch001.line1, length = 5, bodyType = SURFACE)
1797extrude(
1798  getOppositeEdge(extrude001.sketch.tags.line1),
1799  length = 1,
1800  method = NEW,
1801  bodyType = SURFACE,
1802)
1803"#;
1804
1805        parse_execute(code).await.unwrap();
1806    }
1807
1808    #[tokio::test(flavor = "multi_thread")]
1809    async fn edge_specifier_target_cannot_be_extruded_to_a_reference() {
1810        let code = r#"
1811@settings(kclVersion = 2.0, experimentalFeatures = allow)
1812
1813profile = startSketchOn(XY)
1814  |> startProfile(at = [0, 0])
1815  |> line(end = [1, 0], tag = $sideFace)
1816  |> line(end = [0, 1])
1817  |> line(end = [-1, 0])
1818  |> close()
1819body = extrude(profile, length = 1, tagEnd = $endFace)
1820
1821extrude(
1822  { sideFaces = [sideFace, endFace] },
1823  to = offsetPlane(XY, offset = 10),
1824  bodyType = SURFACE,
1825  method = NEW,
1826)
1827"#;
1828
1829        let err = parse_execute(code).await.unwrap_err();
1830
1831        assert!(matches!(err, KclError::Semantic { .. }), "{err:?}");
1832        assert!(
1833            err.message()
1834                .contains("Edge specifiers cannot be extruded to a reference"),
1835            "{err:?}"
1836        );
1837    }
1838
1839    #[tokio::test(flavor = "multi_thread")]
1840    async fn segment_extrude_rejects_cap_tags() {
1841        let ctx = ExecutorContext::new_mock(None).await;
1842        let mut exec_state = ExecState::new(&ctx);
1843        let err = coerce_extrude_targets(
1844            vec![segment_value(&mut exec_state)],
1845            BodyType::Surface,
1846            Some(&TagDeclarator::new("cap_start")),
1847            None,
1848            &mut exec_state,
1849            &ctx,
1850            crate::SourceRange::default(),
1851        )
1852        .await
1853        .unwrap_err();
1854
1855        assert!(
1856            err.message()
1857                .contains("`tagStart` and `tagEnd` are not supported when extruding sketch segments"),
1858            "{err:?}"
1859        );
1860        ctx.close().await;
1861    }
1862
1863    /// `getOppositeEdge()` and the other edge getters return a raw edge as
1864    /// `KclValue::Uuid`, which coerces to `Extrudable::Edge`.
1865    fn edge_value(exec_state: &mut ExecState) -> KclValue {
1866        KclValue::Uuid {
1867            value: exec_state.next_uuid(),
1868            meta: vec![],
1869        }
1870    }
1871
1872    #[tokio::test(flavor = "multi_thread")]
1873    async fn edge_extrude_rejects_solid_body_type() {
1874        let ctx = ExecutorContext::new_mock(None).await;
1875        let mut exec_state = ExecState::new(&ctx);
1876        let edge = edge_value(&mut exec_state);
1877        let err = coerce_extrude_targets(
1878            vec![edge],
1879            BodyType::Solid,
1880            None,
1881            None,
1882            &mut exec_state,
1883            &ctx,
1884            crate::SourceRange::default(),
1885        )
1886        .await
1887        .unwrap_err();
1888
1889        assert!(
1890            err.message()
1891                .contains("edges can only be extruded with surface extrudes"),
1892            "{err:?}"
1893        );
1894        ctx.close().await;
1895    }
1896
1897    #[tokio::test(flavor = "multi_thread")]
1898    async fn edge_extrude_rejects_cap_tags() {
1899        let ctx = ExecutorContext::new_mock(None).await;
1900        let mut exec_state = ExecState::new(&ctx);
1901        let edge = edge_value(&mut exec_state);
1902        let err = coerce_extrude_targets(
1903            vec![edge],
1904            BodyType::Surface,
1905            Some(&TagDeclarator::new("cap_start")),
1906            None,
1907            &mut exec_state,
1908            &ctx,
1909            crate::SourceRange::default(),
1910        )
1911        .await
1912        .unwrap_err();
1913
1914        assert!(
1915            err.message()
1916                .contains("`tagStart` and `tagEnd` are not supported when extruding edges"),
1917            "{err:?}"
1918        );
1919        ctx.close().await;
1920    }
1921
1922    #[tokio::test(flavor = "multi_thread")]
1923    async fn edge_extrude_rejects_mixing_with_face() {
1924        let ctx = ExecutorContext::new_mock(None).await;
1925        let mut exec_state = ExecState::new(&ctx);
1926        let edge = edge_value(&mut exec_state);
1927        // The string "START" coerces to a `FaceTag`, i.e. a non-edge extrudable.
1928        let face = KclValue::String {
1929            value: "START".to_owned(),
1930            meta: vec![],
1931        };
1932        let err = coerce_extrude_targets(
1933            vec![edge, face],
1934            BodyType::Surface,
1935            None,
1936            None,
1937            &mut exec_state,
1938            &ctx,
1939            crate::SourceRange::default(),
1940        )
1941        .await
1942        .unwrap_err();
1943
1944        assert!(
1945            err.message()
1946                .contains("Cannot extrude edges together with sketches or faces"),
1947            "{err:?}"
1948        );
1949        ctx.close().await;
1950    }
1951}