zoo_mcp 0.9.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
zoo_mcp/zoo_tools.py ADDED
@@ -0,0 +1,1179 @@
1
+ from enum import Enum
2
+ from pathlib import Path
3
+ from typing import TYPE_CHECKING, Protocol, cast
4
+ from uuid import uuid4
5
+
6
+ import aiofiles
7
+ import kcl
8
+
9
+ if TYPE_CHECKING:
10
+
11
+ class FixedLintsProtocol(Protocol):
12
+ """Protocol for kcl.FixedLints - the stub file is missing these attributes."""
13
+
14
+ @property
15
+ def new_code(self) -> str: ...
16
+ @property
17
+ def unfixed_lints(self) -> list[kcl.Discovered]: ...
18
+
19
+
20
+ from kittycad.models import (
21
+ Axis,
22
+ AxisDirectionPair,
23
+ Direction,
24
+ FileCenterOfMass,
25
+ FileConversion,
26
+ FileExportFormat,
27
+ FileImportFormat,
28
+ FileMass,
29
+ FileSurfaceArea,
30
+ FileVolume,
31
+ ImageFormat,
32
+ ImportFile,
33
+ InputFormat3d,
34
+ ModelingCmd,
35
+ ModelingCmdId,
36
+ Point3d,
37
+ PostEffectType,
38
+ System,
39
+ UnitArea,
40
+ UnitDensity,
41
+ UnitLength,
42
+ UnitMass,
43
+ UnitVolume,
44
+ WebSocketRequest,
45
+ )
46
+ from kittycad.models.input_format3d import (
47
+ OptionFbx,
48
+ OptionGltf,
49
+ OptionObj,
50
+ OptionPly,
51
+ OptionSldprt,
52
+ OptionStep,
53
+ OptionStl,
54
+ )
55
+ from kittycad.models.modeling_cmd import (
56
+ OptionDefaultCameraLookAt,
57
+ OptionDefaultCameraSetOrthographic,
58
+ OptionImportFiles,
59
+ OptionTakeSnapshot,
60
+ OptionViewIsometric,
61
+ OptionZoomToFit,
62
+ )
63
+ from kittycad.models.web_socket_request import OptionModelingCmdReq
64
+
65
+ from zoo_mcp import ZooMCPException, kittycad_client, logger
66
+ from zoo_mcp.utils.image_utils import create_image_collage
67
+
68
+
69
+ def _check_kcl_code_or_path(
70
+ kcl_code: str | None,
71
+ kcl_path: Path | str | None,
72
+ require_main_file: bool = True,
73
+ ) -> None:
74
+ """This is a helper function to check the provided kcl_code or kcl_path for various functions.
75
+ If both are provided, kcl_code is used.
76
+ If kcl_path is a file, it checks if the path is a .kcl file, otherwise raises an exception.
77
+ If kcl_path is a directory, it checks if it contains a main.kcl file in the root, otherwise raises an exception.
78
+ If neither are provided, it raises an exception.
79
+
80
+ Args:
81
+ kcl_code (str | None): KCL code
82
+ kcl_path (Path | str | None): KCL path, the path should point to a .kcl file or a directory containing a main.kcl file.
83
+ require_main_file (bool): Whether to require a main.kcl file in the directory if kcl_path is a directory. Default is True.
84
+
85
+ Returns:
86
+ None
87
+ """
88
+
89
+ # default to using the code if both are provided
90
+ if kcl_code and kcl_path:
91
+ logger.warning("Both code and kcl_path provided, using code")
92
+ kcl_path = None
93
+
94
+ if kcl_path:
95
+ kcl_path = Path(kcl_path)
96
+ if not kcl_path.exists():
97
+ logger.error("The provided kcl_path does not exist")
98
+ raise ZooMCPException("The provided kcl_path does not exist")
99
+ if kcl_path.is_file() and kcl_path.suffix != ".kcl":
100
+ logger.error("The provided kcl_path is not a .kcl file")
101
+ raise ZooMCPException("The provided kcl_path is not a .kcl file")
102
+ if (
103
+ kcl_path.is_dir()
104
+ and require_main_file
105
+ and not (kcl_path / "main.kcl").is_file()
106
+ ):
107
+ logger.error(
108
+ "The provided kcl_path directory does not contain a main.kcl file"
109
+ )
110
+ raise ZooMCPException(
111
+ "The provided kcl_path does not contain a main.kcl file"
112
+ )
113
+
114
+ if not kcl_code and not kcl_path:
115
+ logger.error("Neither code nor kcl_path provided")
116
+ raise ZooMCPException("Neither code nor kcl_path provided")
117
+
118
+
119
+ class KCLExportFormat(Enum):
120
+ formats = {
121
+ "fbx": kcl.FileExportFormat.Fbx,
122
+ "gltf": kcl.FileExportFormat.Gltf,
123
+ "glb": kcl.FileExportFormat.Glb,
124
+ "obj": kcl.FileExportFormat.Obj,
125
+ "ply": kcl.FileExportFormat.Ply,
126
+ "step": kcl.FileExportFormat.Step,
127
+ "stl": kcl.FileExportFormat.Stl,
128
+ }
129
+
130
+
131
+ class CameraView(Enum):
132
+ views = {
133
+ "front": {"up": [0, 0, 1], "vantage": [0, -1, 0], "center": [0, 0, 0]},
134
+ "back": {"up": [0, 0, 1], "vantage": [0, 1, 0], "center": [0, 0, 0]},
135
+ "left": {"up": [0, 0, 1], "vantage": [-1, 0, 0], "center": [0, 0, 0]},
136
+ "right": {"up": [0, 0, 1], "vantage": [1, 0, 0], "center": [0, 0, 0]},
137
+ "top": {"up": [0, 1, 0], "vantage": [0, 0, 1], "center": [0, 0, 0]},
138
+ "bottom": {"up": [0, -1, 0], "vantage": [0, 0, -1], "center": [0, 0, 0]},
139
+ "isometric": {"up": [0, 0, 1], "vantage": [1, -1, 1], "center": [0, 0, 0]},
140
+ }
141
+
142
+ @staticmethod
143
+ def to_kcl_camera(view: dict[str, list[float]]) -> kcl.CameraLookAt:
144
+ return kcl.CameraLookAt(
145
+ up=kcl.Point3d(
146
+ x=view["up"][0],
147
+ y=view["up"][1],
148
+ z=view["up"][2],
149
+ ),
150
+ vantage=kcl.Point3d(
151
+ x=view["vantage"][0],
152
+ y=-view["vantage"][1],
153
+ z=view["vantage"][2],
154
+ ),
155
+ center=kcl.Point3d(
156
+ x=view["center"][0],
157
+ y=view["center"][1],
158
+ z=view["center"][2],
159
+ ),
160
+ )
161
+
162
+ @staticmethod
163
+ def to_kittycad_camera(view: dict[str, list[float]]) -> OptionDefaultCameraLookAt:
164
+ return OptionDefaultCameraLookAt(
165
+ up=Point3d(
166
+ x=view["up"][0],
167
+ y=view["up"][1],
168
+ z=view["up"][2],
169
+ ),
170
+ vantage=Point3d(
171
+ x=view["vantage"][0],
172
+ y=-view["vantage"][1],
173
+ z=view["vantage"][2],
174
+ ),
175
+ center=Point3d(
176
+ x=view["center"][0],
177
+ y=view["center"][1],
178
+ z=view["center"][2],
179
+ ),
180
+ )
181
+
182
+
183
+ def _get_input_format(ext: str) -> InputFormat3d | None:
184
+ match ext.lower():
185
+ case "fbx":
186
+ return InputFormat3d(OptionFbx())
187
+ case "gltf":
188
+ return InputFormat3d(OptionGltf())
189
+ case "obj":
190
+ return InputFormat3d(
191
+ OptionObj(
192
+ coords=System(
193
+ forward=AxisDirectionPair(
194
+ axis=Axis.Y, direction=Direction.NEGATIVE
195
+ ),
196
+ up=AxisDirectionPair(axis=Axis.Z, direction=Direction.POSITIVE),
197
+ ),
198
+ units=UnitLength.MM,
199
+ )
200
+ )
201
+ case "ply":
202
+ return InputFormat3d(
203
+ OptionPly(
204
+ coords=System(
205
+ forward=AxisDirectionPair(
206
+ axis=Axis.Y, direction=Direction.NEGATIVE
207
+ ),
208
+ up=AxisDirectionPair(axis=Axis.Z, direction=Direction.POSITIVE),
209
+ ),
210
+ units=UnitLength.MM,
211
+ )
212
+ )
213
+ case "sldprt":
214
+ return InputFormat3d(OptionSldprt(split_closed_faces=True))
215
+ case "step" | "stp":
216
+ return InputFormat3d(OptionStep(split_closed_faces=True))
217
+ case "stl":
218
+ return InputFormat3d(
219
+ OptionStl(
220
+ coords=System(
221
+ forward=AxisDirectionPair(
222
+ axis=Axis.Y, direction=Direction.NEGATIVE
223
+ ),
224
+ up=AxisDirectionPair(axis=Axis.Z, direction=Direction.POSITIVE),
225
+ ),
226
+ units=UnitLength.MM,
227
+ )
228
+ )
229
+ return None
230
+
231
+
232
+ async def zoo_calculate_center_of_mass(
233
+ file_path: Path | str,
234
+ unit_length: str,
235
+ ) -> dict[str, float]:
236
+ """Calculate the center of mass of the file
237
+
238
+ Args:
239
+ file_path(Path | str): The path to the file. The file should be one of the supported formats: .fbx, .gltf, .obj, .ply, .sldprt, .step, .stl
240
+ unit_length(str): The unit length to return. This should be one of 'cm', 'ft', 'in', 'm', 'mm', 'yd'
241
+
242
+ Returns:
243
+ dict[str]: If the center of mass can be calculated return the center of mass as a dictionary with x, y, and z keys
244
+ """
245
+ file_path = Path(file_path)
246
+
247
+ logger.info("Calculating center of mass for %s", str(file_path.resolve()))
248
+
249
+ async with aiofiles.open(file_path, "rb") as inp:
250
+ data = await inp.read()
251
+
252
+ src_format = FileImportFormat(file_path.suffix.split(".")[1].lower())
253
+
254
+ result = kittycad_client.file.create_file_center_of_mass(
255
+ src_format=src_format,
256
+ body=data,
257
+ output_unit=UnitLength(unit_length),
258
+ )
259
+
260
+ if not isinstance(result, FileCenterOfMass):
261
+ logger.info(
262
+ "Failed to calculate center of mass, incorrect return type %s",
263
+ type(result),
264
+ )
265
+ raise ZooMCPException(
266
+ "Failed to calculate center of mass, incorrect return type %s",
267
+ type(result),
268
+ )
269
+
270
+ com = result.center_of_mass.to_dict() if result.center_of_mass is not None else None
271
+
272
+ if com is None:
273
+ raise ZooMCPException(
274
+ "Failed to calculate center of mass, no center of mass returned"
275
+ )
276
+
277
+ return com
278
+
279
+
280
+ async def zoo_calculate_mass(
281
+ file_path: Path | str,
282
+ unit_mass: str,
283
+ unit_density: str,
284
+ density: float,
285
+ ) -> float:
286
+ """Calculate the mass of the file in the requested unit
287
+
288
+ Args:
289
+ file_path(Path | str): The path to the file. The file should be one of the supported formats: .fbx, .gltf, .obj, .ply, .sldprt, .step, .stl
290
+ unit_mass(str): The unit mass to return. This should be one of 'g', 'kg', 'lb'.
291
+ unit_density(str): The unit density of the material. This should be one of 'lb:ft3', 'kg:m3'.
292
+ density(float): The density of the material.
293
+
294
+ Returns:
295
+ float | None: If the mass of the file can be calculated, return the mass in the requested unit
296
+ """
297
+
298
+ file_path = Path(file_path)
299
+
300
+ logger.info("Calculating mass for %s", str(file_path.resolve()))
301
+
302
+ async with aiofiles.open(file_path, "rb") as inp:
303
+ data = await inp.read()
304
+
305
+ src_format = FileImportFormat(file_path.suffix.split(".")[1].lower())
306
+
307
+ result = kittycad_client.file.create_file_mass(
308
+ output_unit=UnitMass(unit_mass),
309
+ src_format=src_format,
310
+ body=data,
311
+ material_density_unit=UnitDensity(unit_density),
312
+ material_density=density,
313
+ )
314
+
315
+ if not isinstance(result, FileMass):
316
+ logger.info("Failed to calculate mass, incorrect return type %s", type(result))
317
+ raise ZooMCPException(
318
+ "Failed to calculate mass, incorrect return type %s", type(result)
319
+ )
320
+
321
+ mass = result.mass
322
+
323
+ if mass is None:
324
+ raise ZooMCPException("Failed to calculate mass, no mass returned")
325
+
326
+ return mass
327
+
328
+
329
+ async def zoo_calculate_surface_area(file_path: Path | str, unit_area: str) -> float:
330
+ """Calculate the surface area of the file in the requested unit
331
+
332
+ Args:
333
+ file_path (Path | str): The path to the file. The file should be one of the supported formats: .fbx, .gltf, .obj, .ply, .sldprt, .step, .stl
334
+ unit_area (str): The unit area to return. This should be one of 'cm2', 'dm2', 'ft2', 'in2', 'km2', 'm2', 'mm2', 'yd2'.
335
+
336
+ Returns:
337
+ float: If the surface area can be calculated return the surface area
338
+ """
339
+
340
+ file_path = Path(file_path)
341
+
342
+ logger.info("Calculating surface area for %s", str(file_path.resolve()))
343
+
344
+ async with aiofiles.open(file_path, "rb") as inp:
345
+ data = await inp.read()
346
+
347
+ src_format = FileImportFormat(file_path.suffix.split(".")[1].lower())
348
+
349
+ result = kittycad_client.file.create_file_surface_area(
350
+ output_unit=UnitArea(unit_area),
351
+ src_format=src_format,
352
+ body=data,
353
+ )
354
+
355
+ if not isinstance(result, FileSurfaceArea):
356
+ logger.error(
357
+ "Failed to calculate surface area, incorrect return type %s",
358
+ type(result),
359
+ )
360
+ raise ZooMCPException(
361
+ "Failed to calculate surface area, incorrect return type %s",
362
+ )
363
+
364
+ surface_area = result.surface_area
365
+
366
+ if surface_area is None:
367
+ raise ZooMCPException(
368
+ "Failed to calculate surface area, no surface area returned"
369
+ )
370
+
371
+ return surface_area
372
+
373
+
374
+ async def zoo_calculate_volume(file_path: Path | str, unit_vol: str) -> float:
375
+ """Calculate the volume of the file in the requested unit
376
+
377
+ Args:
378
+ file_path (Path | str): The path to the file. The file should be one of the supported formats: .fbx, .gltf, .obj, .ply, .sldprt, .step, .stl
379
+ unit_vol (str): The unit volume to return. This should be one of 'cm3', 'ft3', 'in3', 'm3', 'yd3', 'usfloz', 'usgal', 'l', 'ml'.
380
+
381
+ Returns:
382
+ float: If the volume of the file can be calculated, return the volume in the requested unit
383
+ """
384
+
385
+ file_path = Path(file_path)
386
+
387
+ logger.info("Calculating volume for %s", str(file_path.resolve()))
388
+
389
+ async with aiofiles.open(file_path, "rb") as inp:
390
+ data = await inp.read()
391
+
392
+ src_format = FileImportFormat(file_path.suffix.split(".")[1].lower())
393
+
394
+ result = kittycad_client.file.create_file_volume(
395
+ output_unit=UnitVolume(unit_vol),
396
+ src_format=src_format,
397
+ body=data,
398
+ )
399
+
400
+ if not isinstance(result, FileVolume):
401
+ logger.info(
402
+ "Failed to calculate volume, incorrect return type %s", type(result)
403
+ )
404
+ raise ZooMCPException(
405
+ "Failed to calculate volume, incorrect return type %s", type(result)
406
+ )
407
+
408
+ volume = result.volume
409
+
410
+ if volume is None:
411
+ raise ZooMCPException("Failed to calculate volume, no volume returned")
412
+
413
+ return volume
414
+
415
+
416
+ async def zoo_convert_cad_file(
417
+ input_path: Path | str,
418
+ export_path: Path | str | None = None,
419
+ export_format: FileExportFormat | str | None = FileExportFormat.STEP,
420
+ ) -> Path:
421
+ """Convert a cad file to another cad file
422
+
423
+ Args:
424
+ input_path (Path | str): path to the CAD file to convert. The file should be one of the supported formats: .fbx, .gltf, .obj, .ply, .sldprt, .step, .stl
425
+ export_path (Path | str | None): The path to save the cad file. If no path is provided, a temporary file will be created. If the path is a directory, a temporary file will be created in the directory. If the path is a file, it will be overwritten if the extension is valid.
426
+ export_format (FileExportFormat | str | None): format to export the KCL code to. This should be one of 'fbx', 'glb', 'gltf', 'obj', 'ply', 'step', 'stl'. If no format is provided, the default is 'step'.
427
+
428
+ Returns:
429
+ Path: Return the path to the exported model if successful
430
+ """
431
+
432
+ input_path = Path(input_path)
433
+ input_ext = input_path.suffix.split(".")[1]
434
+ if input_ext not in [i.value for i in FileImportFormat]:
435
+ logger.error("The provided input path does not have a valid extension")
436
+ raise ZooMCPException("The provided input path does not have a valid extension")
437
+ logger.info("Converting the cad file %s", str(input_path.resolve()))
438
+
439
+ # check the export format
440
+ if not export_format:
441
+ logger.warning("No export format provided, defaulting to step")
442
+ export_format = FileExportFormat.STEP
443
+ else:
444
+ if export_format not in FileExportFormat:
445
+ logger.warning(
446
+ "Invalid export format %s provided, defaulting to step", export_format
447
+ )
448
+ export_format = FileExportFormat.STEP
449
+ else:
450
+ export_format = FileExportFormat(export_format)
451
+
452
+ if export_path is None:
453
+ logger.warning("No export path provided, creating a temporary file")
454
+ export_path = await aiofiles.tempfile.NamedTemporaryFile(
455
+ delete=False, suffix=f".{export_format.value.lower()}"
456
+ )
457
+ export_path = Path(export_path.name)
458
+ else:
459
+ export_path = Path(export_path)
460
+ if export_path.suffix:
461
+ ext = export_path.suffix.split(".")[1]
462
+ if ext not in [i.value for i in FileExportFormat]:
463
+ logger.warning(
464
+ "The provided export path does not have a valid extension, using a temporary file instead"
465
+ )
466
+ export_path = await aiofiles.tempfile.NamedTemporaryFile(
467
+ dir=export_path.parent.resolve(),
468
+ delete=False,
469
+ suffix=f".{export_format.value.lower()}",
470
+ )
471
+ else:
472
+ logger.warning("The provided export path is a file, overwriting")
473
+ else:
474
+ export_path = await aiofiles.tempfile.NamedTemporaryFile(
475
+ dir=export_path.resolve(),
476
+ delete=False,
477
+ suffix=f".{export_format.value.lower()}",
478
+ )
479
+ logger.info("Using provided export path: %s", str(export_path.name))
480
+
481
+ async with aiofiles.open(input_path, "rb") as inp:
482
+ data = await inp.read()
483
+
484
+ export_response = kittycad_client.file.create_file_conversion(
485
+ src_format=FileImportFormat(input_ext),
486
+ output_format=FileExportFormat(export_format),
487
+ body=data,
488
+ )
489
+
490
+ if not isinstance(export_response, FileConversion):
491
+ logger.error(
492
+ "Failed to convert file, incorrect return type %s",
493
+ type(export_response),
494
+ )
495
+ raise ZooMCPException(
496
+ "Failed to convert file, incorrect return type %s",
497
+ )
498
+
499
+ if export_response.outputs is None:
500
+ logger.error("Failed to convert file")
501
+ raise ZooMCPException("Failed to convert file no output response")
502
+
503
+ async with aiofiles.open(export_path, "wb") as out:
504
+ await out.write(list(export_response.outputs.values())[0])
505
+
506
+ logger.info("KCL project exported successfully to %s", str(export_path.resolve()))
507
+
508
+ return export_path
509
+
510
+
511
+ async def zoo_execute_kcl(
512
+ kcl_code: str | None = None,
513
+ kcl_path: Path | str | None = None,
514
+ ) -> tuple[bool, str]:
515
+ """Execute KCL code given a string of KCL code or a path to a KCL project. Either kcl_code or kcl_path must be provided. If kcl_path is provided, it should point to a .kcl file or a directory containing a main.kcl file.
516
+
517
+ Args:
518
+ kcl_code (str | None): KCL code
519
+ kcl_path (Path | str | None): KCL path, the path should point to a .kcl file or a directory containing a main.kcl file.
520
+
521
+ Returns:
522
+ tuple(bool, str): Returns True if the KCL code executed successfully and a success message, False otherwise and the error message.
523
+ """
524
+ logger.info("Executing KCL code")
525
+
526
+ _check_kcl_code_or_path(kcl_code, kcl_path)
527
+
528
+ try:
529
+ if kcl_code:
530
+ await kcl.execute_code(kcl_code)
531
+ else:
532
+ await kcl.execute(str(kcl_path))
533
+ logger.info("KCL code executed successfully")
534
+ return True, "KCL code executed successfully"
535
+ except Exception as e:
536
+ logger.info("Failed to execute KCL code: %s", e)
537
+ return False, f"Failed to execute KCL code: {e}"
538
+
539
+
540
+ async def zoo_export_kcl(
541
+ kcl_code: str | None = None,
542
+ kcl_path: Path | str | None = None,
543
+ export_path: Path | str | None = None,
544
+ export_format: kcl.FileExportFormat | str | None = kcl.FileExportFormat.Step,
545
+ ) -> Path:
546
+ """Export KCL code to a CAD file. Either kcl_code or kcl_path must be provided. If kcl_path is provided, it should point to a .kcl file or a directory containing a main.kcl file.
547
+
548
+ Args:
549
+ kcl_code (str | None): KCL code
550
+ kcl_path (Path | str | None): KCL path, the path should point to a .kcl file or a directory containing a main.kcl file.
551
+ export_path (Path | str | None): path to save the step file, this should be a directory or a file with the appropriate extension. If no path is provided, a temporary file will be created.
552
+ export_format (kcl.FileExportFormat | str | None): format to export the KCL code to. This should be one of 'fbx', 'glb', 'gltf', 'obj', 'ply', 'step', 'stl'. If no format is provided, the default is 'step'.
553
+
554
+ Returns:
555
+ Path: Return the path to the exported model if successful
556
+ """
557
+
558
+ logger.info("Exporting KCL to Step")
559
+
560
+ _check_kcl_code_or_path(kcl_code, kcl_path)
561
+
562
+ # check the export format
563
+ if not export_format:
564
+ logger.warning("No export format provided, defaulting to step")
565
+ export_format = kcl.FileExportFormat.Step
566
+ else:
567
+ if export_format not in KCLExportFormat.formats.value.keys():
568
+ logger.warning(
569
+ "Invalid export format %s provided, defaulting to step", export_format
570
+ )
571
+ export_format = kcl.FileExportFormat.Step
572
+ else:
573
+ export_format = KCLExportFormat.formats.value[export_format]
574
+
575
+ if export_path is None:
576
+ logger.warning("No export path provided, creating a temporary file")
577
+ export_path = await aiofiles.tempfile.NamedTemporaryFile(
578
+ delete=False, suffix=f".{str(export_format).split('.')[1].lower()}"
579
+ )
580
+ export_path = Path(export_path.name)
581
+ else:
582
+ export_path = Path(export_path)
583
+ if export_path.suffix:
584
+ ext = export_path.suffix.split(".")[1]
585
+ if ext not in [i.value for i in FileExportFormat]:
586
+ logger.warning(
587
+ "The provided export path does not have a valid extension, using a temporary file instead"
588
+ )
589
+ export_path = await aiofiles.tempfile.NamedTemporaryFile(
590
+ dir=export_path.parent.resolve(),
591
+ delete=False,
592
+ suffix=f".{str(export_format).split('.')[1].lower()}",
593
+ )
594
+ else:
595
+ logger.warning("The provided export path is a file, overwriting")
596
+ else:
597
+ export_path = await aiofiles.tempfile.NamedTemporaryFile(
598
+ dir=export_path.resolve(),
599
+ delete=False,
600
+ suffix=f".{str(export_format).split('.')[1].lower()}",
601
+ )
602
+ logger.info("Using provided export path: %s", str(export_path.name))
603
+
604
+ async with aiofiles.open(export_path, "wb") as out:
605
+ if kcl_code:
606
+ logger.info("Exporting KCL code to %s", str(kcl_code))
607
+ export_response = await kcl.execute_code_and_export(kcl_code, export_format)
608
+ else:
609
+ logger.info("Exporting KCL project to %s", str(kcl_path))
610
+ assert kcl_path is not None # _check_kcl_code_or_path ensures this
611
+ kcl_path_resolved = Path(kcl_path)
612
+ export_response = await kcl.execute_and_export(
613
+ str(kcl_path_resolved.resolve()), export_format
614
+ )
615
+ await out.write(bytes(export_response[0].contents))
616
+
617
+ logger.info("KCL exported successfully to %s", str(export_path))
618
+ return Path(export_path)
619
+
620
+
621
+ def zoo_format_kcl(
622
+ kcl_code: str | None,
623
+ kcl_path: Path | str | None,
624
+ ) -> str | None:
625
+ """Format KCL given a string of KCL code or a path to a KCL project. Either kcl_code or kcl_path must be provided. If kcl_path is provided, it should point to a .kcl file or a directory containing .kcl files.
626
+
627
+ Args:
628
+ kcl_code (str | None): KCL code to format.
629
+ kcl_path (Path | str | None): KCL path, the path should point to a .kcl file or a directory containing a main.kcl file.
630
+
631
+ Returns:
632
+ str | None: Returns the formatted kcl code if the kcl_code is used otherwise returns None, the KCL in the kcl_path will be formatted in place
633
+ """
634
+
635
+ logger.info("Formatting the KCL")
636
+
637
+ _check_kcl_code_or_path(kcl_code, kcl_path, require_main_file=False)
638
+
639
+ try:
640
+ if kcl_code:
641
+ formatted_code = kcl.format(kcl_code)
642
+ return formatted_code
643
+ else:
644
+ kcl.format_dir(str(kcl_path))
645
+ return None
646
+ except Exception as e:
647
+ logger.error(e)
648
+ raise ZooMCPException(f"Failed to format the KCL: {e}")
649
+
650
+
651
+ def zoo_lint_and_fix_kcl(
652
+ kcl_code: str | None,
653
+ kcl_path: Path | str | None,
654
+ ) -> tuple[str | None, list[str]]:
655
+ """Lint and fix KCL given a string of KCL code or a path to a KCL project. Either kcl_code or kcl_path must be provided. If kcl_path is provided, it should point to a .kcl file or a directory containing .kcl files.
656
+
657
+ Args:
658
+ kcl_code (str | None): KCL code to lint and fix.
659
+ kcl_path (Path | str | None): KCL path, the path should point to a .kcl file or a directory containing a main.kcl file.
660
+
661
+ Returns:
662
+ tuple[str | None, list[str]]: If kcl_code is provided, it returns a tuple of the fixed kcl code and a list of unfixed lints.
663
+ If kcl_path is provided, it returns None and a list of unfixed lints for each file in the project.
664
+ """
665
+
666
+ logger.info("Linting and fixing the KCL")
667
+
668
+ _check_kcl_code_or_path(kcl_code, kcl_path, require_main_file=False)
669
+
670
+ try:
671
+ if kcl_code:
672
+ linted_kcl = cast(
673
+ "FixedLintsProtocol",
674
+ kcl.lint_and_fix_families(
675
+ kcl_code,
676
+ [kcl.FindingFamily.Correctness, kcl.FindingFamily.Simplify],
677
+ ),
678
+ )
679
+ if len(linted_kcl.unfixed_lints) > 0:
680
+ unfixed_lints = [
681
+ f"{lint.description}, {lint.finding.description}"
682
+ for lint in linted_kcl.unfixed_lints
683
+ ]
684
+ else:
685
+ unfixed_lints = ["All lints fixed"]
686
+ return linted_kcl.new_code, unfixed_lints
687
+ else:
688
+ # _check_kcl_code_or_path ensures kcl_path is valid when kcl_code is None
689
+ assert kcl_path is not None
690
+ kcl_path_resolved = Path(kcl_path)
691
+ unfixed_lints = []
692
+ for kcl_file in kcl_path_resolved.rglob("*.kcl"):
693
+ linted_kcl = cast(
694
+ "FixedLintsProtocol",
695
+ kcl.lint_and_fix_families(
696
+ kcl_file.read_text(),
697
+ [kcl.FindingFamily.Correctness, kcl.FindingFamily.Simplify],
698
+ ),
699
+ )
700
+ kcl_file.write_text(linted_kcl.new_code)
701
+ if len(linted_kcl.unfixed_lints) > 0:
702
+ unfixed_lints.extend(
703
+ [
704
+ f"In file {kcl_file.name}, {lint.description}, {lint.finding.description}"
705
+ for lint in linted_kcl.unfixed_lints
706
+ ]
707
+ )
708
+ else:
709
+ unfixed_lints.append(f"In file {kcl_file.name}, All lints fixed")
710
+ return None, unfixed_lints
711
+ except Exception as e:
712
+ logger.error(e)
713
+ raise ZooMCPException(f"Failed to lint and fix the KCL: {e}")
714
+
715
+
716
+ async def zoo_mock_execute_kcl(
717
+ kcl_code: str | None = None,
718
+ kcl_path: Path | str | None = None,
719
+ ) -> tuple[bool, str]:
720
+ """Mock execute KCL code given a string of KCL code or a path to a KCL project. Either kcl_code or kcl_path must be provided. If kcl_path is provided, it should point to a .kcl file or a directory containing a main.kcl file.
721
+
722
+ Args:
723
+ kcl_code (str | None): KCL code
724
+ kcl_path (Path | str | None): KCL path, the path should point to a .kcl file or a directory containing a main.kcl file.
725
+
726
+ Returns:
727
+ tuple(bool, str): Returns True if the KCL code executed successfully and a success message, False otherwise and the error message.
728
+ """
729
+ logger.info("Executing KCL code")
730
+
731
+ _check_kcl_code_or_path(kcl_code, kcl_path)
732
+
733
+ try:
734
+ if kcl_code:
735
+ await kcl.mock_execute_code(kcl_code)
736
+ else:
737
+ await kcl.mock_execute(str(kcl_path))
738
+ logger.info("KCL mock executed successfully")
739
+ return True, "KCL code mock executed successfully"
740
+ except Exception as e:
741
+ logger.info("Failed to mock execute KCL code: %s", e)
742
+ return False, f"Failed to mock execute KCL code: {e}"
743
+
744
+
745
+ def zoo_multiview_snapshot_of_cad(
746
+ input_path: Path | str,
747
+ padding: float = 0.2,
748
+ ) -> bytes:
749
+ """Save a multiview snapshot of a CAD file.
750
+
751
+ Args:
752
+ input_path (Path | str): Path to the CAD file to save a multiview snapshot. The file should be one of the supported formats: .fbx, .gltf, .obj, .ply, .sldprt, .step, .stl
753
+ padding (float): The padding to apply to the snapshot. Default is 0.2.
754
+
755
+ Returns:
756
+ bytes or None: The JPEG image contents if successful
757
+ """
758
+
759
+ input_path = Path(input_path)
760
+
761
+ # Connect to the websocket.
762
+ with (
763
+ kittycad_client.modeling.modeling_commands_ws(
764
+ fps=30,
765
+ post_effect=PostEffectType.SSAO,
766
+ show_grid=False,
767
+ unlocked_framerate=False,
768
+ video_res_height=1024,
769
+ video_res_width=1024,
770
+ webrtc=False,
771
+ ) as ws,
772
+ open(input_path, "rb") as data,
773
+ ):
774
+ # Import files request must be sent as binary, because the file contents might be binary.
775
+ import_id = ModelingCmdId(uuid4())
776
+
777
+ input_ext = input_path.suffix.split(".")[1]
778
+ if input_ext not in [i.value for i in FileImportFormat]:
779
+ logger.error("The provided input path does not have a valid extension")
780
+ raise ZooMCPException(
781
+ "The provided input path does not have a valid extension"
782
+ )
783
+
784
+ input_format = _get_input_format(input_ext)
785
+ if input_format is None:
786
+ logger.error("The provided extension is not supported for import")
787
+ raise ZooMCPException("The provided extension is not supported for import")
788
+
789
+ ws.send_binary(
790
+ WebSocketRequest(
791
+ OptionModelingCmdReq(
792
+ cmd=ModelingCmd(
793
+ OptionImportFiles(
794
+ files=[ImportFile(data=data.read(), path=str(input_path))],
795
+ format=input_format,
796
+ )
797
+ ),
798
+ cmd_id=ModelingCmdId(import_id),
799
+ )
800
+ )
801
+ )
802
+
803
+ # Wait for the import to succeed.
804
+ while True:
805
+ message = ws.recv().model_dump()
806
+ if message["request_id"] == import_id:
807
+ break
808
+ if message["success"] is not True:
809
+ logger.error("Failed to import CAD file")
810
+ raise ZooMCPException("Failed to import CAD file")
811
+ object_id = message["resp"]["data"]["modeling_response"]["data"]["object_id"]
812
+
813
+ # set camera to ortho
814
+ ortho_cam_id = ModelingCmdId(uuid4())
815
+ ws.send(
816
+ WebSocketRequest(
817
+ OptionModelingCmdReq(
818
+ cmd=ModelingCmd(OptionDefaultCameraSetOrthographic()),
819
+ cmd_id=ModelingCmdId(ortho_cam_id),
820
+ )
821
+ )
822
+ )
823
+
824
+ views = [
825
+ OptionDefaultCameraLookAt(
826
+ up=Point3d(x=0, y=0, z=1),
827
+ vantage=Point3d(x=0, y=-1, z=0),
828
+ center=Point3d(x=0, y=0, z=0),
829
+ ),
830
+ OptionDefaultCameraLookAt(
831
+ up=Point3d(x=0, y=0, z=1),
832
+ vantage=Point3d(x=1, y=0, z=0),
833
+ center=Point3d(x=0, y=0, z=0),
834
+ ),
835
+ OptionDefaultCameraLookAt(
836
+ up=Point3d(x=0, y=1, z=0),
837
+ vantage=Point3d(x=0, y=0, z=1),
838
+ center=Point3d(x=0, y=0, z=0),
839
+ ),
840
+ OptionViewIsometric(),
841
+ ]
842
+
843
+ jpeg_contents_list = []
844
+
845
+ for view in views:
846
+ # change camera look at
847
+ camera_look_id = ModelingCmdId(uuid4())
848
+ ws.send(
849
+ WebSocketRequest(
850
+ OptionModelingCmdReq(
851
+ cmd=ModelingCmd(view),
852
+ cmd_id=ModelingCmdId(camera_look_id),
853
+ )
854
+ )
855
+ )
856
+
857
+ focus_id = ModelingCmdId(uuid4())
858
+ ws.send(
859
+ WebSocketRequest(
860
+ OptionModelingCmdReq(
861
+ cmd=ModelingCmd(
862
+ OptionZoomToFit(object_ids=[object_id], padding=padding)
863
+ ),
864
+ cmd_id=ModelingCmdId(focus_id),
865
+ )
866
+ )
867
+ )
868
+
869
+ # Wait for success message.
870
+ while True:
871
+ message = ws.recv().model_dump()
872
+ if message["request_id"] == focus_id:
873
+ break
874
+ if message["success"] is not True:
875
+ logger.error("Failed to move camera to fit object")
876
+ raise ZooMCPException("Failed to move camera to fit object")
877
+
878
+ # Take a snapshot as a JPEG.
879
+ snapshot_id = ModelingCmdId(uuid4())
880
+ ws.send(
881
+ WebSocketRequest(
882
+ OptionModelingCmdReq(
883
+ cmd=ModelingCmd(OptionTakeSnapshot(format=ImageFormat.JPEG)),
884
+ cmd_id=ModelingCmdId(snapshot_id),
885
+ )
886
+ )
887
+ )
888
+
889
+ # Wait for success message.
890
+ while True:
891
+ message = ws.recv().model_dump()
892
+ if message["request_id"] == snapshot_id:
893
+ break
894
+ if message["success"] is not True:
895
+ logger.error("Failed to capture snapshot")
896
+ raise ZooMCPException("Failed to capture snapshot")
897
+ jpeg_contents = message["resp"]["data"]["modeling_response"]["data"][
898
+ "contents"
899
+ ]
900
+
901
+ jpeg_contents_list.append(jpeg_contents)
902
+
903
+ collage = create_image_collage(jpeg_contents_list)
904
+
905
+ return collage
906
+
907
+
908
+ async def zoo_multiview_snapshot_of_kcl(
909
+ kcl_code: str | None,
910
+ kcl_path: Path | str | None,
911
+ padding: float = 0.2,
912
+ ) -> bytes:
913
+ """Execute the KCL code and save a multiview snapshot of the resulting CAD model. Either kcl_code or kcl_path must be provided. If kcl_path is provided, it should point to a .kcl file or a directory containing a main.kcl file.
914
+
915
+ Args:
916
+ kcl_code (str | None): KCL code
917
+ kcl_path (Path | str | None): KCL path, the path should point to a .kcl file or a directory containing a main.kcl file.
918
+ padding (float): The padding to apply to the snapshot. Default is 0.2.
919
+
920
+ Returns:
921
+ bytes or None: The JPEG image contents if successful
922
+ """
923
+
924
+ logger.info("Taking a multiview snapshot of KCL")
925
+
926
+ _check_kcl_code_or_path(kcl_code, kcl_path)
927
+
928
+ try:
929
+ # None in the camera list means isometric view
930
+ # https://github.com/KittyCAD/modeling-app/blob/main/rust/kcl-python-bindings/tests/tests.py#L192
931
+ camera_list = [
932
+ kcl.CameraLookAt(
933
+ up=kcl.Point3d(x=0, y=0, z=1),
934
+ vantage=kcl.Point3d(x=0, y=-1, z=0),
935
+ center=kcl.Point3d(x=0, y=0, z=0),
936
+ ),
937
+ kcl.CameraLookAt(
938
+ up=kcl.Point3d(x=0, y=0, z=1),
939
+ vantage=kcl.Point3d(x=1, y=0, z=0),
940
+ center=kcl.Point3d(x=0, y=0, z=0),
941
+ ),
942
+ kcl.CameraLookAt(
943
+ up=kcl.Point3d(x=0, y=1, z=0),
944
+ vantage=kcl.Point3d(x=0, y=0, z=1),
945
+ center=kcl.Point3d(x=0, y=0, z=0),
946
+ ),
947
+ None,
948
+ ]
949
+
950
+ views = [
951
+ kcl.SnapshotOptions(camera=camera, padding=padding)
952
+ for camera in camera_list
953
+ ]
954
+
955
+ if kcl_code:
956
+ # The stub says list[list[int]] but it actually returns list[bytes]
957
+ jpeg_contents_list: list[bytes] = cast(
958
+ list[bytes],
959
+ cast(
960
+ object,
961
+ await kcl.execute_code_and_snapshot_views(
962
+ kcl_code, kcl.ImageFormat.Jpeg, snapshot_options=views
963
+ ),
964
+ ),
965
+ )
966
+ else:
967
+ # _check_kcl_code_or_path ensures kcl_path is valid when kcl_code is None
968
+ assert kcl_path is not None
969
+ kcl_path_resolved = Path(kcl_path)
970
+ # The stub says list[list[int]] but it actually returns list[bytes]
971
+ jpeg_contents_list = cast(
972
+ list[bytes],
973
+ cast(
974
+ object,
975
+ await kcl.execute_and_snapshot_views(
976
+ str(kcl_path_resolved),
977
+ kcl.ImageFormat.Jpeg,
978
+ snapshot_options=views,
979
+ ),
980
+ ),
981
+ )
982
+
983
+ collage = create_image_collage(jpeg_contents_list)
984
+
985
+ return collage
986
+
987
+ except Exception as e:
988
+ logger.error("Failed to take multiview snapshot: %s", e)
989
+ raise ZooMCPException(f"Failed to take multiview snapshot: {e}")
990
+
991
+
992
+ def zoo_snapshot_of_cad(
993
+ input_path: Path | str,
994
+ camera: OptionDefaultCameraLookAt | OptionViewIsometric | None = None,
995
+ padding: float = 0.2,
996
+ ) -> bytes:
997
+ """Save a single view snapshot of a CAD file.
998
+
999
+ Args:
1000
+ input_path (Path | str): Path to the CAD file to save a snapshot. The file should be one of the supported formats: .fbx, .gltf, .obj, .ply, .sldprt, .step, .stl
1001
+ camera (OptionDefaultCameraLookAt | OptionViewIsometric | None): The camera to use for the snapshot. If None, a default camera (isometric) will be used.
1002
+ padding (float): The padding to apply to the snapshot. Default is 0.2.
1003
+
1004
+ Returns:
1005
+ bytes or None: The JPEG image contents if successful
1006
+ """
1007
+
1008
+ input_path = Path(input_path)
1009
+
1010
+ # Connect to the websocket.
1011
+ with (
1012
+ kittycad_client.modeling.modeling_commands_ws(
1013
+ fps=30,
1014
+ post_effect=PostEffectType.SSAO,
1015
+ show_grid=False,
1016
+ unlocked_framerate=False,
1017
+ video_res_height=1024,
1018
+ video_res_width=1024,
1019
+ webrtc=False,
1020
+ ) as ws,
1021
+ open(input_path, "rb") as data,
1022
+ ):
1023
+ # Import files request must be sent as binary, because the file contents might be binary.
1024
+ import_id = ModelingCmdId(uuid4())
1025
+
1026
+ input_ext = input_path.suffix.split(".")[1]
1027
+ if input_ext not in [i.value for i in FileImportFormat]:
1028
+ logger.error("The provided input path does not have a valid extension")
1029
+ raise ZooMCPException(
1030
+ "The provided input path does not have a valid extension"
1031
+ )
1032
+
1033
+ input_format = _get_input_format(input_ext)
1034
+ if input_format is None:
1035
+ logger.error("The provided extension is not supported for import")
1036
+ raise ZooMCPException("The provided extension is not supported for import")
1037
+
1038
+ ws.send_binary(
1039
+ WebSocketRequest(
1040
+ OptionModelingCmdReq(
1041
+ cmd=ModelingCmd(
1042
+ OptionImportFiles(
1043
+ files=[ImportFile(data=data.read(), path=str(input_path))],
1044
+ format=input_format,
1045
+ )
1046
+ ),
1047
+ cmd_id=ModelingCmdId(import_id),
1048
+ )
1049
+ )
1050
+ )
1051
+
1052
+ # Wait for the import to succeed.
1053
+ while True:
1054
+ message = ws.recv().model_dump()
1055
+ if message["request_id"] == import_id:
1056
+ break
1057
+ if message["success"] is not True:
1058
+ raise ZooMCPException("Failed to import CAD file")
1059
+ object_id = message["resp"]["data"]["modeling_response"]["data"]["object_id"]
1060
+
1061
+ # set camera to ortho
1062
+ ortho_cam_id = ModelingCmdId(uuid4())
1063
+ ws.send(
1064
+ WebSocketRequest(
1065
+ OptionModelingCmdReq(
1066
+ cmd=ModelingCmd(OptionDefaultCameraSetOrthographic()),
1067
+ cmd_id=ModelingCmdId(ortho_cam_id),
1068
+ )
1069
+ )
1070
+ )
1071
+
1072
+ camera_look_id = ModelingCmdId(uuid4())
1073
+ if camera is None:
1074
+ camera = OptionViewIsometric()
1075
+ ws.send(
1076
+ WebSocketRequest(
1077
+ OptionModelingCmdReq(
1078
+ cmd=ModelingCmd(camera),
1079
+ cmd_id=ModelingCmdId(camera_look_id),
1080
+ )
1081
+ )
1082
+ )
1083
+
1084
+ focus_id = ModelingCmdId(uuid4())
1085
+ ws.send(
1086
+ WebSocketRequest(
1087
+ OptionModelingCmdReq(
1088
+ cmd=ModelingCmd(
1089
+ OptionZoomToFit(object_ids=[object_id], padding=padding)
1090
+ ),
1091
+ cmd_id=ModelingCmdId(focus_id),
1092
+ )
1093
+ )
1094
+ )
1095
+
1096
+ # Wait for success message.
1097
+ while True:
1098
+ message = ws.recv().model_dump()
1099
+ if message["request_id"] == focus_id:
1100
+ break
1101
+ if message["success"] is not True:
1102
+ raise ZooMCPException("Failed to zoom to fit on CAD file")
1103
+
1104
+ # Take a snapshot as a JPEG.
1105
+ snapshot_id = ModelingCmdId(uuid4())
1106
+ ws.send(
1107
+ WebSocketRequest(
1108
+ OptionModelingCmdReq(
1109
+ cmd=ModelingCmd(OptionTakeSnapshot(format=ImageFormat.JPEG)),
1110
+ cmd_id=ModelingCmdId(snapshot_id),
1111
+ )
1112
+ )
1113
+ )
1114
+
1115
+ # Wait for success message.
1116
+ while True:
1117
+ message = ws.recv().model_dump()
1118
+ if message["request_id"] == snapshot_id:
1119
+ break
1120
+ if message["success"] is not True:
1121
+ raise ZooMCPException("Failed to take snapshot of CAD file")
1122
+ jpeg_contents = message["resp"]["data"]["modeling_response"]["data"]["contents"]
1123
+
1124
+ return jpeg_contents
1125
+
1126
+
1127
+ async def zoo_snapshot_of_kcl(
1128
+ kcl_code: str | None,
1129
+ kcl_path: Path | str | None,
1130
+ camera: kcl.CameraLookAt | None = None,
1131
+ padding: float = 0.2,
1132
+ ) -> bytes:
1133
+ """Execute the KCL code and save a single view snapshot of the resulting CAD model. Either kcl_code or kcl_path must be provided. If kcl_path is provided, it should point to a .kcl file or a directory containing a main.kcl file.
1134
+
1135
+ Args:
1136
+ kcl_code (str | None): KCL code
1137
+ kcl_path (Path | str | None): KCL path, the path should point to a .kcl file or a directory containing a main.kcl file.
1138
+ camera (kcl.CameraLookAt | None): The camera to use for the snapshot. If None, a default camera (isometric) will be used.
1139
+ padding (float): The padding to apply to the snapshot. Default is 0.2.
1140
+
1141
+ Returns:
1142
+ bytes or None: The JPEG image contents if successful
1143
+ """
1144
+
1145
+ logger.info("Taking a snapshot of KCL")
1146
+
1147
+ _check_kcl_code_or_path(kcl_code, kcl_path)
1148
+
1149
+ view = kcl.SnapshotOptions(camera=camera, padding=padding)
1150
+
1151
+ if kcl_code:
1152
+ # The stub says list[list[int]] but it actually returns list[bytes]
1153
+ jpeg_contents_list: list[bytes] = cast(
1154
+ list[bytes],
1155
+ cast(
1156
+ object,
1157
+ await kcl.execute_code_and_snapshot_views(
1158
+ kcl_code, kcl.ImageFormat.Jpeg, snapshot_options=[view]
1159
+ ),
1160
+ ),
1161
+ )
1162
+ else:
1163
+ # _check_kcl_code_or_path ensures kcl_path is valid when kcl_code is None
1164
+ assert kcl_path is not None
1165
+ kcl_path_resolved = Path(kcl_path)
1166
+ # The stub says list[list[int]] but it actually returns list[bytes]
1167
+ jpeg_contents_list = cast(
1168
+ list[bytes],
1169
+ cast(
1170
+ object,
1171
+ await kcl.execute_and_snapshot_views(
1172
+ str(kcl_path_resolved),
1173
+ kcl.ImageFormat.Jpeg,
1174
+ snapshot_options=[view],
1175
+ ),
1176
+ ),
1177
+ )
1178
+
1179
+ return jpeg_contents_list[0]