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