Skip to main content

kcl_lib/std/
csg.rs

1//! Constructive Solid Geometry (CSG) operations.
2
3use anyhow::Result;
4use kcl_error::CompilationIssue;
5use kcmc::ModelingCmd;
6use kcmc::each_cmd as mcmd;
7use kcmc::length_unit::LengthUnit;
8use kittycad_modeling_cmds::ok_response::OkModelingCmdResponse;
9use kittycad_modeling_cmds::websocket::OkWebSocketResponseData;
10use kittycad_modeling_cmds::{self as kcmc};
11
12use super::DEFAULT_TOLERANCE_MM;
13use super::args::TyF64;
14use super::solid_consumption::record_consumed_solids;
15use super::solid_consumption::validate_solids_not_consumed;
16use crate::errors::KclError;
17use crate::errors::KclErrorDetails;
18use crate::execution::ConsumedSolidOperation;
19use crate::execution::ExecState;
20use crate::execution::KclValue;
21use crate::execution::ModelingCmdMeta;
22use crate::execution::Solid;
23use crate::execution::annotations;
24use crate::execution::types::RuntimeType;
25use crate::std::Args;
26use crate::std::patterns::GeometryTrait;
27
28/// Union two or more solids into a single solid.
29pub async fn union(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
30    let solids: Vec<Solid> =
31        args.get_unlabeled_kw_arg("solids", &RuntimeType::Union(vec![RuntimeType::solids()]), exec_state)?;
32    let tolerance: Option<TyF64> = args.get_kw_arg_opt("tolerance", &RuntimeType::length(), exec_state)?;
33    let legacy_csg: Option<bool> = args.get_kw_arg_opt("legacyMethod", &RuntimeType::bool(), exec_state)?;
34    let csg_algorithm = CsgAlgorithm::legacy(legacy_csg.unwrap_or_default());
35
36    if solids.len() < 2 {
37        return Err(KclError::new_semantic(KclErrorDetails::new(
38            "At least two solids are required for a union operation.".to_string(),
39            vec![args.source_range],
40        )));
41    }
42
43    let solids = inner_union(solids, tolerance, csg_algorithm, exec_state, args).await?;
44    Ok(solids.into())
45}
46
47pub enum CsgAlgorithm {
48    Latest,
49    Legacy,
50}
51
52impl CsgAlgorithm {
53    pub fn legacy(is_legacy: bool) -> Self {
54        if is_legacy { Self::Legacy } else { Self::Latest }
55    }
56    pub fn is_legacy(&self) -> bool {
57        match self {
58            CsgAlgorithm::Latest => false,
59            CsgAlgorithm::Legacy => true,
60        }
61    }
62}
63
64pub(crate) async fn inner_union(
65    solids: Vec<Solid>,
66    tolerance: Option<TyF64>,
67    csg_algorithm: CsgAlgorithm,
68    exec_state: &mut ExecState,
69    args: Args,
70) -> Result<Vec<Solid>, KclError> {
71    validate_solids_not_consumed(&solids, exec_state, args.source_range)?;
72
73    let solid_out_id = exec_state.next_uuid();
74
75    let mut solid = solids[0].clone();
76    solid.set_id(solid_out_id);
77    solid.artifact_id = solid_out_id.into();
78    let mut new_solids = vec![solid.clone()];
79
80    if args.ctx.no_engine_commands().await {
81        record_consumed_solids(exec_state, &solids, ConsumedSolidOperation::Union, Some(solid_out_id));
82        return Ok(new_solids);
83    }
84
85    // Flush the fillets for the solids.
86    exec_state
87        .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), &solids)
88        .await?;
89
90    let result = exec_state
91        .send_modeling_cmd(
92            ModelingCmdMeta::from_args_id(exec_state, &args, solid_out_id),
93            ModelingCmd::from(
94                mcmd::BooleanUnion::builder()
95                    .use_legacy(csg_algorithm.is_legacy())
96                    .solid_ids(solids.iter().map(|s| s.id).collect())
97                    .tolerance(LengthUnit(tolerance.map(|t| t.to_mm()).unwrap_or(DEFAULT_TOLERANCE_MM)))
98                    .build(),
99            ),
100        )
101        .await?;
102
103    let OkWebSocketResponseData::Modeling {
104        modeling_response: OkModelingCmdResponse::BooleanUnion(boolean_resp),
105    } = result
106    else {
107        return Err(KclError::new_internal(KclErrorDetails::new(
108            "Failed to get the result of the union operation.".to_string(),
109            vec![args.source_range],
110        )));
111    };
112
113    if !boolean_resp.any_intersections {
114        exec_state.warn(
115            CompilationIssue::err(
116                args.source_range,
117                "The bodies in this union had no overlap. This usually indicates a problem in your model, these bodies were probably intended to intersect somewhere.".to_string(),
118            ),
119            annotations::WARN_CSG_NO_INTERSECTION,
120        );
121    }
122
123    // If we have more solids, set those as well.
124    for extra_solid_id in boolean_resp.extra_solid_ids {
125        if extra_solid_id == solid_out_id {
126            continue;
127        }
128        let mut new_solid = solid.clone();
129        new_solid.set_id(extra_solid_id);
130        new_solid.artifact_id = extra_solid_id.into();
131        new_solids.push(new_solid);
132    }
133
134    record_consumed_solids(exec_state, &solids, ConsumedSolidOperation::Union, Some(solid_out_id));
135
136    Ok(new_solids)
137}
138
139/// Intersect returns the shared volume between multiple solids, preserving only
140/// overlapping regions.
141pub async fn intersect(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
142    let solids: Vec<Solid> = args.get_unlabeled_kw_arg("solids", &RuntimeType::solids(), exec_state)?;
143    let tolerance: Option<TyF64> = args.get_kw_arg_opt("tolerance", &RuntimeType::length(), exec_state)?;
144    let legacy_csg: Option<bool> = args.get_kw_arg_opt("legacyMethod", &RuntimeType::bool(), exec_state)?;
145    let csg_algorithm = CsgAlgorithm::legacy(legacy_csg.unwrap_or_default());
146
147    if solids.len() < 2 {
148        return Err(KclError::new_semantic(KclErrorDetails::new(
149            "At least two solids are required for an intersect operation.".to_string(),
150            vec![args.source_range],
151        )));
152    }
153
154    let solids = inner_intersect(solids, tolerance, csg_algorithm, exec_state, args).await?;
155    Ok(solids.into())
156}
157
158pub(crate) async fn inner_intersect(
159    solids: Vec<Solid>,
160    tolerance: Option<TyF64>,
161    csg_algorithm: CsgAlgorithm,
162    exec_state: &mut ExecState,
163    args: Args,
164) -> Result<Vec<Solid>, KclError> {
165    validate_solids_not_consumed(&solids, exec_state, args.source_range)?;
166
167    let solid_out_id = exec_state.next_uuid();
168
169    let mut solid = solids[0].clone();
170    solid.set_id(solid_out_id);
171    solid.artifact_id = solid_out_id.into();
172    let mut new_solids = vec![solid.clone()];
173
174    if args.ctx.no_engine_commands().await {
175        record_consumed_solids(
176            exec_state,
177            &solids,
178            ConsumedSolidOperation::Intersect,
179            Some(solid_out_id),
180        );
181        return Ok(new_solids);
182    }
183
184    // Flush the fillets for the solids.
185    exec_state
186        .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), &solids)
187        .await?;
188
189    let result = exec_state
190        .send_modeling_cmd(
191            ModelingCmdMeta::from_args_id(exec_state, &args, solid_out_id),
192            ModelingCmd::from(
193                mcmd::BooleanIntersection::builder()
194                    .use_legacy(csg_algorithm.is_legacy())
195                    .solid_ids(solids.iter().map(|s| s.id).collect())
196                    .tolerance(LengthUnit(tolerance.map(|t| t.to_mm()).unwrap_or(DEFAULT_TOLERANCE_MM)))
197                    .build(),
198            ),
199        )
200        .await?;
201
202    let OkWebSocketResponseData::Modeling {
203        modeling_response: OkModelingCmdResponse::BooleanIntersection(boolean_resp),
204    } = result
205    else {
206        return Err(KclError::new_internal(KclErrorDetails::new(
207            "Failed to get the result of the intersection operation.".to_string(),
208            vec![args.source_range],
209        )));
210    };
211    if !boolean_resp.any_intersections {
212        exec_state.warn(
213            CompilationIssue::err(
214                args.source_range,
215                "The bodies in this intersection had no overlap. This usually indicates a problem in your model, these bodies were probably intended to intersect somewhere.".to_string(),
216            ),
217            annotations::WARN_CSG_NO_INTERSECTION,
218        );
219    }
220
221    // If we have more solids, set those as well.
222    for extra_solid_id in boolean_resp.extra_solid_ids {
223        if extra_solid_id == solid_out_id {
224            continue;
225        }
226        let mut new_solid = solid.clone();
227        new_solid.set_id(extra_solid_id);
228        new_solid.artifact_id = extra_solid_id.into();
229        new_solids.push(new_solid);
230    }
231
232    record_consumed_solids(
233        exec_state,
234        &solids,
235        ConsumedSolidOperation::Intersect,
236        Some(solid_out_id),
237    );
238
239    Ok(new_solids)
240}
241
242/// Subtract removes tool solids from base solids, leaving the remaining material.
243pub async fn subtract(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
244    let solids: Vec<Solid> = args.get_unlabeled_kw_arg("solids", &RuntimeType::solids(), exec_state)?;
245    let tools: Vec<Solid> = args.get_kw_arg("tools", &RuntimeType::solids(), exec_state)?;
246
247    let tolerance: Option<TyF64> = args.get_kw_arg_opt("tolerance", &RuntimeType::length(), exec_state)?;
248    let legacy_csg: Option<bool> = args.get_kw_arg_opt("legacyMethod", &RuntimeType::bool(), exec_state)?;
249    let csg_algorithm = CsgAlgorithm::legacy(legacy_csg.unwrap_or_default());
250
251    let solids = inner_subtract(solids, tools, tolerance, csg_algorithm, exec_state, args).await?;
252    Ok(solids.into())
253}
254
255pub(crate) async fn inner_subtract(
256    solids: Vec<Solid>,
257    tools: Vec<Solid>,
258    tolerance: Option<TyF64>,
259    csg_algorithm: CsgAlgorithm,
260    exec_state: &mut ExecState,
261    args: Args,
262) -> Result<Vec<Solid>, KclError> {
263    let combined_solids = solids.iter().chain(tools.iter()).cloned().collect::<Vec<Solid>>();
264    validate_solids_not_consumed(&combined_solids, exec_state, args.source_range)?;
265
266    let solid_out_id = exec_state.next_uuid();
267
268    let mut solid = solids[0].clone();
269    solid.set_id(solid_out_id);
270    solid.artifact_id = solid_out_id.into();
271    let mut new_solids = vec![solid.clone()];
272
273    if args.ctx.no_engine_commands().await {
274        record_consumed_solids(
275            exec_state,
276            &solids,
277            ConsumedSolidOperation::Subtract,
278            Some(solid_out_id),
279        );
280        record_consumed_solids(exec_state, &tools, ConsumedSolidOperation::Subtract, None);
281        return Ok(new_solids);
282    }
283
284    // Flush the fillets for the solids and the tools.
285    exec_state
286        .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), &combined_solids)
287        .await?;
288
289    let result = exec_state
290        .send_modeling_cmd(
291            ModelingCmdMeta::from_args_id(exec_state, &args, solid_out_id),
292            ModelingCmd::from(
293                mcmd::BooleanSubtract::builder()
294                    .use_legacy(csg_algorithm.is_legacy())
295                    .target_ids(solids.iter().map(|s| s.id).collect())
296                    .tool_ids(tools.iter().map(|s| s.id).collect())
297                    .tolerance(LengthUnit(tolerance.map(|t| t.to_mm()).unwrap_or(DEFAULT_TOLERANCE_MM)))
298                    .build(),
299            ),
300        )
301        .await?;
302
303    let OkWebSocketResponseData::Modeling {
304        modeling_response: OkModelingCmdResponse::BooleanSubtract(boolean_resp),
305    } = result
306    else {
307        return Err(KclError::new_internal(KclErrorDetails::new(
308            "Failed to get the result of the subtract operation.".to_string(),
309            vec![args.source_range],
310        )));
311    };
312
313    if !boolean_resp.any_intersections {
314        exec_state.warn(
315            CompilationIssue::err(
316                args.source_range,
317                "The bodies in this subtraction had no overlap. This usually indicates a problem in your model, these bodies were probably intended to intersect somewhere.".to_string(),
318            ),
319            annotations::WARN_CSG_NO_INTERSECTION,
320        );
321    }
322
323    // If we have more solids, set those as well.
324    for extra_solid_id in boolean_resp.extra_solid_ids {
325        if extra_solid_id == solid_out_id {
326            continue;
327        }
328        let mut new_solid = solid.clone();
329        new_solid.set_id(extra_solid_id);
330        new_solid.artifact_id = extra_solid_id.into();
331        new_solids.push(new_solid);
332    }
333
334    record_consumed_solids(
335        exec_state,
336        &solids,
337        ConsumedSolidOperation::Subtract,
338        Some(solid_out_id),
339    );
340    record_consumed_solids(exec_state, &tools, ConsumedSolidOperation::Subtract, None);
341
342    Ok(new_solids)
343}
344
345/// Split a target body into two parts: the part that overlaps with the tool, and the part that doesn't.
346pub async fn split(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
347    let targets: Vec<Solid> = args.get_unlabeled_kw_arg("targets", &RuntimeType::solids(), exec_state)?;
348    let tolerance: Option<TyF64> = args.get_kw_arg_opt("tolerance", &RuntimeType::length(), exec_state)?;
349    let legacy_csg: Option<bool> = args.get_kw_arg_opt("legacyMethod", &RuntimeType::bool(), exec_state)?;
350    let csg_algorithm = CsgAlgorithm::legacy(legacy_csg.unwrap_or_default());
351    let tools: Option<Vec<Solid>> = args.get_kw_arg_opt("tools", &RuntimeType::solids(), exec_state)?;
352    let keep_tools = args
353        .get_kw_arg_opt("keepTools", &RuntimeType::bool(), exec_state)?
354        .unwrap_or_default();
355    let merge = args
356        .get_kw_arg_opt("merge", &RuntimeType::bool(), exec_state)?
357        .unwrap_or_default();
358
359    if targets.is_empty() {
360        return Err(KclError::new_semantic(KclErrorDetails::new(
361            "At least one target body is required.".to_string(),
362            vec![args.source_range],
363        )));
364    }
365
366    let body = inner_imprint(
367        targets,
368        tools,
369        keep_tools,
370        merge,
371        tolerance,
372        csg_algorithm,
373        exec_state,
374        args,
375    )
376    .await?;
377    Ok(body.into())
378}
379
380#[allow(clippy::too_many_arguments)]
381pub(crate) async fn inner_imprint(
382    targets: Vec<Solid>,
383    tools: Option<Vec<Solid>>,
384    keep_tools: bool,
385    merge: bool,
386    tolerance: Option<TyF64>,
387    csg_algorithm: CsgAlgorithm,
388    exec_state: &mut ExecState,
389    args: Args,
390) -> Result<Vec<Solid>, KclError> {
391    validate_solids_not_consumed(&targets, exec_state, args.source_range)?;
392    if let Some(tools) = tools.as_ref() {
393        validate_solids_not_consumed(tools, exec_state, args.source_range)?;
394    }
395
396    let body_out_id = exec_state.next_uuid();
397
398    let mut body = targets[0].clone();
399    body.set_id(body_out_id);
400    body.artifact_id = body_out_id.into();
401    let mut new_solids = vec![body.clone()];
402    let separate_bodies = !merge;
403
404    if args.ctx.no_engine_commands().await {
405        if separate_bodies {
406            let extra_solid_id = exec_state.next_uuid();
407            let mut new_solid = body.clone();
408            new_solid.set_id(extra_solid_id);
409            new_solid.artifact_id = extra_solid_id.into();
410            new_solids.push(new_solid);
411        }
412        record_consumed_solids(exec_state, &targets, ConsumedSolidOperation::Split, Some(body_out_id));
413        if !keep_tools && let Some(tools) = tools.as_ref() {
414            record_consumed_solids(exec_state, tools, ConsumedSolidOperation::Split, None);
415        }
416        return Ok(new_solids);
417    }
418
419    // Flush pending edge-cut operations for any solids consumed by imprint.
420    let mut imprint_solids = targets.clone();
421    if let Some(tool_solids) = tools.as_ref() {
422        imprint_solids.extend_from_slice(tool_solids);
423    }
424    exec_state
425        .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), &imprint_solids)
426        .await?;
427
428    let body_ids = targets.iter().map(|body| body.id).collect();
429    let tool_ids = tools.as_ref().map(|tools| tools.iter().map(|tool| tool.id).collect());
430    let tolerance = LengthUnit(tolerance.map(|t| t.to_mm()).unwrap_or(DEFAULT_TOLERANCE_MM));
431    let imprint_cmd = mcmd::BooleanImprint::builder()
432        .use_legacy(csg_algorithm.is_legacy())
433        .body_ids(body_ids)
434        .tolerance(tolerance)
435        .separate_bodies(separate_bodies)
436        .keep_tools(keep_tools)
437        .maybe_tool_ids(tool_ids)
438        .build();
439    let result = exec_state
440        .send_modeling_cmd(
441            ModelingCmdMeta::from_args_id(exec_state, &args, body_out_id),
442            ModelingCmd::from(imprint_cmd),
443        )
444        .await?;
445
446    let OkWebSocketResponseData::Modeling {
447        modeling_response: OkModelingCmdResponse::BooleanImprint(boolean_resp),
448    } = result
449    else {
450        return Err(KclError::new_internal(KclErrorDetails::new(
451            "Failed to get the result of the Imprint operation.".to_string(),
452            vec![args.source_range],
453        )));
454    };
455    if !boolean_resp.any_intersections {
456        exec_state.warn(
457            CompilationIssue::err(
458                args.source_range,
459                "The bodies in this split had no overlap. This usually indicates a problem in your model, these bodies were probably intended to intersect somewhere.".to_string(),
460            ),
461            annotations::WARN_CSG_NO_INTERSECTION,
462        );
463    }
464
465    // If we have more solids, set those as well.
466    for extra_solid_id in boolean_resp.extra_solid_ids {
467        if extra_solid_id == body_out_id {
468            continue;
469        }
470        let mut new_solid = body.clone();
471        new_solid.set_id(extra_solid_id);
472        new_solid.artifact_id = extra_solid_id.into();
473        new_solids.push(new_solid);
474    }
475
476    record_consumed_solids(exec_state, &targets, ConsumedSolidOperation::Split, Some(body_out_id));
477    if !keep_tools && let Some(tools) = tools.as_ref() {
478        record_consumed_solids(exec_state, tools, ConsumedSolidOperation::Split, None);
479    }
480
481    Ok(new_solids)
482}
483
484#[cfg(test)]
485mod tests {
486    use crate::errors::KclError;
487    use crate::execution::MockConfig;
488
489    #[tokio::test(flavor = "multi_thread")]
490    async fn subtract_reusing_consumed_target_reports_kcl_error() {
491        let code = r#"
492targetSketch = sketch(on = XY) {
493  line1 = line(start = [var -10, var -10], end = [var 10, var -10])
494  line2 = line(start = [var 10, var -10], end = [var 10, var 10])
495  line3 = line(start = [var 10, var 10], end = [var -10, var 10])
496  line4 = line(start = [var -10, var 10], end = [var -10, var -10])
497  coincident([line1.end, line2.start])
498  coincident([line2.end, line3.start])
499  coincident([line3.end, line4.start])
500  coincident([line4.end, line1.start])
501  equalLength([line1, line2, line3, line4])
502}
503
504target = extrude(region(point = [0, 0], sketch = targetSketch), length = 20)
505
506tool1Sketch = sketch(on = XY) {
507  line1 = line(start = [var -11, var -11], end = [var -7, var -11])
508  line2 = line(start = [var -7, var -11], end = [var -7, var -7])
509  line3 = line(start = [var -7, var -7], end = [var -11, var -7])
510  line4 = line(start = [var -11, var -7], end = [var -11, var -11])
511  coincident([line1.end, line2.start])
512  coincident([line2.end, line3.start])
513  coincident([line3.end, line4.start])
514  coincident([line4.end, line1.start])
515  equalLength([line1, line2, line3, line4])
516}
517
518tool1 = extrude(region(point = [-9, -9], sketch = tool1Sketch), length = 4)
519
520tool2Sketch = sketch(on = XY) {
521  line1 = line(start = [var 7, var 7], end = [var 11, var 7])
522  line2 = line(start = [var 11, var 7], end = [var 11, var 11])
523  line3 = line(start = [var 11, var 11], end = [var 7, var 11])
524  line4 = line(start = [var 7, var 11], end = [var 7, var 7])
525  coincident([line1.end, line2.start])
526  coincident([line2.end, line3.start])
527  coincident([line3.end, line4.start])
528  coincident([line4.end, line1.start])
529  equalLength([line1, line2, line3, line4])
530}
531
532tool2 = extrude(region(point = [9, 9], sketch = tool2Sketch), length = 4)
533
534first = subtract(target, tools = [tool1])
535second = subtract(target, tools = [tool2])
536"#;
537
538        let ctx = crate::ExecutorContext::new_mock(None).await;
539        let program = crate::Program::parse_no_errs(code).unwrap();
540        let err = ctx.run_mock(&program, &MockConfig::default()).await.unwrap_err();
541        ctx.close().await;
542
543        assert!(matches!(&err.error, KclError::Semantic { .. }), "{:?}", err.error);
544        let message = err.error.message();
545        assert!(
546            message.contains("`target` was already consumed by a `subtract` operation"),
547            "{message}"
548        );
549        assert!(message.contains("The operation result is now in `first`"), "{message}");
550    }
551
552    #[tokio::test(flavor = "multi_thread")]
553    async fn subtract_reusing_consumed_tool_reports_kcl_error() {
554        let code = r#"
555targetSketch = sketch(on = XY) {
556  line1 = line(start = [var -10, var -10], end = [var 10, var -10])
557  line2 = line(start = [var 10, var -10], end = [var 10, var 10])
558  line3 = line(start = [var 10, var 10], end = [var -10, var 10])
559  line4 = line(start = [var -10, var 10], end = [var -10, var -10])
560  coincident([line1.end, line2.start])
561  coincident([line2.end, line3.start])
562  coincident([line3.end, line4.start])
563  coincident([line4.end, line1.start])
564  equalLength([line1, line2, line3, line4])
565}
566
567target = extrude(region(point = [0, 0], sketch = targetSketch), length = 20)
568
569toolSketch = sketch(on = XY) {
570  line1 = line(start = [var -2, var -2], end = [var 2, var -2])
571  line2 = line(start = [var 2, var -2], end = [var 2, var 2])
572  line3 = line(start = [var 2, var 2], end = [var -2, var 2])
573  line4 = line(start = [var -2, var 2], end = [var -2, var -2])
574  coincident([line1.end, line2.start])
575  coincident([line2.end, line3.start])
576  coincident([line3.end, line4.start])
577  coincident([line4.end, line1.start])
578  equalLength([line1, line2, line3, line4])
579}
580
581tool = extrude(region(point = [0, 0], sketch = toolSketch), length = 4)
582
583first = subtract(target, tools = [tool])
584second = subtract(first, tools = [tool])
585"#;
586
587        let ctx = crate::ExecutorContext::new_mock(None).await;
588        let program = crate::Program::parse_no_errs(code).unwrap();
589        let err = ctx.run_mock(&program, &MockConfig::default()).await.unwrap_err();
590        ctx.close().await;
591
592        assert!(matches!(&err.error, KclError::Semantic { .. }), "{:?}", err.error);
593        let message = err.error.message();
594        assert!(
595            message.contains("`tool` was already consumed by a `subtract` operation"),
596            "{message}"
597        );
598        assert!(message.contains("can no longer be used"), "{message}");
599    }
600
601    #[tokio::test(flavor = "multi_thread")]
602    async fn union_reusing_consumed_solid_reports_kcl_error() {
603        let code = r#"
604leftSketch = sketch(on = XY) {
605  line1 = line(start = [var -10, var -10], end = [var -2, var -10])
606  line2 = line(start = [var -2, var -10], end = [var -2, var -2])
607  line3 = line(start = [var -2, var -2], end = [var -10, var -2])
608  line4 = line(start = [var -10, var -2], end = [var -10, var -10])
609  coincident([line1.end, line2.start])
610  coincident([line2.end, line3.start])
611  coincident([line3.end, line4.start])
612  coincident([line4.end, line1.start])
613  equalLength([line1, line2, line3, line4])
614}
615
616left = extrude(region(point = [-6, -6], sketch = leftSketch), length = 8)
617
618rightSketch = sketch(on = XY) {
619  line1 = line(start = [var -2, var -2], end = [var 6, var -2])
620  line2 = line(start = [var 6, var -2], end = [var 6, var 6])
621  line3 = line(start = [var 6, var 6], end = [var -2, var 6])
622  line4 = line(start = [var -2, var 6], end = [var -2, var -2])
623  coincident([line1.end, line2.start])
624  coincident([line2.end, line3.start])
625  coincident([line3.end, line4.start])
626  coincident([line4.end, line1.start])
627  equalLength([line1, line2, line3, line4])
628}
629
630right = extrude(region(point = [2, 2], sketch = rightSketch), length = 8)
631
632toolSketch = sketch(on = XY) {
633  line1 = line(start = [var -1, var -1], end = [var 1, var -1])
634  line2 = line(start = [var 1, var -1], end = [var 1, var 1])
635  line3 = line(start = [var 1, var 1], end = [var -1, var 1])
636  line4 = line(start = [var -1, var 1], end = [var -1, var -1])
637  coincident([line1.end, line2.start])
638  coincident([line2.end, line3.start])
639  coincident([line3.end, line4.start])
640  coincident([line4.end, line1.start])
641  equalLength([line1, line2, line3, line4])
642}
643
644tool = extrude(region(point = [0, 0], sketch = toolSketch), length = 2)
645
646first = union([left, right])
647second = union([first, tool])
648third = subtract(left, tools = [tool])
649"#;
650
651        let ctx = crate::ExecutorContext::new_mock(None).await;
652        let program = crate::Program::parse_no_errs(code).unwrap();
653        let err = ctx.run_mock(&program, &MockConfig::default()).await.unwrap_err();
654        ctx.close().await;
655
656        assert!(matches!(&err.error, KclError::Semantic { .. }), "{:?}", err.error);
657        let message = err.error.message();
658        assert!(
659            message.contains("`left` was already consumed by a `union` operation"),
660            "{message}"
661        );
662        assert!(message.contains("The operation result is now in `second`"), "{message}");
663    }
664
665    #[tokio::test(flavor = "multi_thread")]
666    async fn intersect_reusing_consumed_solid_reports_kcl_error() {
667        let code = r#"
668leftSketch = sketch(on = XY) {
669  line1 = line(start = [var -10, var -10], end = [var 4, var -10])
670  line2 = line(start = [var 4, var -10], end = [var 4, var 4])
671  line3 = line(start = [var 4, var 4], end = [var -10, var 4])
672  line4 = line(start = [var -10, var 4], end = [var -10, var -10])
673  coincident([line1.end, line2.start])
674  coincident([line2.end, line3.start])
675  coincident([line3.end, line4.start])
676  coincident([line4.end, line1.start])
677  equalLength([line1, line2, line3, line4])
678}
679
680left = extrude(region(point = [-3, -3], sketch = leftSketch), length = 8)
681
682rightSketch = sketch(on = XY) {
683  line1 = line(start = [var -4, var -4], end = [var 10, var -4])
684  line2 = line(start = [var 10, var -4], end = [var 10, var 10])
685  line3 = line(start = [var 10, var 10], end = [var -4, var 10])
686  line4 = line(start = [var -4, var 10], end = [var -4, var -4])
687  coincident([line1.end, line2.start])
688  coincident([line2.end, line3.start])
689  coincident([line3.end, line4.start])
690  coincident([line4.end, line1.start])
691  equalLength([line1, line2, line3, line4])
692}
693
694right = extrude(region(point = [3, 3], sketch = rightSketch), length = 8)
695
696toolSketch = sketch(on = XY) {
697  line1 = line(start = [var -1, var -1], end = [var 1, var -1])
698  line2 = line(start = [var 1, var -1], end = [var 1, var 1])
699  line3 = line(start = [var 1, var 1], end = [var -1, var 1])
700  line4 = line(start = [var -1, var 1], end = [var -1, var -1])
701  coincident([line1.end, line2.start])
702  coincident([line2.end, line3.start])
703  coincident([line3.end, line4.start])
704  coincident([line4.end, line1.start])
705  equalLength([line1, line2, line3, line4])
706}
707
708tool = extrude(region(point = [0, 0], sketch = toolSketch), length = 2)
709
710first = intersect([left, right])
711second = subtract(left, tools = [tool])
712"#;
713
714        let ctx = crate::ExecutorContext::new_mock(None).await;
715        let program = crate::Program::parse_no_errs(code).unwrap();
716        let err = ctx.run_mock(&program, &MockConfig::default()).await.unwrap_err();
717        ctx.close().await;
718
719        assert!(matches!(&err.error, KclError::Semantic { .. }), "{:?}", err.error);
720        let message = err.error.message();
721        assert!(
722            message.contains("`left` was already consumed by an `intersect` operation"),
723            "{message}"
724        );
725        assert!(message.contains("The operation result is now in `first`"), "{message}");
726    }
727
728    #[tokio::test(flavor = "multi_thread")]
729    async fn split_keep_tools_does_not_consume_tools() {
730        let code = r#"
731targetSketch = sketch(on = XY) {
732  line1 = line(start = [var -10, var -10], end = [var 10, var -10])
733  line2 = line(start = [var 10, var -10], end = [var 10, var 10])
734  line3 = line(start = [var 10, var 10], end = [var -10, var 10])
735  line4 = line(start = [var -10, var 10], end = [var -10, var -10])
736  coincident([line1.end, line2.start])
737  coincident([line2.end, line3.start])
738  coincident([line3.end, line4.start])
739  coincident([line4.end, line1.start])
740  equalLength([line1, line2, line3, line4])
741}
742
743target = extrude(region(point = [0, 0], sketch = targetSketch), length = 20)
744
745toolSketch = sketch(on = XY) {
746  line1 = line(start = [var -2, var -10], end = [var 2, var -10])
747  line2 = line(start = [var 2, var -10], end = [var 2, var 10])
748  line3 = line(start = [var 2, var 10], end = [var -2, var 10])
749  line4 = line(start = [var -2, var 10], end = [var -2, var -10])
750  coincident([line1.end, line2.start])
751  coincident([line2.end, line3.start])
752  coincident([line3.end, line4.start])
753  coincident([line4.end, line1.start])
754}
755
756tool = extrude(region(point = [0, 0], sketch = toolSketch), length = 20)
757
758first = split(target, tools = [tool], keepTools = true)
759second = subtract(first, tools = [tool])
760"#;
761
762        let ctx = crate::ExecutorContext::new_mock(None).await;
763        let program = crate::Program::parse_no_errs(code).unwrap();
764        let outcome = ctx.run_mock(&program, &MockConfig::default()).await.unwrap();
765        ctx.close().await;
766
767        assert!(outcome.variables.contains_key("second"));
768    }
769
770    #[tokio::test(flavor = "multi_thread")]
771    async fn split_without_keep_tools_consumes_tools() {
772        let code = r#"
773targetSketch = sketch(on = XY) {
774  line1 = line(start = [var -10, var -10], end = [var 10, var -10])
775  line2 = line(start = [var 10, var -10], end = [var 10, var 10])
776  line3 = line(start = [var 10, var 10], end = [var -10, var 10])
777  line4 = line(start = [var -10, var 10], end = [var -10, var -10])
778  coincident([line1.end, line2.start])
779  coincident([line2.end, line3.start])
780  coincident([line3.end, line4.start])
781  coincident([line4.end, line1.start])
782  equalLength([line1, line2, line3, line4])
783}
784
785target = extrude(region(point = [0, 0], sketch = targetSketch), length = 20)
786
787toolSketch = sketch(on = XY) {
788  line1 = line(start = [var -2, var -10], end = [var 2, var -10])
789  line2 = line(start = [var 2, var -10], end = [var 2, var 10])
790  line3 = line(start = [var 2, var 10], end = [var -2, var 10])
791  line4 = line(start = [var -2, var 10], end = [var -2, var -10])
792  coincident([line1.end, line2.start])
793  coincident([line2.end, line3.start])
794  coincident([line3.end, line4.start])
795  coincident([line4.end, line1.start])
796}
797
798tool = extrude(region(point = [0, 0], sketch = toolSketch), length = 20)
799
800first = split(target, tools = [tool])
801second = subtract(first, tools = [tool])
802"#;
803
804        let ctx = crate::ExecutorContext::new_mock(None).await;
805        let program = crate::Program::parse_no_errs(code).unwrap();
806        let err = ctx.run_mock(&program, &MockConfig::default()).await.unwrap_err();
807        ctx.close().await;
808
809        assert!(matches!(&err.error, KclError::Semantic { .. }), "{:?}", err.error);
810        let message = err.error.message();
811        assert!(
812            message.contains("`tool` was already consumed by a `split` operation"),
813            "{message}"
814        );
815        assert!(message.contains("can no longer be used"), "{message}");
816    }
817}