1#[cfg(not(target_arch = "wasm32"))]
4#[cfg(feature = "engine")]
5pub mod conn;
6pub mod conn_mock;
7#[cfg(target_arch = "wasm32")]
8#[cfg(feature = "engine")]
9pub mod conn_wasm;
10
11use std::{collections::HashMap, sync::Arc};
12
13use indexmap::IndexMap;
14use kcmc::{
15 each_cmd as mcmd,
16 id::ModelingCmdId,
17 length_unit::LengthUnit,
18 ok_response::OkModelingCmdResponse,
19 shared::Color,
20 websocket::{
21 BatchResponse, ModelingBatch, ModelingCmdReq, ModelingSessionData, OkWebSocketResponseData, WebSocketRequest,
22 WebSocketResponse,
23 },
24 ModelingCmd,
25};
26use kittycad_modeling_cmds as kcmc;
27use schemars::JsonSchema;
28use serde::{Deserialize, Serialize};
29use tokio::sync::RwLock;
30use uuid::Uuid;
31
32use crate::{
33 errors::{KclError, KclErrorDetails},
34 execution::{ArtifactCommand, DefaultPlanes, IdGenerator, Point3d},
35 SourceRange,
36};
37
38lazy_static::lazy_static! {
39 pub static ref GRID_OBJECT_ID: uuid::Uuid = uuid::Uuid::parse_str("cfa78409-653d-4c26-96f1-7c45fb784840").unwrap();
40
41 pub static ref GRID_SCALE_TEXT_OBJECT_ID: uuid::Uuid = uuid::Uuid::parse_str("10782f33-f588-4668-8bcd-040502d26590").unwrap();
42}
43
44#[derive(Debug, Default, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, ts_rs::TS, JsonSchema)]
47#[ts(export)]
48#[serde(rename_all = "camelCase")]
49pub enum ExecutionKind {
50 #[default]
51 Normal,
52 Isolated,
53}
54
55impl ExecutionKind {
56 pub fn is_isolated(&self) -> bool {
57 matches!(self, ExecutionKind::Isolated)
58 }
59}
60
61#[async_trait::async_trait]
62pub trait EngineManager: std::fmt::Debug + Send + Sync + 'static {
63 fn batch(&self) -> Arc<RwLock<Vec<(WebSocketRequest, SourceRange)>>>;
65
66 fn batch_end(&self) -> Arc<RwLock<IndexMap<uuid::Uuid, (WebSocketRequest, SourceRange)>>>;
68
69 fn responses(&self) -> Arc<RwLock<IndexMap<Uuid, WebSocketResponse>>>;
71
72 fn artifact_commands(&self) -> Arc<RwLock<Vec<ArtifactCommand>>>;
74
75 async fn clear_artifact_commands(&self) {
77 self.artifact_commands().write().await.clear();
78 }
79
80 async fn take_artifact_commands(&self) -> Vec<ArtifactCommand> {
82 std::mem::take(&mut *self.artifact_commands().write().await)
83 }
84
85 async fn take_responses(&self) -> IndexMap<Uuid, WebSocketResponse> {
87 std::mem::take(&mut *self.responses().write().await)
88 }
89
90 async fn execution_kind(&self) -> ExecutionKind;
92
93 async fn replace_execution_kind(&self, execution_kind: ExecutionKind) -> ExecutionKind;
96
97 async fn default_planes(
99 &self,
100 id_generator: &mut IdGenerator,
101 _source_range: SourceRange,
102 ) -> Result<DefaultPlanes, crate::errors::KclError>;
103
104 async fn clear_scene_post_hook(
107 &self,
108 id_generator: &mut IdGenerator,
109 source_range: SourceRange,
110 ) -> Result<(), crate::errors::KclError>;
111
112 async fn inner_send_modeling_cmd(
114 &self,
115 id: uuid::Uuid,
116 source_range: SourceRange,
117 cmd: WebSocketRequest,
118 id_to_source_range: HashMap<uuid::Uuid, SourceRange>,
119 ) -> Result<kcmc::websocket::WebSocketResponse, crate::errors::KclError>;
120
121 async fn clear_scene(
122 &self,
123 id_generator: &mut IdGenerator,
124 source_range: SourceRange,
125 ) -> Result<(), crate::errors::KclError> {
126 self.batch_modeling_cmd(
127 uuid::Uuid::new_v4(),
128 source_range,
129 &ModelingCmd::SceneClearAll(mcmd::SceneClearAll::default()),
130 )
131 .await?;
132
133 self.flush_batch(false, source_range).await?;
136
137 self.clear_artifact_commands().await;
140
141 self.clear_scene_post_hook(id_generator, source_range).await?;
143
144 Ok(())
145 }
146
147 async fn set_edge_visibility(
149 &self,
150 visible: bool,
151 source_range: SourceRange,
152 ) -> Result<(), crate::errors::KclError> {
153 self.batch_modeling_cmd(
154 uuid::Uuid::new_v4(),
155 source_range,
156 &ModelingCmd::from(mcmd::EdgeLinesVisible { hidden: !visible }),
157 )
158 .await?;
159
160 Ok(())
161 }
162
163 async fn handle_artifact_command(
164 &self,
165 cmd: &ModelingCmd,
166 cmd_id: ModelingCmdId,
167 id_to_source_range: &HashMap<Uuid, SourceRange>,
168 ) -> Result<(), KclError> {
169 let cmd_id = *cmd_id.as_ref();
170 let range = id_to_source_range
171 .get(&cmd_id)
172 .copied()
173 .ok_or_else(|| KclError::internal(format!("Failed to get source range for command ID: {:?}", cmd_id)))?;
174
175 self.artifact_commands().write().await.push(ArtifactCommand {
177 cmd_id,
178 range,
179 command: cmd.clone(),
180 });
181 Ok(())
182 }
183
184 async fn set_units(
185 &self,
186 units: crate::UnitLength,
187 source_range: SourceRange,
188 ) -> Result<(), crate::errors::KclError> {
189 self.batch_modeling_cmd(
191 uuid::Uuid::new_v4(),
192 source_range,
193 &ModelingCmd::from(mcmd::SetSceneUnits { unit: units.into() }),
194 )
195 .await?;
196
197 Ok(())
198 }
199
200 async fn reapply_settings(
202 &self,
203 settings: &crate::ExecutorSettings,
204 source_range: SourceRange,
205 ) -> Result<(), crate::errors::KclError> {
206 self.set_edge_visibility(settings.highlight_edges, source_range).await?;
208
209 self.set_units(settings.units, source_range).await?;
211
212 self.modify_grid(!settings.show_grid, source_range).await?;
214
215 self.flush_batch(false, source_range).await?;
219
220 Ok(())
221 }
222
223 async fn batch_modeling_cmd(
225 &self,
226 id: uuid::Uuid,
227 source_range: SourceRange,
228 cmd: &ModelingCmd,
229 ) -> Result<(), crate::errors::KclError> {
230 if self.execution_kind().await.is_isolated() {
232 return Ok(());
233 }
234
235 let req = WebSocketRequest::ModelingCmdReq(ModelingCmdReq {
236 cmd: cmd.clone(),
237 cmd_id: id.into(),
238 });
239
240 self.batch().write().await.push((req, source_range));
242
243 Ok(())
244 }
245
246 async fn batch_end_cmd(
250 &self,
251 id: uuid::Uuid,
252 source_range: SourceRange,
253 cmd: &ModelingCmd,
254 ) -> Result<(), crate::errors::KclError> {
255 if self.execution_kind().await.is_isolated() {
257 return Ok(());
258 }
259
260 let req = WebSocketRequest::ModelingCmdReq(ModelingCmdReq {
261 cmd: cmd.clone(),
262 cmd_id: id.into(),
263 });
264
265 self.batch_end().write().await.insert(id, (req, source_range));
267 Ok(())
268 }
269
270 async fn send_modeling_cmd(
272 &self,
273 id: uuid::Uuid,
274 source_range: SourceRange,
275 cmd: &ModelingCmd,
276 ) -> Result<OkWebSocketResponseData, crate::errors::KclError> {
277 self.batch_modeling_cmd(id, source_range, cmd).await?;
278
279 self.flush_batch(false, source_range).await
281 }
282
283 async fn flush_batch(
285 &self,
286 batch_end: bool,
289 source_range: SourceRange,
290 ) -> Result<OkWebSocketResponseData, crate::errors::KclError> {
291 let all_requests = if batch_end {
292 let mut requests = self.batch().read().await.clone();
293 requests.extend(self.batch_end().read().await.values().cloned());
294 requests
295 } else {
296 self.batch().read().await.clone()
297 };
298
299 if all_requests.is_empty() {
301 return Ok(OkWebSocketResponseData::Modeling {
302 modeling_response: OkModelingCmdResponse::Empty {},
303 });
304 }
305
306 let requests: Vec<ModelingCmdReq> = all_requests
307 .iter()
308 .filter_map(|(val, _)| match val {
309 WebSocketRequest::ModelingCmdReq(ModelingCmdReq { cmd, cmd_id }) => Some(ModelingCmdReq {
310 cmd: cmd.clone(),
311 cmd_id: *cmd_id,
312 }),
313 _ => None,
314 })
315 .collect();
316
317 let batched_requests = WebSocketRequest::ModelingCmdBatchReq(ModelingBatch {
318 requests,
319 batch_id: uuid::Uuid::new_v4().into(),
320 responses: true,
321 });
322
323 let final_req = if all_requests.len() == 1 {
324 all_requests.first().unwrap().0.clone()
326 } else {
327 batched_requests
328 };
329
330 let mut id_to_source_range = HashMap::new();
333 for (req, range) in all_requests.iter() {
334 match req {
335 WebSocketRequest::ModelingCmdReq(ModelingCmdReq { cmd: _, cmd_id }) => {
336 id_to_source_range.insert(Uuid::from(*cmd_id), *range);
337 }
338 _ => {
339 return Err(KclError::Engine(KclErrorDetails {
340 message: format!("The request is not a modeling command: {:?}", req),
341 source_ranges: vec![*range],
342 }));
343 }
344 }
345 }
346
347 for (req, _) in all_requests.iter() {
349 match &req {
350 WebSocketRequest::ModelingCmdBatchReq(ModelingBatch { requests, .. }) => {
351 for request in requests {
352 self.handle_artifact_command(&request.cmd, request.cmd_id, &id_to_source_range)
353 .await?;
354 }
355 }
356 WebSocketRequest::ModelingCmdReq(request) => {
357 self.handle_artifact_command(&request.cmd, request.cmd_id, &id_to_source_range)
358 .await?;
359 }
360 _ => {}
361 }
362 }
363
364 self.batch().write().await.clear();
366 if batch_end {
367 self.batch_end().write().await.clear();
368 }
369
370 match final_req {
372 WebSocketRequest::ModelingCmdBatchReq(ModelingBatch {
373 ref requests,
374 batch_id,
375 responses: _,
376 }) => {
377 let last_id = requests.last().unwrap().cmd_id;
379 let ws_resp = self
380 .inner_send_modeling_cmd(batch_id.into(), source_range, final_req, id_to_source_range.clone())
381 .await?;
382 let response = self.parse_websocket_response(ws_resp, source_range)?;
383
384 if let OkWebSocketResponseData::ModelingBatch { responses } = response {
386 let responses = responses.into_iter().map(|(k, v)| (Uuid::from(k), v)).collect();
387 self.parse_batch_responses(last_id.into(), id_to_source_range, responses)
388 } else {
389 Err(KclError::Engine(KclErrorDetails {
391 message: format!("Failed to get batch response: {:?}", response),
392 source_ranges: vec![source_range],
393 }))
394 }
395 }
396 WebSocketRequest::ModelingCmdReq(ModelingCmdReq { cmd: _, cmd_id }) => {
397 let source_range = id_to_source_range.get(cmd_id.as_ref()).cloned().ok_or_else(|| {
405 KclError::Engine(KclErrorDetails {
406 message: format!("Failed to get source range for command ID: {:?}", cmd_id),
407 source_ranges: vec![],
408 })
409 })?;
410 let ws_resp = self
411 .inner_send_modeling_cmd(cmd_id.into(), source_range, final_req, id_to_source_range)
412 .await?;
413 self.parse_websocket_response(ws_resp, source_range)
414 }
415 _ => Err(KclError::Engine(KclErrorDetails {
416 message: format!("The final request is not a modeling command: {:?}", final_req),
417 source_ranges: vec![source_range],
418 })),
419 }
420 }
421
422 async fn make_default_plane(
423 &self,
424 plane_id: uuid::Uuid,
425 x_axis: Point3d,
426 y_axis: Point3d,
427 color: Option<Color>,
428 source_range: SourceRange,
429 ) -> Result<uuid::Uuid, KclError> {
430 let default_size = 100.0;
432 let default_origin = Point3d { x: 0.0, y: 0.0, z: 0.0 }.into();
433
434 self.batch_modeling_cmd(
435 plane_id,
436 source_range,
437 &ModelingCmd::from(mcmd::MakePlane {
438 clobber: false,
439 origin: default_origin,
440 size: LengthUnit(default_size),
441 x_axis: x_axis.into(),
442 y_axis: y_axis.into(),
443 hide: Some(true),
444 }),
445 )
446 .await?;
447
448 if let Some(color) = color {
449 self.batch_modeling_cmd(
451 uuid::Uuid::new_v4(),
452 source_range,
453 &ModelingCmd::from(mcmd::PlaneSetColor { color, plane_id }),
454 )
455 .await?;
456 }
457
458 Ok(plane_id)
459 }
460
461 async fn new_default_planes(
462 &self,
463 id_generator: &mut IdGenerator,
464 source_range: SourceRange,
465 ) -> Result<DefaultPlanes, KclError> {
466 let plane_settings: Vec<(PlaneName, Uuid, Point3d, Point3d, Option<Color>)> = vec![
467 (
468 PlaneName::Xy,
469 id_generator.next_uuid(),
470 Point3d { x: 1.0, y: 0.0, z: 0.0 },
471 Point3d { x: 0.0, y: 1.0, z: 0.0 },
472 Some(Color {
473 r: 0.7,
474 g: 0.28,
475 b: 0.28,
476 a: 0.4,
477 }),
478 ),
479 (
480 PlaneName::Yz,
481 id_generator.next_uuid(),
482 Point3d { x: 0.0, y: 1.0, z: 0.0 },
483 Point3d { x: 0.0, y: 0.0, z: 1.0 },
484 Some(Color {
485 r: 0.28,
486 g: 0.7,
487 b: 0.28,
488 a: 0.4,
489 }),
490 ),
491 (
492 PlaneName::Xz,
493 id_generator.next_uuid(),
494 Point3d { x: 1.0, y: 0.0, z: 0.0 },
495 Point3d { x: 0.0, y: 0.0, z: 1.0 },
496 Some(Color {
497 r: 0.28,
498 g: 0.28,
499 b: 0.7,
500 a: 0.4,
501 }),
502 ),
503 (
504 PlaneName::NegXy,
505 id_generator.next_uuid(),
506 Point3d {
507 x: -1.0,
508 y: 0.0,
509 z: 0.0,
510 },
511 Point3d { x: 0.0, y: 1.0, z: 0.0 },
512 None,
513 ),
514 (
515 PlaneName::NegYz,
516 id_generator.next_uuid(),
517 Point3d {
518 x: 0.0,
519 y: -1.0,
520 z: 0.0,
521 },
522 Point3d { x: 0.0, y: 0.0, z: 1.0 },
523 None,
524 ),
525 (
526 PlaneName::NegXz,
527 id_generator.next_uuid(),
528 Point3d {
529 x: -1.0,
530 y: 0.0,
531 z: 0.0,
532 },
533 Point3d { x: 0.0, y: 0.0, z: 1.0 },
534 None,
535 ),
536 ];
537
538 let mut planes = HashMap::new();
539 for (name, plane_id, x_axis, y_axis, color) in plane_settings {
540 planes.insert(
541 name,
542 self.make_default_plane(plane_id, x_axis, y_axis, color, source_range)
543 .await?,
544 );
545 }
546
547 self.flush_batch(false, source_range).await?;
549
550 Ok(DefaultPlanes {
551 xy: planes[&PlaneName::Xy],
552 neg_xy: planes[&PlaneName::NegXy],
553 xz: planes[&PlaneName::Xz],
554 neg_xz: planes[&PlaneName::NegXz],
555 yz: planes[&PlaneName::Yz],
556 neg_yz: planes[&PlaneName::NegYz],
557 })
558 }
559
560 fn parse_websocket_response(
561 &self,
562 response: WebSocketResponse,
563 source_range: SourceRange,
564 ) -> Result<OkWebSocketResponseData, crate::errors::KclError> {
565 match response {
566 WebSocketResponse::Success(success) => Ok(success.resp),
567 WebSocketResponse::Failure(fail) => {
568 let _request_id = fail.request_id;
569 Err(KclError::Engine(KclErrorDetails {
570 message: format!("Modeling command failed: {:?}", fail.errors),
571 source_ranges: vec![source_range],
572 }))
573 }
574 }
575 }
576
577 fn parse_batch_responses(
578 &self,
579 id: uuid::Uuid,
581 id_to_source_range: HashMap<uuid::Uuid, SourceRange>,
583 responses: HashMap<uuid::Uuid, BatchResponse>,
585 ) -> Result<OkWebSocketResponseData, crate::errors::KclError> {
586 #[expect(
588 clippy::iter_over_hash_type,
589 reason = "modeling command uses a HashMap and keys are random, so we don't really have a choice"
590 )]
591 for (cmd_id, resp) in responses.iter() {
592 match resp {
593 BatchResponse::Success { response } => {
594 if cmd_id == &id {
595 return Ok(OkWebSocketResponseData::Modeling {
597 modeling_response: response.clone(),
598 });
599 } else {
600 continue;
602 }
603 }
604 BatchResponse::Failure { errors } => {
605 let source_range = id_to_source_range.get(cmd_id).cloned().ok_or_else(|| {
607 KclError::Engine(KclErrorDetails {
608 message: format!("Failed to get source range for command ID: {:?}", cmd_id),
609 source_ranges: vec![],
610 })
611 })?;
612 return Err(KclError::Engine(KclErrorDetails {
613 message: format!("Modeling command failed: {:?}", errors),
614 source_ranges: vec![source_range],
615 }));
616 }
617 }
618 }
619
620 Err(KclError::Engine(KclErrorDetails {
623 message: format!("Failed to find response for command ID: {:?}", id),
624 source_ranges: vec![],
625 }))
626 }
627
628 async fn modify_grid(&self, hidden: bool, source_range: SourceRange) -> Result<(), KclError> {
629 self.batch_modeling_cmd(
631 uuid::Uuid::new_v4(),
632 source_range,
633 &ModelingCmd::from(mcmd::ObjectVisible {
634 hidden,
635 object_id: *GRID_OBJECT_ID,
636 }),
637 )
638 .await?;
639
640 self.batch_modeling_cmd(
642 uuid::Uuid::new_v4(),
643 source_range,
644 &ModelingCmd::from(mcmd::ObjectVisible {
645 hidden,
646 object_id: *GRID_SCALE_TEXT_OBJECT_ID,
647 }),
648 )
649 .await?;
650
651 Ok(())
652 }
653
654 async fn get_session_data(&self) -> Option<ModelingSessionData> {
657 None
658 }
659
660 async fn close(&self);
662}
663
664#[derive(Debug, Hash, Eq, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
665#[ts(export)]
666#[serde(rename_all = "camelCase")]
667pub enum PlaneName {
668 Xy,
670 NegXy,
672 Xz,
674 NegXz,
676 Yz,
678 NegYz,
680}
681
682#[cfg(not(target_arch = "wasm32"))]
684pub fn new_zoo_client(token: Option<String>, engine_addr: Option<String>) -> anyhow::Result<kittycad::Client> {
685 let user_agent = concat!(env!("CARGO_PKG_NAME"), ".rs/", env!("CARGO_PKG_VERSION"),);
686 let http_client = reqwest::Client::builder()
687 .user_agent(user_agent)
688 .timeout(std::time::Duration::from_secs(600))
690 .connect_timeout(std::time::Duration::from_secs(60));
691 let ws_client = reqwest::Client::builder()
692 .user_agent(user_agent)
693 .timeout(std::time::Duration::from_secs(600))
695 .connect_timeout(std::time::Duration::from_secs(60))
696 .connection_verbose(true)
697 .tcp_keepalive(std::time::Duration::from_secs(600))
698 .http1_only();
699
700 let zoo_token_env = std::env::var("ZOO_API_TOKEN");
701
702 let token = if let Some(token) = token {
703 token
704 } else if let Ok(token) = std::env::var("KITTYCAD_API_TOKEN") {
705 if let Ok(zoo_token) = zoo_token_env {
706 if zoo_token != token {
707 return Err(anyhow::anyhow!(
708 "Both environment variables KITTYCAD_API_TOKEN=`{}` and ZOO_API_TOKEN=`{}` are set. Use only one.",
709 token,
710 zoo_token
711 ));
712 }
713 }
714 token
715 } else if let Ok(token) = zoo_token_env {
716 token
717 } else {
718 return Err(anyhow::anyhow!(
719 "No API token found in environment variables. Use KITTYCAD_API_TOKEN or ZOO_API_TOKEN"
720 ));
721 };
722
723 let mut client = kittycad::Client::new_from_reqwest(token, http_client, ws_client);
725 let kittycad_host_env = std::env::var("KITTYCAD_HOST");
727 if let Some(addr) = engine_addr {
728 client.set_base_url(addr);
729 } else if let Ok(addr) = std::env::var("ZOO_HOST") {
730 if let Ok(kittycad_host) = kittycad_host_env {
731 if kittycad_host != addr {
732 return Err(anyhow::anyhow!(
733 "Both environment variables KITTYCAD_HOST=`{}` and ZOO_HOST=`{}` are set. Use only one.",
734 kittycad_host,
735 addr
736 ));
737 }
738 }
739 client.set_base_url(addr);
740 } else if let Ok(addr) = kittycad_host_env {
741 client.set_base_url(addr);
742 }
743
744 Ok(client)
745}