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