Skip to main content

kcl_lib/engine/
mod.rs

1//! Functions for managing engine communications.
2
3pub mod async_tasks;
4#[cfg(not(target_arch = "wasm32"))]
5#[cfg(feature = "engine")]
6pub mod conn;
7pub mod conn_mock;
8#[cfg(target_arch = "wasm32")]
9#[cfg(feature = "engine")]
10pub mod conn_wasm;
11
12use std::collections::HashMap;
13use std::sync::Arc;
14use std::sync::atomic::AtomicUsize;
15use std::sync::atomic::Ordering;
16
17pub use async_tasks::AsyncTasks;
18use indexmap::IndexMap;
19use kcmc::ModelingCmd;
20use kcmc::each_cmd as mcmd;
21use kcmc::length_unit::LengthUnit;
22use kcmc::ok_response::OkModelingCmdResponse;
23use kcmc::shared::Color;
24use kcmc::websocket::BatchResponse;
25use kcmc::websocket::ModelingBatch;
26use kcmc::websocket::ModelingCmdReq;
27use kcmc::websocket::ModelingSessionData;
28use kcmc::websocket::OkWebSocketResponseData;
29use kcmc::websocket::WebSocketRequest;
30use kcmc::websocket::WebSocketResponse;
31use kittycad_modeling_cmds::units::UnitLength;
32use kittycad_modeling_cmds::{self as kcmc};
33use parse_display::Display;
34use parse_display::FromStr;
35use serde::Deserialize;
36use serde::Serialize;
37use tokio::sync::RwLock;
38use uuid::Uuid;
39use web_time::Instant;
40
41use crate::SourceRange;
42use crate::errors::KclError;
43use crate::errors::KclErrorDetails;
44use crate::execution::DefaultPlanes;
45use crate::execution::IdGenerator;
46use crate::execution::PlaneInfo;
47use crate::execution::Point3d;
48
49lazy_static::lazy_static! {
50    pub static ref GRID_OBJECT_ID: uuid::Uuid = uuid::Uuid::parse_str("cfa78409-653d-4c26-96f1-7c45fb784840").unwrap();
51
52    pub static ref GRID_SCALE_TEXT_OBJECT_ID: uuid::Uuid = uuid::Uuid::parse_str("10782f33-f588-4668-8bcd-040502d26590").unwrap();
53
54    pub static ref DEFAULT_PLANE_INFO: IndexMap<PlaneName, PlaneInfo> = IndexMap::from([
55            (
56                PlaneName::Xy,
57                PlaneInfo {
58                    origin: Point3d::new(0.0, 0.0, 0.0, Some(UnitLength::Millimeters)),
59                    x_axis: Point3d::new(1.0, 0.0, 0.0, None),
60                    y_axis: Point3d::new(0.0, 1.0, 0.0, None),
61                    z_axis: Point3d::new(0.0, 0.0, 1.0, None),
62                },
63            ),
64            (
65                PlaneName::NegXy,
66                PlaneInfo {
67                    origin: Point3d::new( 0.0, 0.0,  0.0, Some(UnitLength::Millimeters)),
68                    x_axis: Point3d::new(-1.0, 0.0,  0.0, None),
69                    y_axis: Point3d::new( 0.0, 1.0,  0.0, None),
70                    z_axis: Point3d::new( 0.0, 0.0, -1.0, None),
71                },
72            ),
73            (
74                PlaneName::Xz,
75                PlaneInfo {
76                    origin: Point3d::new(0.0,  0.0, 0.0, Some(UnitLength::Millimeters)),
77                    x_axis: Point3d::new(1.0,  0.0, 0.0, None),
78                    y_axis: Point3d::new(0.0,  0.0, 1.0, None),
79                    z_axis: Point3d::new(0.0, -1.0, 0.0, None),
80                },
81            ),
82            (
83                PlaneName::NegXz,
84                PlaneInfo {
85                    origin: Point3d::new( 0.0, 0.0, 0.0, Some(UnitLength::Millimeters)),
86                    x_axis: Point3d::new(-1.0, 0.0, 0.0, None),
87                    y_axis: Point3d::new( 0.0, 0.0, 1.0, None),
88                    z_axis: Point3d::new( 0.0, 1.0, 0.0, None),
89                },
90            ),
91            (
92                PlaneName::Yz,
93                PlaneInfo {
94                    origin: Point3d::new(0.0, 0.0, 0.0, Some(UnitLength::Millimeters)),
95                    x_axis: Point3d::new(0.0, 1.0, 0.0, None),
96                    y_axis: Point3d::new(0.0, 0.0, 1.0, None),
97                    z_axis: Point3d::new(1.0, 0.0, 0.0, None),
98                },
99            ),
100            (
101                PlaneName::NegYz,
102                PlaneInfo {
103                    origin: Point3d::new( 0.0,  0.0, 0.0, Some(UnitLength::Millimeters)),
104                    x_axis: Point3d::new( 0.0, -1.0, 0.0, None),
105                    y_axis: Point3d::new( 0.0,  0.0, 1.0, None),
106                    z_axis: Point3d::new(-1.0,  0.0, 0.0, None),
107                },
108            ),
109    ]);
110}
111
112/// Per-execution buffer for modeling commands that must preserve temporal order.
113///
114/// A single execution can enqueue commands whose source ranges come from multiple
115/// modules. The ownership boundary is the execution task carrying this context,
116/// not the module id embedded in a source range.
117#[derive(Debug, Clone)]
118pub struct EngineBatchContext {
119    batch: Arc<RwLock<Vec<(WebSocketRequest, SourceRange)>>>,
120    batch_end: Arc<RwLock<IndexMap<Uuid, (WebSocketRequest, SourceRange)>>>,
121}
122
123impl Default for EngineBatchContext {
124    fn default() -> Self {
125        Self::new()
126    }
127}
128
129impl EngineBatchContext {
130    pub fn new() -> Self {
131        Self {
132            batch: Arc::new(RwLock::new(Vec::new())),
133            batch_end: Arc::new(RwLock::new(IndexMap::new())),
134        }
135    }
136
137    pub async fn is_empty(&self) -> bool {
138        self.batch.read().await.is_empty() && self.batch_end.read().await.is_empty()
139    }
140
141    async fn clear(&self) {
142        self.batch.write().await.clear();
143        self.batch_end.write().await.clear();
144    }
145
146    async fn push(&self, req: WebSocketRequest, source_range: SourceRange) {
147        self.batch.write().await.push((req, source_range));
148    }
149
150    async fn extend(&self, requests: Vec<(WebSocketRequest, SourceRange)>) {
151        self.batch.write().await.extend(requests);
152    }
153
154    async fn insert_end(&self, id: Uuid, req: WebSocketRequest, source_range: SourceRange) {
155        self.batch_end.write().await.insert(id, (req, source_range));
156    }
157
158    pub(crate) async fn move_batch_end_to_batch(&self, ids: Vec<Uuid>) {
159        let mut moved = Vec::new();
160        {
161            let mut batch_end = self.batch_end.write().await;
162            for id in ids {
163                let Some(item) = batch_end.shift_remove(&id) else {
164                    continue;
165                };
166                moved.push(item);
167            }
168        }
169
170        self.extend(moved).await;
171    }
172
173    async fn take_batch(&self) -> Vec<(WebSocketRequest, SourceRange)> {
174        std::mem::take(&mut *self.batch.write().await)
175    }
176
177    async fn take_batch_end(&self) -> IndexMap<Uuid, (WebSocketRequest, SourceRange)> {
178        std::mem::take(&mut *self.batch_end.write().await)
179    }
180}
181
182#[derive(Default, Debug)]
183pub struct EngineStats {
184    pub commands_batched: AtomicUsize,
185    pub batches_sent: AtomicUsize,
186}
187
188impl Clone for EngineStats {
189    fn clone(&self) -> Self {
190        Self {
191            commands_batched: AtomicUsize::new(self.commands_batched.load(Ordering::Relaxed)),
192            batches_sent: AtomicUsize::new(self.batches_sent.load(Ordering::Relaxed)),
193        }
194    }
195}
196
197#[async_trait::async_trait]
198pub trait EngineManager: std::fmt::Debug + Send + Sync + 'static {
199    /// Get the command responses from the engine.
200    fn responses(&self) -> Arc<RwLock<IndexMap<Uuid, WebSocketResponse>>>;
201
202    /// Get the ids of the async commands we are waiting for.
203    fn ids_of_async_commands(&self) -> Arc<RwLock<IndexMap<Uuid, SourceRange>>>;
204
205    /// Get the async tasks we are waiting for.
206    fn async_tasks(&self) -> AsyncTasks;
207
208    /// Take the ids of async commands that have accumulated so far and clear them.
209    async fn take_ids_of_async_commands(&self) -> IndexMap<Uuid, SourceRange> {
210        std::mem::take(&mut *self.ids_of_async_commands().write().await)
211    }
212
213    /// Take the responses that have accumulated so far and clear them.
214    async fn take_responses(&self) -> IndexMap<Uuid, WebSocketResponse> {
215        std::mem::take(&mut *self.responses().write().await)
216    }
217
218    /// Get the default planes.
219    fn get_default_planes(&self) -> Arc<RwLock<Option<DefaultPlanes>>>;
220
221    fn stats(&self) -> &EngineStats;
222
223    /// Get the default planes, creating them if they don't exist.
224    async fn default_planes(
225        &self,
226        batch_context: &EngineBatchContext,
227        id_generator: &mut IdGenerator,
228        source_range: SourceRange,
229    ) -> Result<DefaultPlanes, KclError> {
230        {
231            let opt = self.get_default_planes().read().await.as_ref().cloned();
232            if let Some(planes) = opt {
233                return Ok(planes);
234            }
235        } // drop the read lock
236
237        let new_planes = self
238            .new_default_planes(batch_context, id_generator, source_range)
239            .await?;
240        *self.get_default_planes().write().await = Some(new_planes.clone());
241
242        Ok(new_planes)
243    }
244
245    /// Helpers to be called after clearing a scene.
246    /// (These really only apply to wasm for now).
247    async fn clear_scene_post_hook(
248        &self,
249        batch_context: &EngineBatchContext,
250        id_generator: &mut IdGenerator,
251        source_range: SourceRange,
252    ) -> Result<(), crate::errors::KclError>;
253
254    async fn clear_queues(&self, batch_context: &EngineBatchContext) {
255        batch_context.clear().await;
256        self.ids_of_async_commands().write().await.clear();
257        self.async_tasks().clear().await;
258    }
259
260    /// Fetch debug information from the peer.
261    async fn fetch_debug(&self) -> Result<(), crate::errors::KclError>;
262
263    /// Get any debug information (if requested)
264    async fn get_debug(&self) -> Option<OkWebSocketResponseData>;
265
266    /// Send a modeling command and do not wait for the response message.
267    async fn inner_fire_modeling_cmd(
268        &self,
269        id: uuid::Uuid,
270        source_range: SourceRange,
271        cmd: WebSocketRequest,
272        id_to_source_range: HashMap<Uuid, SourceRange>,
273    ) -> Result<(), crate::errors::KclError>;
274
275    /// Send a modeling command and wait for the response message.
276    async fn inner_send_modeling_cmd(
277        &self,
278        id: uuid::Uuid,
279        source_range: SourceRange,
280        cmd: WebSocketRequest,
281        id_to_source_range: HashMap<uuid::Uuid, SourceRange>,
282    ) -> Result<kcmc::websocket::WebSocketResponse, crate::errors::KclError>;
283
284    async fn clear_scene(
285        &self,
286        batch_context: &EngineBatchContext,
287        id_generator: &mut IdGenerator,
288        source_range: SourceRange,
289    ) -> Result<(), crate::errors::KclError> {
290        // Clear any batched commands leftover from previous scenes.
291        self.clear_queues(batch_context).await;
292
293        self.batch_modeling_cmd(
294            batch_context,
295            id_generator.next_uuid(),
296            source_range,
297            &ModelingCmd::SceneClearAll(mcmd::SceneClearAll::default()),
298        )
299        .await?;
300
301        // Flush the batch queue, so clear is run right away.
302        // Otherwise the hooks below won't work.
303        self.flush_batch(batch_context, false, source_range).await?;
304
305        // Do the after clear scene hook.
306        self.clear_scene_post_hook(batch_context, id_generator, source_range)
307            .await?;
308
309        Ok(())
310    }
311
312    /// Ensure a specific async command has been completed.
313    async fn ensure_async_command_completed(
314        &self,
315        id: uuid::Uuid,
316        source_range: Option<SourceRange>,
317    ) -> Result<OkWebSocketResponseData, KclError> {
318        let source_range = if let Some(source_range) = source_range {
319            source_range
320        } else {
321            // Look it up if we don't have it.
322            self.ids_of_async_commands()
323                .read()
324                .await
325                .get(&id)
326                .cloned()
327                .unwrap_or_default()
328        };
329
330        let current_time = Instant::now();
331        while current_time.elapsed().as_secs() < 60 {
332            let responses = self.responses().read().await.clone();
333            let Some(resp) = responses.get(&id) else {
334                // Yield to the event loop so that we don’t block the UI thread.
335                // No seriously WE DO NOT WANT TO PAUSE THE WHOLE APP ON THE JS SIDE.
336                #[cfg(target_arch = "wasm32")]
337                {
338                    let duration = web_time::Duration::from_millis(1);
339                    wasm_timer::Delay::new(duration).await.map_err(|err| {
340                        KclError::new_internal(KclErrorDetails::new(
341                            format!("Failed to sleep: {:?}", err),
342                            vec![source_range],
343                        ))
344                    })?;
345                }
346                #[cfg(not(target_arch = "wasm32"))]
347                tokio::task::yield_now().await;
348                continue;
349            };
350
351            // If the response is an error, return it.
352            // Parsing will do that and we can ignore the result, we don't care.
353            let response = self.parse_websocket_response(resp.clone(), source_range)?;
354            return Ok(response);
355        }
356
357        Err(KclError::new_engine(KclErrorDetails::new(
358            "async command timed out".to_string(),
359            vec![source_range],
360        )))
361    }
362
363    /// Ensure ALL async commands have been completed.
364    async fn ensure_async_commands_completed(&self, batch_context: &EngineBatchContext) -> Result<(), KclError> {
365        // Check if all async commands have been completed.
366        let ids = self.take_ids_of_async_commands().await;
367
368        // Try to get them from the responses.
369        for (id, source_range) in ids {
370            self.ensure_async_command_completed(id, Some(source_range)).await?;
371        }
372
373        // Make sure we check for all async tasks as well.
374        // The reason why we ignore the error here is that, if a model fillets an edge
375        // we previously called something on, it might no longer exist. In which case,
376        // the artifact graph won't care either if its gone since you can't select it
377        // anymore anyways.
378        if let Err(err) = self.async_tasks().join_all().await {
379            crate::log::logln!(
380                "Error waiting for async tasks (this is typically fine and just means that an edge became something else): {:?}",
381                err
382            );
383        }
384
385        // Flush the batch to make sure nothing remains.
386        self.flush_batch(batch_context, true, SourceRange::default()).await?;
387
388        Ok(())
389    }
390
391    /// Set the visibility of edges.
392    async fn set_edge_visibility(
393        &self,
394        batch_context: &EngineBatchContext,
395        visible: bool,
396        source_range: SourceRange,
397        id_generator: &mut IdGenerator,
398    ) -> Result<(), crate::errors::KclError> {
399        self.batch_modeling_cmd(
400            batch_context,
401            id_generator.next_uuid(),
402            source_range,
403            &ModelingCmd::from(mcmd::EdgeLinesVisible::builder().hidden(!visible).build()),
404        )
405        .await?;
406
407        Ok(())
408    }
409
410    /// Re-run the command to apply the settings.
411    async fn reapply_settings(
412        &self,
413        batch_context: &EngineBatchContext,
414        settings: &crate::ExecutorSettings,
415        source_range: SourceRange,
416        id_generator: &mut IdGenerator,
417        grid_scale_unit: GridScaleBehavior,
418    ) -> Result<(), crate::errors::KclError> {
419        // Set the edge visibility.
420        self.set_edge_visibility(batch_context, settings.highlight_edges, source_range, id_generator)
421            .await?;
422
423        // Send the command to show the grid.
424
425        self.modify_grid(
426            batch_context,
427            !settings.show_grid,
428            grid_scale_unit,
429            source_range,
430            id_generator,
431        )
432        .await?;
433
434        // We do not have commands for changing ssao on the fly.
435
436        // Flush the batch queue, so the settings are applied right away.
437        self.flush_batch(batch_context, false, source_range).await?;
438
439        Ok(())
440    }
441
442    // Add a modeling command to the batch but don't fire it right away.
443    async fn batch_modeling_cmd(
444        &self,
445        batch_context: &EngineBatchContext,
446        id: uuid::Uuid,
447        source_range: SourceRange,
448        cmd: &ModelingCmd,
449    ) -> Result<(), crate::errors::KclError> {
450        let req = WebSocketRequest::ModelingCmdReq(ModelingCmdReq {
451            cmd: cmd.clone(),
452            cmd_id: id.into(),
453        });
454
455        // Add cmd to the batch.
456        batch_context.push(req, source_range).await;
457        self.stats().commands_batched.fetch_add(1, Ordering::Relaxed);
458
459        Ok(())
460    }
461
462    // Add a vector of modeling commands to the batch but don't fire it right away.
463    // This allows you to force them all to be added together in the same order.
464    // When we are running things in parallel this prevents race conditions that might come
465    // if specific commands are run before others.
466    async fn batch_modeling_cmds(
467        &self,
468        batch_context: &EngineBatchContext,
469        source_range: SourceRange,
470        cmds: &[ModelingCmdReq],
471    ) -> Result<(), crate::errors::KclError> {
472        // Add cmds to the batch.
473        let mut extended_cmds = Vec::with_capacity(cmds.len());
474        for cmd in cmds {
475            extended_cmds.push((WebSocketRequest::ModelingCmdReq(cmd.clone()), source_range));
476        }
477        self.stats()
478            .commands_batched
479            .fetch_add(extended_cmds.len(), Ordering::Relaxed);
480        batch_context.extend(extended_cmds).await;
481
482        Ok(())
483    }
484
485    /// Add a command to the batch that needs to be executed at the very end.
486    /// This for stuff like fillets or chamfers where if we execute too soon the
487    /// engine will eat the ID and we can't reference it for other commands.
488    async fn batch_end_cmd(
489        &self,
490        batch_context: &EngineBatchContext,
491        id: uuid::Uuid,
492        source_range: SourceRange,
493        cmd: &ModelingCmd,
494    ) -> Result<(), crate::errors::KclError> {
495        let req = WebSocketRequest::ModelingCmdReq(ModelingCmdReq {
496            cmd: cmd.clone(),
497            cmd_id: id.into(),
498        });
499
500        // Add cmd to the batch end.
501        batch_context.insert_end(id, req, source_range).await;
502        self.stats().commands_batched.fetch_add(1, Ordering::Relaxed);
503        Ok(())
504    }
505
506    /// Send the modeling cmd and wait for the response.
507    async fn send_modeling_cmd(
508        &self,
509        batch_context: &EngineBatchContext,
510        id: uuid::Uuid,
511        source_range: SourceRange,
512        cmd: &ModelingCmd,
513    ) -> Result<OkWebSocketResponseData, crate::errors::KclError> {
514        let mut requests = batch_context.take_batch().await;
515
516        // Add the command to the batch.
517        requests.push((
518            WebSocketRequest::ModelingCmdReq(ModelingCmdReq {
519                cmd: cmd.clone(),
520                cmd_id: id.into(),
521            }),
522            source_range,
523        ));
524        self.stats().commands_batched.fetch_add(1, Ordering::Relaxed);
525
526        // Flush the batch queue.
527        self.run_batch(requests, source_range).await
528    }
529
530    /// Send the modeling cmd async and don't wait for the response.
531    /// Add it to our list of async commands.
532    async fn async_modeling_cmd(
533        &self,
534        id: uuid::Uuid,
535        source_range: SourceRange,
536        cmd: &ModelingCmd,
537    ) -> Result<(), crate::errors::KclError> {
538        // Add the command ID to the list of async commands.
539        self.ids_of_async_commands().write().await.insert(id, source_range);
540
541        // Fire off the command now, but don't wait for the response, we don't care about it.
542        self.inner_fire_modeling_cmd(
543            id,
544            source_range,
545            WebSocketRequest::ModelingCmdReq(ModelingCmdReq {
546                cmd: cmd.clone(),
547                cmd_id: id.into(),
548            }),
549            HashMap::from([(id, source_range)]),
550        )
551        .await?;
552
553        Ok(())
554    }
555
556    /// Run the batch for the specific commands.
557    async fn run_batch(
558        &self,
559        orig_requests: Vec<(WebSocketRequest, SourceRange)>,
560        source_range: SourceRange,
561    ) -> Result<OkWebSocketResponseData, crate::errors::KclError> {
562        // Return early if we have no commands to send.
563        if orig_requests.is_empty() {
564            return Ok(OkWebSocketResponseData::Modeling {
565                modeling_response: OkModelingCmdResponse::Empty {},
566            });
567        }
568
569        let requests: Vec<ModelingCmdReq> = orig_requests
570            .iter()
571            .filter_map(|(val, _)| match val {
572                WebSocketRequest::ModelingCmdReq(ModelingCmdReq { cmd, cmd_id }) => Some(ModelingCmdReq {
573                    cmd: cmd.clone(),
574                    cmd_id: *cmd_id,
575                }),
576                _ => None,
577            })
578            .collect();
579
580        let batched_requests = WebSocketRequest::ModelingCmdBatchReq(ModelingBatch {
581            requests,
582            batch_id: uuid::Uuid::new_v4().into(),
583            responses: true,
584        });
585
586        let final_req = if orig_requests.len() == 1 {
587            // We can unwrap here because we know the batch has only one element.
588            orig_requests.first().unwrap().0.clone()
589        } else {
590            batched_requests
591        };
592
593        // Create the map of original command IDs to source range.
594        // This is for the wasm side, kurt needs it for selections.
595        let mut id_to_source_range = HashMap::new();
596        for (req, range) in orig_requests.iter() {
597            match req {
598                WebSocketRequest::ModelingCmdReq(ModelingCmdReq { cmd: _, cmd_id }) => {
599                    id_to_source_range.insert(Uuid::from(*cmd_id), *range);
600                }
601                _ => {
602                    return Err(KclError::new_engine(KclErrorDetails::new(
603                        format!("The request is not a modeling command: {req:?}"),
604                        vec![*range],
605                    )));
606                }
607            }
608        }
609
610        self.stats().batches_sent.fetch_add(1, Ordering::Relaxed);
611
612        // We pop off the responses to cleanup our mappings.
613        match final_req {
614            WebSocketRequest::ModelingCmdBatchReq(ModelingBatch {
615                ref requests,
616                batch_id,
617                responses: _,
618            }) => {
619                // Get the last command ID.
620                let last_id = requests.last().unwrap().cmd_id;
621                let ws_resp = self
622                    .inner_send_modeling_cmd(batch_id.into(), source_range, final_req, id_to_source_range.clone())
623                    .await?;
624                let response = self.parse_websocket_response(ws_resp, source_range)?;
625
626                // If we have a batch response, we want to return the specific id we care about.
627                if let OkWebSocketResponseData::ModelingBatch { responses } = response {
628                    let responses = responses.into_iter().map(|(k, v)| (Uuid::from(k), v)).collect();
629                    self.parse_batch_responses(last_id.into(), id_to_source_range, responses)
630                } else {
631                    // We should never get here.
632                    Err(KclError::new_engine(KclErrorDetails::new(
633                        format!("Failed to get batch response: {response:?}"),
634                        vec![source_range],
635                    )))
636                }
637            }
638            WebSocketRequest::ModelingCmdReq(ModelingCmdReq { cmd: _, cmd_id }) => {
639                // You are probably wondering why we can't just return the source range we were
640                // passed with the function. Well this is actually really important.
641                // If this is the last command in the batch and there is only one and we've reached
642                // the end of the file, this will trigger a flush batch function, but it will just
643                // send default or the end of the file as it's source range not the origin of the
644                // request so we need the original request source range in case the engine returns
645                // an error.
646                let source_range = id_to_source_range.get(cmd_id.as_ref()).cloned().ok_or_else(|| {
647                    KclError::new_engine(KclErrorDetails::new(
648                        format!("Failed to get source range for command ID: {cmd_id:?}"),
649                        vec![],
650                    ))
651                })?;
652                let ws_resp = self
653                    .inner_send_modeling_cmd(cmd_id.into(), source_range, final_req, id_to_source_range)
654                    .await?;
655                self.parse_websocket_response(ws_resp, source_range)
656            }
657            _ => Err(KclError::new_engine(KclErrorDetails::new(
658                format!("The final request is not a modeling command: {final_req:?}"),
659                vec![source_range],
660            ))),
661        }
662    }
663
664    /// Force flush the batch queue.
665    async fn flush_batch(
666        &self,
667        batch_context: &EngineBatchContext,
668        // Whether or not to flush the end commands as well.
669        // We only do this at the very end of the file.
670        batch_end: bool,
671        source_range: SourceRange,
672    ) -> Result<OkWebSocketResponseData, crate::errors::KclError> {
673        let all_requests = if batch_end {
674            let mut requests = batch_context.take_batch().await;
675            requests.extend(batch_context.take_batch_end().await.values().cloned());
676            requests
677        } else {
678            batch_context.take_batch().await
679        };
680
681        self.run_batch(all_requests, source_range).await
682    }
683
684    async fn make_default_plane(
685        &self,
686        batch_context: &EngineBatchContext,
687        plane_id: uuid::Uuid,
688        info: &PlaneInfo,
689        color: Option<Color>,
690        source_range: SourceRange,
691        id_generator: &mut IdGenerator,
692    ) -> Result<uuid::Uuid, KclError> {
693        // Create new default planes.
694        let default_size = 100.0;
695
696        self.batch_modeling_cmd(
697            batch_context,
698            plane_id,
699            source_range,
700            &ModelingCmd::from(
701                mcmd::MakePlane::builder()
702                    .clobber(false)
703                    .origin(info.origin.into())
704                    .size(LengthUnit(default_size))
705                    .x_axis(info.x_axis.into())
706                    .y_axis(info.y_axis.into())
707                    .hide(true)
708                    .build(),
709            ),
710        )
711        .await?;
712
713        if let Some(color) = color {
714            // Set the color.
715            self.batch_modeling_cmd(
716                batch_context,
717                id_generator.next_uuid(),
718                source_range,
719                &ModelingCmd::from(mcmd::PlaneSetColor::builder().color(color).plane_id(plane_id).build()),
720            )
721            .await?;
722        }
723
724        Ok(plane_id)
725    }
726
727    async fn new_default_planes(
728        &self,
729        batch_context: &EngineBatchContext,
730        id_generator: &mut IdGenerator,
731        source_range: SourceRange,
732    ) -> Result<DefaultPlanes, KclError> {
733        let plane_opacity = 0.1;
734        let plane_settings: Vec<(PlaneName, Uuid, Option<Color>)> = vec![
735            (
736                PlaneName::Xy,
737                id_generator.next_uuid(),
738                Some(Color::from_rgba(0.7, 0.28, 0.28, plane_opacity)),
739            ),
740            (
741                PlaneName::Yz,
742                id_generator.next_uuid(),
743                Some(Color::from_rgba(0.28, 0.7, 0.28, plane_opacity)),
744            ),
745            (
746                PlaneName::Xz,
747                id_generator.next_uuid(),
748                Some(Color::from_rgba(0.28, 0.28, 0.7, plane_opacity)),
749            ),
750            (PlaneName::NegXy, id_generator.next_uuid(), None),
751            (PlaneName::NegYz, id_generator.next_uuid(), None),
752            (PlaneName::NegXz, id_generator.next_uuid(), None),
753        ];
754
755        let mut planes = HashMap::new();
756        for (name, plane_id, color) in plane_settings {
757            let info = DEFAULT_PLANE_INFO.get(&name).ok_or_else(|| {
758                // We should never get here.
759                KclError::new_engine(KclErrorDetails::new(
760                    format!("Failed to get default plane info for: {name:?}"),
761                    vec![source_range],
762                ))
763            })?;
764            planes.insert(
765                name,
766                self.make_default_plane(batch_context, plane_id, info, color, source_range, id_generator)
767                    .await?,
768            );
769        }
770
771        // Flush the batch queue, so these planes are created right away.
772        self.flush_batch(batch_context, false, source_range).await?;
773
774        Ok(DefaultPlanes {
775            xy: planes[&PlaneName::Xy],
776            neg_xy: planes[&PlaneName::NegXy],
777            xz: planes[&PlaneName::Xz],
778            neg_xz: planes[&PlaneName::NegXz],
779            yz: planes[&PlaneName::Yz],
780            neg_yz: planes[&PlaneName::NegYz],
781        })
782    }
783
784    fn parse_websocket_response(
785        &self,
786        response: WebSocketResponse,
787        source_range: SourceRange,
788    ) -> Result<OkWebSocketResponseData, crate::errors::KclError> {
789        match response {
790            WebSocketResponse::Success(success) => Ok(success.resp),
791            WebSocketResponse::Failure(fail) => {
792                let _request_id = fail.request_id;
793                if fail.errors.is_empty() {
794                    return Err(KclError::new_engine(KclErrorDetails::new(
795                        "Failure response with no error details".to_owned(),
796                        vec![source_range],
797                    )));
798                }
799                Err(KclError::new_engine(KclErrorDetails::new(
800                    fail.errors
801                        .iter()
802                        .map(|e| e.message.clone())
803                        .collect::<Vec<_>>()
804                        .join("\n"),
805                    vec![source_range],
806                )))
807            }
808        }
809    }
810
811    fn parse_batch_responses(
812        &self,
813        // The last response we are looking for.
814        id: uuid::Uuid,
815        // The mapping of source ranges to command IDs.
816        id_to_source_range: HashMap<uuid::Uuid, SourceRange>,
817        // The response from the engine.
818        responses: HashMap<uuid::Uuid, BatchResponse>,
819    ) -> Result<OkWebSocketResponseData, crate::errors::KclError> {
820        // Iterate over the responses and check for errors.
821        #[expect(
822            clippy::iter_over_hash_type,
823            reason = "modeling command uses a HashMap and keys are random, so we don't really have a choice"
824        )]
825        for (cmd_id, resp) in responses.iter() {
826            match resp {
827                BatchResponse::Success { response } => {
828                    if cmd_id == &id {
829                        // This is the response we care about.
830                        return Ok(OkWebSocketResponseData::Modeling {
831                            modeling_response: response.clone(),
832                        });
833                    } else {
834                        // Continue the loop if this is not the response we care about.
835                        continue;
836                    }
837                }
838                BatchResponse::Failure { errors } => {
839                    // Get the source range for the command.
840                    let source_range = id_to_source_range.get(cmd_id).cloned().ok_or_else(|| {
841                        KclError::new_engine(KclErrorDetails::new(
842                            format!("Failed to get source range for command ID: {cmd_id:?}"),
843                            vec![],
844                        ))
845                    })?;
846                    if errors.is_empty() {
847                        return Err(KclError::new_engine(KclErrorDetails::new(
848                            "Failure response for batch with no error details".to_owned(),
849                            vec![source_range],
850                        )));
851                    }
852                    return Err(KclError::new_engine(KclErrorDetails::new(
853                        errors.iter().map(|e| e.message.clone()).collect::<Vec<_>>().join("\n"),
854                        vec![source_range],
855                    )));
856                }
857            }
858        }
859
860        // Return an error that we did not get an error or the response we wanted.
861        // This should never happen but who knows.
862        Err(KclError::new_engine(KclErrorDetails::new(
863            format!("Failed to find response for command ID: {id:?}"),
864            vec![],
865        )))
866    }
867
868    async fn modify_grid(
869        &self,
870        batch_context: &EngineBatchContext,
871        hidden: bool,
872        grid_scale_behavior: GridScaleBehavior,
873        source_range: SourceRange,
874        id_generator: &mut IdGenerator,
875    ) -> Result<(), KclError> {
876        // Hide/show the grid.
877        self.batch_modeling_cmd(
878            batch_context,
879            id_generator.next_uuid(),
880            source_range,
881            &ModelingCmd::from(
882                mcmd::ObjectVisible::builder()
883                    .hidden(hidden)
884                    .object_id(*GRID_OBJECT_ID)
885                    .build(),
886            ),
887        )
888        .await?;
889
890        self.batch_modeling_cmd(
891            batch_context,
892            id_generator.next_uuid(),
893            source_range,
894            &grid_scale_behavior.into_modeling_cmd(),
895        )
896        .await?;
897
898        // Hide/show the grid scale text.
899        self.batch_modeling_cmd(
900            batch_context,
901            id_generator.next_uuid(),
902            source_range,
903            &ModelingCmd::from(
904                mcmd::ObjectVisible::builder()
905                    .hidden(hidden)
906                    .object_id(*GRID_SCALE_TEXT_OBJECT_ID)
907                    .build(),
908            ),
909        )
910        .await?;
911
912        Ok(())
913    }
914
915    /// Get session data, if it has been received.
916    /// Returns None if the server never sent it.
917    async fn get_session_data(&self) -> Option<ModelingSessionData> {
918        None
919    }
920
921    /// Close the engine connection and wait for it to finish.
922    async fn close(&self);
923}
924
925#[derive(Debug, Hash, Eq, Copy, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, Display, FromStr)]
926#[ts(export)]
927#[serde(rename_all = "camelCase")]
928pub enum PlaneName {
929    /// The XY plane.
930    #[display("XY")]
931    Xy,
932    /// The opposite side of the XY plane.
933    #[display("-XY")]
934    NegXy,
935    /// The XZ plane.
936    #[display("XZ")]
937    Xz,
938    /// The opposite side of the XZ plane.
939    #[display("-XZ")]
940    NegXz,
941    /// The YZ plane.
942    #[display("YZ")]
943    Yz,
944    /// The opposite side of the YZ plane.
945    #[display("-YZ")]
946    NegYz,
947}
948
949/// Create a new zoo api client.
950#[cfg(not(target_arch = "wasm32"))]
951pub fn new_zoo_client(token: Option<String>, engine_addr: Option<String>) -> anyhow::Result<kittycad::Client> {
952    let user_agent = concat!(env!("CARGO_PKG_NAME"), ".rs/", env!("CARGO_PKG_VERSION"),);
953    let http_client = reqwest::Client::builder()
954        .user_agent(user_agent)
955        // For file conversions we need this to be long.
956        .timeout(std::time::Duration::from_secs(600))
957        .connect_timeout(std::time::Duration::from_secs(60));
958    let ws_client = reqwest::Client::builder()
959        .user_agent(user_agent)
960        // For file conversions we need this to be long.
961        .timeout(std::time::Duration::from_secs(600))
962        .connect_timeout(std::time::Duration::from_secs(60))
963        .connection_verbose(true)
964        .tcp_keepalive(std::time::Duration::from_secs(600))
965        .http1_only();
966
967    let zoo_token_env = std::env::var("ZOO_API_TOKEN");
968
969    let token = if let Some(token) = token {
970        token
971    } else if let Ok(token) = std::env::var("KITTYCAD_API_TOKEN") {
972        if let Ok(zoo_token) = zoo_token_env
973            && zoo_token != token
974        {
975            return Err(anyhow::anyhow!(
976                "Both environment variables KITTYCAD_API_TOKEN=`{}` and ZOO_API_TOKEN=`{}` are set. Use only one.",
977                token,
978                zoo_token
979            ));
980        }
981        token
982    } else if let Ok(token) = zoo_token_env {
983        token
984    } else {
985        return Err(anyhow::anyhow!(
986            "No API token found in environment variables. Use ZOO_API_TOKEN"
987        ));
988    };
989
990    // Create the client.
991    let mut client = kittycad::Client::new_from_reqwest(token, http_client, ws_client);
992    // Set an engine address if it's set.
993    let kittycad_host_env = std::env::var("KITTYCAD_HOST");
994    if let Some(addr) = engine_addr {
995        client.set_base_url(addr);
996    } else if let Ok(addr) = std::env::var("ZOO_HOST") {
997        if let Ok(kittycad_host) = kittycad_host_env
998            && kittycad_host != addr
999        {
1000            return Err(anyhow::anyhow!(
1001                "Both environment variables KITTYCAD_HOST=`{}` and ZOO_HOST=`{}` are set. Use only one.",
1002                kittycad_host,
1003                addr
1004            ));
1005        }
1006        client.set_base_url(addr);
1007    } else if let Ok(addr) = kittycad_host_env {
1008        client.set_base_url(addr);
1009    }
1010
1011    Ok(client)
1012}
1013
1014#[derive(Copy, Clone, Debug)]
1015pub enum GridScaleBehavior {
1016    ScaleWithZoom,
1017    Fixed(Option<kcmc::units::UnitLength>),
1018}
1019
1020impl GridScaleBehavior {
1021    fn into_modeling_cmd(self) -> ModelingCmd {
1022        const NUMBER_OF_GRID_COLUMNS: f32 = 10.0;
1023        match self {
1024            GridScaleBehavior::ScaleWithZoom => ModelingCmd::from(mcmd::SetGridAutoScale::builder().build()),
1025            GridScaleBehavior::Fixed(unit_length) => ModelingCmd::from(
1026                mcmd::SetGridScale::builder()
1027                    .value(NUMBER_OF_GRID_COLUMNS)
1028                    .units(unit_length.unwrap_or(kcmc::units::UnitLength::Millimeters))
1029                    .build(),
1030            ),
1031        }
1032    }
1033}