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        // The previous 60s ceiling here was too aggressive for long-running
331        // engine commands - notably large STEP / B-rep imports, which the
332        // engine itself routinely takes several minutes to process. When the
333        // ceiling fired first the user got a generic "async command timed
334        // out" message and the eventual engine response (success OR error)
335        // was discarded, masking the real outcome. 600s (10 min) gives the
336        // engine room to finish or to surface its own error.
337        const ASYNC_CMD_TIMEOUT_SECS: u64 = 600;
338        let current_time = Instant::now();
339        while current_time.elapsed().as_secs() < ASYNC_CMD_TIMEOUT_SECS {
340            let responses = self.responses().read().await.clone();
341            let Some(resp) = responses.get(&id) else {
342                // Yield to the event loop so that we don’t block the UI thread.
343                // No seriously WE DO NOT WANT TO PAUSE THE WHOLE APP ON THE JS SIDE.
344                #[cfg(target_arch = "wasm32")]
345                {
346                    let duration = web_time::Duration::from_millis(1);
347                    wasm_timer::Delay::new(duration).await.map_err(|err| {
348                        KclError::new_internal(KclErrorDetails::new(
349                            format!("Failed to sleep: {:?}", err),
350                            vec![source_range],
351                        ))
352                    })?;
353                }
354                #[cfg(not(target_arch = "wasm32"))]
355                tokio::task::yield_now().await;
356                continue;
357            };
358
359            // If the response is an error, return it.
360            // Parsing will do that and we can ignore the result, we don't care.
361            let response = self.parse_websocket_response(resp.clone(), source_range)?;
362            return Ok(response);
363        }
364
365        Err(KclError::new_engine(KclErrorDetails::new(
366            format!(
367                "async command timed out after {ASYNC_CMD_TIMEOUT_SECS}s (client-side ceiling, not an engine error)"
368            ),
369            vec![source_range],
370        )))
371    }
372
373    /// Ensure ALL async commands have been completed.
374    async fn ensure_async_commands_completed(&self, batch_context: &EngineBatchContext) -> Result<(), KclError> {
375        // Check if all async commands have been completed.
376        let ids = self.take_ids_of_async_commands().await;
377
378        // Try to get them from the responses.
379        for (id, source_range) in ids {
380            self.ensure_async_command_completed(id, Some(source_range)).await?;
381        }
382
383        // Make sure we check for all async tasks as well.
384        // The reason why we ignore the error here is that, if a model fillets an edge
385        // we previously called something on, it might no longer exist. In which case,
386        // the artifact graph won't care either if its gone since you can't select it
387        // anymore anyways.
388        if let Err(err) = self.async_tasks().join_all().await {
389            crate::log::logln!(
390                "Error waiting for async tasks (this is typically fine and just means that an edge became something else): {:?}",
391                err
392            );
393        }
394
395        // Flush the batch to make sure nothing remains.
396        self.flush_batch(batch_context, true, SourceRange::default()).await?;
397
398        Ok(())
399    }
400
401    /// Set the visibility of edges.
402    async fn set_edge_visibility(
403        &self,
404        batch_context: &EngineBatchContext,
405        visible: bool,
406        source_range: SourceRange,
407        id_generator: &mut IdGenerator,
408    ) -> Result<(), crate::errors::KclError> {
409        self.batch_modeling_cmd(
410            batch_context,
411            id_generator.next_uuid(),
412            source_range,
413            &ModelingCmd::from(mcmd::EdgeLinesVisible::builder().hidden(!visible).build()),
414        )
415        .await?;
416
417        Ok(())
418    }
419
420    /// Re-run the command to apply the settings.
421    async fn reapply_settings(
422        &self,
423        batch_context: &EngineBatchContext,
424        settings: &crate::ExecutorSettings,
425        source_range: SourceRange,
426        id_generator: &mut IdGenerator,
427        grid_scale_unit: GridScaleBehavior,
428    ) -> Result<(), crate::errors::KclError> {
429        // Set the edge visibility.
430        self.set_edge_visibility(batch_context, settings.highlight_edges, source_range, id_generator)
431            .await?;
432
433        // Send the command to show the grid.
434
435        self.modify_grid(
436            batch_context,
437            !settings.show_grid,
438            grid_scale_unit,
439            source_range,
440            id_generator,
441        )
442        .await?;
443
444        // We do not have commands for changing ssao on the fly.
445
446        // Flush the batch queue, so the settings are applied right away.
447        self.flush_batch(batch_context, false, source_range).await?;
448
449        Ok(())
450    }
451
452    // Add a modeling command to the batch but don't fire it right away.
453    async fn batch_modeling_cmd(
454        &self,
455        batch_context: &EngineBatchContext,
456        id: uuid::Uuid,
457        source_range: SourceRange,
458        cmd: &ModelingCmd,
459    ) -> Result<(), crate::errors::KclError> {
460        let req = WebSocketRequest::ModelingCmdReq(ModelingCmdReq {
461            cmd: cmd.clone(),
462            cmd_id: id.into(),
463        });
464
465        // Add cmd to the batch.
466        batch_context.push(req, source_range).await;
467        self.stats().commands_batched.fetch_add(1, Ordering::Relaxed);
468
469        Ok(())
470    }
471
472    // Add a vector of modeling commands to the batch but don't fire it right away.
473    // This allows you to force them all to be added together in the same order.
474    // When we are running things in parallel this prevents race conditions that might come
475    // if specific commands are run before others.
476    async fn batch_modeling_cmds(
477        &self,
478        batch_context: &EngineBatchContext,
479        source_range: SourceRange,
480        cmds: &[ModelingCmdReq],
481    ) -> Result<(), crate::errors::KclError> {
482        // Add cmds to the batch.
483        let mut extended_cmds = Vec::with_capacity(cmds.len());
484        for cmd in cmds {
485            extended_cmds.push((WebSocketRequest::ModelingCmdReq(cmd.clone()), source_range));
486        }
487        self.stats()
488            .commands_batched
489            .fetch_add(extended_cmds.len(), Ordering::Relaxed);
490        batch_context.extend(extended_cmds).await;
491
492        Ok(())
493    }
494
495    /// Add a command to the batch that needs to be executed at the very end.
496    /// This for stuff like fillets or chamfers where if we execute too soon the
497    /// engine will eat the ID and we can't reference it for other commands.
498    async fn batch_end_cmd(
499        &self,
500        batch_context: &EngineBatchContext,
501        id: uuid::Uuid,
502        source_range: SourceRange,
503        cmd: &ModelingCmd,
504    ) -> Result<(), crate::errors::KclError> {
505        let req = WebSocketRequest::ModelingCmdReq(ModelingCmdReq {
506            cmd: cmd.clone(),
507            cmd_id: id.into(),
508        });
509
510        // Add cmd to the batch end.
511        batch_context.insert_end(id, req, source_range).await;
512        self.stats().commands_batched.fetch_add(1, Ordering::Relaxed);
513        Ok(())
514    }
515
516    /// Send the modeling cmd and wait for the response.
517    async fn send_modeling_cmd(
518        &self,
519        batch_context: &EngineBatchContext,
520        id: uuid::Uuid,
521        source_range: SourceRange,
522        cmd: &ModelingCmd,
523    ) -> Result<OkWebSocketResponseData, crate::errors::KclError> {
524        let mut requests = batch_context.take_batch().await;
525
526        // Add the command to the batch.
527        requests.push((
528            WebSocketRequest::ModelingCmdReq(ModelingCmdReq {
529                cmd: cmd.clone(),
530                cmd_id: id.into(),
531            }),
532            source_range,
533        ));
534        self.stats().commands_batched.fetch_add(1, Ordering::Relaxed);
535
536        // Flush the batch queue.
537        self.run_batch(requests, source_range).await
538    }
539
540    /// Send the modeling cmd async and don't wait for the response.
541    /// Add it to our list of async commands.
542    async fn async_modeling_cmd(
543        &self,
544        id: uuid::Uuid,
545        source_range: SourceRange,
546        cmd: &ModelingCmd,
547    ) -> Result<(), crate::errors::KclError> {
548        // Add the command ID to the list of async commands.
549        self.ids_of_async_commands().write().await.insert(id, source_range);
550
551        // Fire off the command now, but don't wait for the response, we don't care about it.
552        self.inner_fire_modeling_cmd(
553            id,
554            source_range,
555            WebSocketRequest::ModelingCmdReq(ModelingCmdReq {
556                cmd: cmd.clone(),
557                cmd_id: id.into(),
558            }),
559            HashMap::from([(id, source_range)]),
560        )
561        .await?;
562
563        Ok(())
564    }
565
566    /// Run the batch for the specific commands.
567    async fn run_batch(
568        &self,
569        orig_requests: Vec<(WebSocketRequest, SourceRange)>,
570        source_range: SourceRange,
571    ) -> Result<OkWebSocketResponseData, crate::errors::KclError> {
572        // Return early if we have no commands to send.
573        if orig_requests.is_empty() {
574            return Ok(OkWebSocketResponseData::Modeling {
575                modeling_response: OkModelingCmdResponse::Empty {},
576            });
577        }
578
579        let requests: Vec<ModelingCmdReq> = orig_requests
580            .iter()
581            .filter_map(|(val, _)| match val {
582                WebSocketRequest::ModelingCmdReq(ModelingCmdReq { cmd, cmd_id }) => Some(ModelingCmdReq {
583                    cmd: cmd.clone(),
584                    cmd_id: *cmd_id,
585                }),
586                _ => None,
587            })
588            .collect();
589
590        let batched_requests = WebSocketRequest::ModelingCmdBatchReq(ModelingBatch {
591            requests,
592            batch_id: uuid::Uuid::new_v4().into(),
593            responses: true,
594        });
595
596        let final_req = if orig_requests.len() == 1 {
597            // We can unwrap here because we know the batch has only one element.
598            orig_requests.first().unwrap().0.clone()
599        } else {
600            batched_requests
601        };
602
603        // Create the map of original command IDs to source range.
604        // This is for the wasm side, kurt needs it for selections.
605        let mut id_to_source_range = HashMap::new();
606        for (req, range) in orig_requests.iter() {
607            match req {
608                WebSocketRequest::ModelingCmdReq(ModelingCmdReq { cmd: _, cmd_id }) => {
609                    id_to_source_range.insert(Uuid::from(*cmd_id), *range);
610                }
611                _ => {
612                    return Err(KclError::new_engine(KclErrorDetails::new(
613                        format!("The request is not a modeling command: {req:?}"),
614                        vec![*range],
615                    )));
616                }
617            }
618        }
619
620        self.stats().batches_sent.fetch_add(1, Ordering::Relaxed);
621
622        // We pop off the responses to cleanup our mappings.
623        match final_req {
624            WebSocketRequest::ModelingCmdBatchReq(ModelingBatch {
625                ref requests,
626                batch_id,
627                responses: _,
628            }) => {
629                // Get the last command ID.
630                let last_id = requests.last().unwrap().cmd_id;
631                let ws_resp = self
632                    .inner_send_modeling_cmd(batch_id.into(), source_range, final_req, id_to_source_range.clone())
633                    .await?;
634                let response = self.parse_websocket_response(ws_resp, source_range)?;
635
636                // If we have a batch response, we want to return the specific id we care about.
637                if let OkWebSocketResponseData::ModelingBatch { responses } = response {
638                    let responses = responses.into_iter().map(|(k, v)| (Uuid::from(k), v)).collect();
639                    self.parse_batch_responses(last_id.into(), id_to_source_range, responses)
640                } else {
641                    // We should never get here.
642                    Err(KclError::new_engine(KclErrorDetails::new(
643                        format!("Failed to get batch response: {response:?}"),
644                        vec![source_range],
645                    )))
646                }
647            }
648            WebSocketRequest::ModelingCmdReq(ModelingCmdReq { cmd: _, cmd_id }) => {
649                // You are probably wondering why we can't just return the source range we were
650                // passed with the function. Well this is actually really important.
651                // If this is the last command in the batch and there is only one and we've reached
652                // the end of the file, this will trigger a flush batch function, but it will just
653                // send default or the end of the file as it's source range not the origin of the
654                // request so we need the original request source range in case the engine returns
655                // an error.
656                let source_range = id_to_source_range.get(cmd_id.as_ref()).cloned().ok_or_else(|| {
657                    KclError::new_engine(KclErrorDetails::new(
658                        format!("Failed to get source range for command ID: {cmd_id:?}"),
659                        vec![],
660                    ))
661                })?;
662                let ws_resp = self
663                    .inner_send_modeling_cmd(cmd_id.into(), source_range, final_req, id_to_source_range)
664                    .await?;
665                self.parse_websocket_response(ws_resp, source_range)
666            }
667            _ => Err(KclError::new_engine(KclErrorDetails::new(
668                format!("The final request is not a modeling command: {final_req:?}"),
669                vec![source_range],
670            ))),
671        }
672    }
673
674    /// Force flush the batch queue.
675    async fn flush_batch(
676        &self,
677        batch_context: &EngineBatchContext,
678        // Whether or not to flush the end commands as well.
679        // We only do this at the very end of the file.
680        batch_end: bool,
681        source_range: SourceRange,
682    ) -> Result<OkWebSocketResponseData, crate::errors::KclError> {
683        let all_requests = if batch_end {
684            let mut requests = batch_context.take_batch().await;
685            requests.extend(batch_context.take_batch_end().await.values().cloned());
686            requests
687        } else {
688            batch_context.take_batch().await
689        };
690
691        self.run_batch(all_requests, source_range).await
692    }
693
694    async fn make_default_plane(
695        &self,
696        batch_context: &EngineBatchContext,
697        plane_id: uuid::Uuid,
698        info: &PlaneInfo,
699        color: Option<Color>,
700        source_range: SourceRange,
701        id_generator: &mut IdGenerator,
702    ) -> Result<uuid::Uuid, KclError> {
703        // Create new default planes.
704        let default_size = 100.0;
705
706        self.batch_modeling_cmd(
707            batch_context,
708            plane_id,
709            source_range,
710            &ModelingCmd::from(
711                mcmd::MakePlane::builder()
712                    .clobber(false)
713                    .origin(info.origin.into())
714                    .size(LengthUnit(default_size))
715                    .x_axis(info.x_axis.into())
716                    .y_axis(info.y_axis.into())
717                    .hide(true)
718                    .build(),
719            ),
720        )
721        .await?;
722
723        if let Some(color) = color {
724            // Set the color.
725            self.batch_modeling_cmd(
726                batch_context,
727                id_generator.next_uuid(),
728                source_range,
729                &ModelingCmd::from(mcmd::PlaneSetColor::builder().color(color).plane_id(plane_id).build()),
730            )
731            .await?;
732        }
733
734        Ok(plane_id)
735    }
736
737    async fn new_default_planes(
738        &self,
739        batch_context: &EngineBatchContext,
740        id_generator: &mut IdGenerator,
741        source_range: SourceRange,
742    ) -> Result<DefaultPlanes, KclError> {
743        let plane_opacity = 0.1;
744        let plane_settings: Vec<(PlaneName, Uuid, Option<Color>)> = vec![
745            (
746                PlaneName::Xy,
747                id_generator.next_uuid(),
748                Some(Color::from_rgba(0.7, 0.28, 0.28, plane_opacity)),
749            ),
750            (
751                PlaneName::Yz,
752                id_generator.next_uuid(),
753                Some(Color::from_rgba(0.28, 0.7, 0.28, plane_opacity)),
754            ),
755            (
756                PlaneName::Xz,
757                id_generator.next_uuid(),
758                Some(Color::from_rgba(0.28, 0.28, 0.7, plane_opacity)),
759            ),
760            (PlaneName::NegXy, id_generator.next_uuid(), None),
761            (PlaneName::NegYz, id_generator.next_uuid(), None),
762            (PlaneName::NegXz, id_generator.next_uuid(), None),
763        ];
764
765        let mut planes = HashMap::new();
766        for (name, plane_id, color) in plane_settings {
767            let info = DEFAULT_PLANE_INFO.get(&name).ok_or_else(|| {
768                // We should never get here.
769                KclError::new_engine(KclErrorDetails::new(
770                    format!("Failed to get default plane info for: {name:?}"),
771                    vec![source_range],
772                ))
773            })?;
774            planes.insert(
775                name,
776                self.make_default_plane(batch_context, plane_id, info, color, source_range, id_generator)
777                    .await?,
778            );
779        }
780
781        // Flush the batch queue, so these planes are created right away.
782        self.flush_batch(batch_context, false, source_range).await?;
783
784        Ok(DefaultPlanes {
785            xy: planes[&PlaneName::Xy],
786            neg_xy: planes[&PlaneName::NegXy],
787            xz: planes[&PlaneName::Xz],
788            neg_xz: planes[&PlaneName::NegXz],
789            yz: planes[&PlaneName::Yz],
790            neg_yz: planes[&PlaneName::NegYz],
791        })
792    }
793
794    fn parse_websocket_response(
795        &self,
796        response: WebSocketResponse,
797        source_range: SourceRange,
798    ) -> Result<OkWebSocketResponseData, crate::errors::KclError> {
799        match response {
800            WebSocketResponse::Success(success) => Ok(success.resp),
801            WebSocketResponse::Failure(fail) => {
802                let _request_id = fail.request_id;
803                if fail.errors.is_empty() {
804                    return Err(KclError::new_engine(KclErrorDetails::new(
805                        "Failure response with no error details".to_owned(),
806                        vec![source_range],
807                    )));
808                }
809                Err(KclError::new_engine(KclErrorDetails::new(
810                    fail.errors
811                        .iter()
812                        .map(|e| e.message.clone())
813                        .collect::<Vec<_>>()
814                        .join("\n"),
815                    vec![source_range],
816                )))
817            }
818        }
819    }
820
821    fn parse_batch_responses(
822        &self,
823        // The last response we are looking for.
824        id: uuid::Uuid,
825        // The mapping of source ranges to command IDs.
826        id_to_source_range: HashMap<uuid::Uuid, SourceRange>,
827        // The response from the engine.
828        responses: HashMap<uuid::Uuid, BatchResponse>,
829    ) -> Result<OkWebSocketResponseData, crate::errors::KclError> {
830        // Iterate over the responses and check for errors.
831        #[expect(
832            clippy::iter_over_hash_type,
833            reason = "modeling command uses a HashMap and keys are random, so we don't really have a choice"
834        )]
835        for (cmd_id, resp) in responses.iter() {
836            match resp {
837                BatchResponse::Success { response } => {
838                    if cmd_id == &id {
839                        // This is the response we care about.
840                        return Ok(OkWebSocketResponseData::Modeling {
841                            modeling_response: response.clone(),
842                        });
843                    } else {
844                        // Continue the loop if this is not the response we care about.
845                        continue;
846                    }
847                }
848                BatchResponse::Failure { errors } => {
849                    // Get the source range for the command.
850                    let source_range = id_to_source_range.get(cmd_id).cloned().ok_or_else(|| {
851                        KclError::new_engine(KclErrorDetails::new(
852                            format!("Failed to get source range for command ID: {cmd_id:?}"),
853                            vec![],
854                        ))
855                    })?;
856                    if errors.is_empty() {
857                        return Err(KclError::new_engine(KclErrorDetails::new(
858                            "Failure response for batch with no error details".to_owned(),
859                            vec![source_range],
860                        )));
861                    }
862                    return Err(KclError::new_engine(KclErrorDetails::new(
863                        errors.iter().map(|e| e.message.clone()).collect::<Vec<_>>().join("\n"),
864                        vec![source_range],
865                    )));
866                }
867            }
868        }
869
870        // Return an error that we did not get an error or the response we wanted.
871        // This should never happen but who knows.
872        Err(KclError::new_engine(KclErrorDetails::new(
873            format!("Failed to find response for command ID: {id:?}"),
874            vec![],
875        )))
876    }
877
878    async fn modify_grid(
879        &self,
880        batch_context: &EngineBatchContext,
881        hidden: bool,
882        grid_scale_behavior: GridScaleBehavior,
883        source_range: SourceRange,
884        id_generator: &mut IdGenerator,
885    ) -> Result<(), KclError> {
886        // Hide/show the grid.
887        self.batch_modeling_cmd(
888            batch_context,
889            id_generator.next_uuid(),
890            source_range,
891            &ModelingCmd::from(
892                mcmd::ObjectVisible::builder()
893                    .hidden(hidden)
894                    .object_id(*GRID_OBJECT_ID)
895                    .build(),
896            ),
897        )
898        .await?;
899
900        self.batch_modeling_cmd(
901            batch_context,
902            id_generator.next_uuid(),
903            source_range,
904            &grid_scale_behavior.into_modeling_cmd(),
905        )
906        .await?;
907
908        // Hide/show the grid scale text.
909        self.batch_modeling_cmd(
910            batch_context,
911            id_generator.next_uuid(),
912            source_range,
913            &ModelingCmd::from(
914                mcmd::ObjectVisible::builder()
915                    .hidden(hidden)
916                    .object_id(*GRID_SCALE_TEXT_OBJECT_ID)
917                    .build(),
918            ),
919        )
920        .await?;
921
922        Ok(())
923    }
924
925    /// Get session data, if it has been received.
926    /// Returns None if the server never sent it.
927    async fn get_session_data(&self) -> Option<ModelingSessionData> {
928        None
929    }
930
931    /// Close the engine connection and wait for it to finish.
932    async fn close(&self);
933}
934
935#[derive(Debug, Hash, Eq, Copy, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, Display, FromStr)]
936#[ts(export)]
937#[serde(rename_all = "camelCase")]
938pub enum PlaneName {
939    /// The XY plane.
940    #[display("XY")]
941    Xy,
942    /// The opposite side of the XY plane.
943    #[display("-XY")]
944    NegXy,
945    /// The XZ plane.
946    #[display("XZ")]
947    Xz,
948    /// The opposite side of the XZ plane.
949    #[display("-XZ")]
950    NegXz,
951    /// The YZ plane.
952    #[display("YZ")]
953    Yz,
954    /// The opposite side of the YZ plane.
955    #[display("-YZ")]
956    NegYz,
957}
958
959/// Create a new zoo api client.
960#[cfg(not(target_arch = "wasm32"))]
961pub fn new_zoo_client(token: Option<String>, engine_addr: Option<String>) -> anyhow::Result<kittycad::Client> {
962    let user_agent = concat!(env!("CARGO_PKG_NAME"), ".rs/", env!("CARGO_PKG_VERSION"),);
963    let http_client = reqwest::Client::builder()
964        .user_agent(user_agent)
965        // For file conversions we need this to be long.
966        .timeout(std::time::Duration::from_secs(600))
967        .connect_timeout(std::time::Duration::from_secs(60));
968    let ws_client = reqwest::Client::builder()
969        .user_agent(user_agent)
970        // For file conversions we need this to be long.
971        .timeout(std::time::Duration::from_secs(600))
972        .connect_timeout(std::time::Duration::from_secs(60))
973        .connection_verbose(true)
974        .tcp_keepalive(std::time::Duration::from_secs(600))
975        .http1_only();
976
977    let zoo_token_env = std::env::var("ZOO_API_TOKEN");
978
979    let token = if let Some(token) = token {
980        token
981    } else if let Ok(token) = std::env::var("KITTYCAD_API_TOKEN") {
982        if let Ok(zoo_token) = zoo_token_env
983            && zoo_token != token
984        {
985            return Err(anyhow::anyhow!(
986                "Both environment variables KITTYCAD_API_TOKEN=`{}` and ZOO_API_TOKEN=`{}` are set. Use only one.",
987                token,
988                zoo_token
989            ));
990        }
991        token
992    } else if let Ok(token) = zoo_token_env {
993        token
994    } else {
995        return Err(anyhow::anyhow!(
996            "No API token found in environment variables. Use ZOO_API_TOKEN"
997        ));
998    };
999
1000    // Create the client.
1001    let mut client = kittycad::Client::new_from_reqwest(token, http_client, ws_client);
1002    // Set an engine address if it's set.
1003    let kittycad_host_env = std::env::var("KITTYCAD_HOST");
1004    if let Some(addr) = engine_addr {
1005        client.set_base_url(addr);
1006    } else if let Ok(addr) = std::env::var("ZOO_HOST") {
1007        if let Ok(kittycad_host) = kittycad_host_env
1008            && kittycad_host != addr
1009        {
1010            return Err(anyhow::anyhow!(
1011                "Both environment variables KITTYCAD_HOST=`{}` and ZOO_HOST=`{}` are set. Use only one.",
1012                kittycad_host,
1013                addr
1014            ));
1015        }
1016        client.set_base_url(addr);
1017    } else if let Ok(addr) = kittycad_host_env {
1018        client.set_base_url(addr);
1019    }
1020
1021    Ok(client)
1022}
1023
1024#[derive(Copy, Clone, Debug)]
1025pub enum GridScaleBehavior {
1026    ScaleWithZoom,
1027    Fixed(Option<kcmc::units::UnitLength>),
1028}
1029
1030impl GridScaleBehavior {
1031    fn into_modeling_cmd(self) -> ModelingCmd {
1032        const NUMBER_OF_GRID_COLUMNS: f32 = 10.0;
1033        match self {
1034            GridScaleBehavior::ScaleWithZoom => ModelingCmd::from(mcmd::SetGridAutoScale::builder().build()),
1035            GridScaleBehavior::Fixed(unit_length) => ModelingCmd::from(
1036                mcmd::SetGridScale::builder()
1037                    .value(NUMBER_OF_GRID_COLUMNS)
1038                    .units(unit_length.unwrap_or(kcmc::units::UnitLength::Millimeters))
1039                    .build(),
1040            ),
1041        }
1042    }
1043}