claude-cad 0.1.0__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.
@@ -0,0 +1,471 @@
1
+ """
2
+ Model generator functions for Claude CAD.
3
+
4
+ This module contains functions to generate CadQuery models from various inputs
5
+ such as primitive parameters, text descriptions, and custom code.
6
+ """
7
+
8
+ import re
9
+ from typing import Dict, Any, Tuple, Union, List, Optional
10
+
11
+ import cadquery as cq
12
+
13
+ from . import utils
14
+
15
+
16
+ def create_primitive(shape_type: str, parameters: Dict[str, Any]) -> cq.Workplane:
17
+ """Create a primitive 3D shape based on the given parameters.
18
+
19
+ Args:
20
+ shape_type: The type of shape to create (box, sphere, cylinder, cone).
21
+ parameters: A dictionary of parameters for the shape.
22
+
23
+ Returns:
24
+ A CadQuery Workplane object representing the shape.
25
+
26
+ Raises:
27
+ ValueError: If the shape type is invalid or required parameters are missing.
28
+ """
29
+ # Start with a base workplane
30
+ result = cq.Workplane("XY")
31
+
32
+ if shape_type == "box":
33
+ # Required parameters for a box
34
+ length = float(parameters.get("length", 10.0))
35
+ width = float(parameters.get("width", 10.0))
36
+ height = float(parameters.get("height", 10.0))
37
+ centered = bool(parameters.get("centered", True))
38
+
39
+ # Create the box
40
+ result = result.box(length, width, height, centered=centered)
41
+
42
+ elif shape_type == "sphere":
43
+ # Required parameters for a sphere
44
+ radius = float(parameters.get("radius", 5.0))
45
+
46
+ # Create the sphere
47
+ result = result.sphere(radius)
48
+
49
+ elif shape_type == "cylinder":
50
+ # Required parameters for a cylinder
51
+ radius = float(parameters.get("radius", 5.0))
52
+ height = float(parameters.get("height", 10.0))
53
+ centered = bool(parameters.get("centered", True))
54
+
55
+ # Create the cylinder
56
+ result = result.cylinder(height, radius, centered=centered)
57
+
58
+ elif shape_type == "cone":
59
+ # Required parameters for a cone
60
+ radius1 = float(parameters.get("radius1", 5.0))
61
+ radius2 = float(parameters.get("radius2", 0.0))
62
+ height = float(parameters.get("height", 10.0))
63
+ centered = bool(parameters.get("centered", True))
64
+
65
+ # Create the cone
66
+ result = result.cone(height, radius1, radius2, centered=centered)
67
+
68
+ else:
69
+ raise ValueError(f"Invalid shape type: {shape_type}")
70
+
71
+ return result
72
+
73
+
74
+ def create_from_text(description: str) -> Tuple[cq.Workplane, str]:
75
+ """Create a 3D model from a text description.
76
+
77
+ This function interprets a natural language description and generates
78
+ a corresponding CadQuery model.
79
+
80
+ Args:
81
+ description: A natural language description of the 3D model to create.
82
+
83
+ Returns:
84
+ A tuple containing:
85
+ - The generated CadQuery Workplane object
86
+ - The Python code used to generate the model
87
+ """
88
+ # Parse the description to extract model type and parameters
89
+ model_type, parameters = _parse_description(description)
90
+
91
+ # Generate CadQuery code based on the parsed description
92
+ code = _generate_cadquery_code(model_type, parameters, description)
93
+
94
+ # Execute the generated code to create the model
95
+ model = execute_script(code)
96
+
97
+ return model, code
98
+
99
+
100
+ def execute_script(code: str) -> cq.Workplane:
101
+ """Execute CadQuery Python code to create a 3D model.
102
+
103
+ Args:
104
+ code: Python code using CadQuery to create a model.
105
+
106
+ Returns:
107
+ A CadQuery Workplane object representing the model.
108
+
109
+ Raises:
110
+ Exception: If there's an error executing the code or if it doesn't produce a valid model.
111
+ """
112
+ # Define a namespace for execution
113
+ namespace = {
114
+ "cq": cq,
115
+ "cadquery": cq,
116
+ "result": None
117
+ }
118
+
119
+ # Execute the code
120
+ try:
121
+ exec(code, namespace)
122
+ except Exception as e:
123
+ raise Exception(f"Error executing CadQuery code: {str(e)}")
124
+
125
+ # Retrieve the result variable
126
+ result = namespace.get("result")
127
+
128
+ # If no result variable, try to find a variable that looks like a CadQuery object
129
+ if result is None:
130
+ for name, value in namespace.items():
131
+ if isinstance(value, cq.Workplane):
132
+ result = value
133
+ break
134
+
135
+ # Ensure we have a valid model
136
+ if not isinstance(result, cq.Workplane):
137
+ raise Exception("CadQuery code did not produce a valid model. Make sure to assign your model to a variable named 'result'.")
138
+
139
+ return result
140
+
141
+
142
+ def _parse_description(description: str) -> Tuple[str, Dict[str, Any]]:
143
+ """Parse a natural language description to extract model type and parameters.
144
+
145
+ Args:
146
+ description: A natural language description of the 3D model.
147
+
148
+ Returns:
149
+ A tuple containing the model type and a dictionary of parameters.
150
+ """
151
+ # Default to a simple box if we can't parse anything specific
152
+ model_type = "custom"
153
+ parameters = {}
154
+
155
+ # Look for common shape types
156
+ if re.search(r'\b(box|cube|block|rectangular)\b', description, re.IGNORECASE):
157
+ model_type = "box"
158
+
159
+ # Try to extract dimensions
160
+ length_match = re.search(r'(\d+(?:\.\d+)?)\s*(?:mm|cm|m)?\s*(?:length|long)', description, re.IGNORECASE)
161
+ width_match = re.search(r'(\d+(?:\.\d+)?)\s*(?:mm|cm|m)?\s*(?:width|wide)', description, re.IGNORECASE)
162
+ height_match = re.search(r'(\d+(?:\.\d+)?)\s*(?:mm|cm|m)?\s*(?:height|tall|high)', description, re.IGNORECASE)
163
+
164
+ if length_match:
165
+ parameters["length"] = float(length_match.group(1))
166
+ if width_match:
167
+ parameters["width"] = float(width_match.group(1))
168
+ if height_match:
169
+ parameters["height"] = float(height_match.group(1))
170
+
171
+ elif re.search(r'\b(sphere|ball|round)\b', description, re.IGNORECASE):
172
+ model_type = "sphere"
173
+
174
+ # Try to extract radius
175
+ radius_match = re.search(r'(\d+(?:\.\d+)?)\s*(?:mm|cm|m)?\s*(?:radius)', description, re.IGNORECASE)
176
+ diameter_match = re.search(r'(\d+(?:\.\d+)?)\s*(?:mm|cm|m)?\s*(?:diameter)', description, re.IGNORECASE)
177
+
178
+ if radius_match:
179
+ parameters["radius"] = float(radius_match.group(1))
180
+ elif diameter_match:
181
+ parameters["radius"] = float(diameter_match.group(1)) / 2.0
182
+
183
+ elif re.search(r'\b(cylinder|tube|pipe)\b', description, re.IGNORECASE):
184
+ model_type = "cylinder"
185
+
186
+ # Try to extract dimensions
187
+ radius_match = re.search(r'(\d+(?:\.\d+)?)\s*(?:mm|cm|m)?\s*(?:radius)', description, re.IGNORECASE)
188
+ diameter_match = re.search(r'(\d+(?:\.\d+)?)\s*(?:mm|cm|m)?\s*(?:diameter)', description, re.IGNORECASE)
189
+ height_match = re.search(r'(\d+(?:\.\d+)?)\s*(?:mm|cm|m)?\s*(?:height|tall|high)', description, re.IGNORECASE)
190
+
191
+ if radius_match:
192
+ parameters["radius"] = float(radius_match.group(1))
193
+ elif diameter_match:
194
+ parameters["radius"] = float(diameter_match.group(1)) / 2.0
195
+
196
+ if height_match:
197
+ parameters["height"] = float(height_match.group(1))
198
+
199
+ elif re.search(r'\b(cone|conical|pyramid)\b', description, re.IGNORECASE):
200
+ model_type = "cone"
201
+
202
+ # Try to extract dimensions
203
+ radius1_match = re.search(r'(\d+(?:\.\d+)?)\s*(?:mm|cm|m)?\s*(?:base radius|bottom radius)', description, re.IGNORECASE)
204
+ radius2_match = re.search(r'(\d+(?:\.\d+)?)\s*(?:mm|cm|m)?\s*(?:top radius)', description, re.IGNORECASE)
205
+ height_match = re.search(r'(\d+(?:\.\d+)?)\s*(?:mm|cm|m)?\s*(?:height|tall|high)', description, re.IGNORECASE)
206
+
207
+ if radius1_match:
208
+ parameters["radius1"] = float(radius1_match.group(1))
209
+
210
+ if radius2_match:
211
+ parameters["radius2"] = float(radius2_match.group(1))
212
+ elif "cone" in description.lower():
213
+ # Default to a pointed cone if not specified
214
+ parameters["radius2"] = 0.0
215
+
216
+ if height_match:
217
+ parameters["height"] = float(height_match.group(1))
218
+
219
+ # If it's a gear or complex shape, use a custom model
220
+ elif re.search(r'\b(gear|cog|wheel|teeth)\b', description, re.IGNORECASE):
221
+ model_type = "gear"
222
+
223
+ # Extract gear parameters
224
+ num_teeth_match = re.search(r'(\d+)\s*(?:teeth|tooth)', description, re.IGNORECASE)
225
+ pitch_diameter_match = re.search(r'(\d+(?:\.\d+)?)\s*(?:mm|cm|m)?\s*(?:pitch diameter|diameter)', description, re.IGNORECASE)
226
+ pressure_angle_match = re.search(r'(\d+(?:\.\d+)?)\s*(?:degree|deg)?\s*(?:pressure angle)', description, re.IGNORECASE)
227
+
228
+ if num_teeth_match:
229
+ parameters["num_teeth"] = int(num_teeth_match.group(1))
230
+ else:
231
+ parameters["num_teeth"] = 20 # Default
232
+
233
+ if pitch_diameter_match:
234
+ parameters["pitch_diameter"] = float(pitch_diameter_match.group(1))
235
+ else:
236
+ parameters["pitch_diameter"] = 50.0 # Default
237
+
238
+ if pressure_angle_match:
239
+ parameters["pressure_angle"] = float(pressure_angle_match.group(1))
240
+ else:
241
+ parameters["pressure_angle"] = 20.0 # Default
242
+
243
+ elif re.search(r'\b(screw|bolt|thread|nut)\b', description, re.IGNORECASE):
244
+ model_type = "screw"
245
+
246
+ # Extract thread parameters
247
+ diameter_match = re.search(r'(\d+(?:\.\d+)?)\s*(?:mm|cm|m)?\s*(?:diameter)', description, re.IGNORECASE)
248
+ length_match = re.search(r'(\d+(?:\.\d+)?)\s*(?:mm|cm|m)?\s*(?:length|long)', description, re.IGNORECASE)
249
+
250
+ if diameter_match:
251
+ parameters["diameter"] = float(diameter_match.group(1))
252
+ else:
253
+ parameters["diameter"] = 5.0 # Default
254
+
255
+ if length_match:
256
+ parameters["length"] = float(length_match.group(1))
257
+ else:
258
+ parameters["length"] = 20.0 # Default
259
+
260
+ # Extract common operations
261
+ if re.search(r'\b(holes?|bore|drill)\b', description, re.IGNORECASE):
262
+ parameters["has_holes"] = True
263
+
264
+ # Try to extract hole dimensions
265
+ hole_diameter_match = re.search(r'(\d+(?:\.\d+)?)\s*(?:mm|cm|m)?\s*(?:diameter|dia)\s*(?:hole|bore)', description, re.IGNORECASE)
266
+ if hole_diameter_match:
267
+ parameters["hole_diameter"] = float(hole_diameter_match.group(1))
268
+ else:
269
+ parameters["hole_diameter"] = 5.0 # Default
270
+
271
+ if re.search(r'\b(fillet|round|radius)\b', description, re.IGNORECASE):
272
+ parameters["has_fillets"] = True
273
+
274
+ # Try to extract fillet radius
275
+ fillet_match = re.search(r'(\d+(?:\.\d+)?)\s*(?:mm|cm|m)?\s*(?:fillet|round|radius)', description, re.IGNORECASE)
276
+ if fillet_match:
277
+ parameters["fillet_radius"] = float(fillet_match.group(1))
278
+ else:
279
+ parameters["fillet_radius"] = 1.0 # Default
280
+
281
+ if re.search(r'\b(chamfer|bevel)\b', description, re.IGNORECASE):
282
+ parameters["has_chamfers"] = True
283
+
284
+ # Try to extract chamfer distance
285
+ chamfer_match = re.search(r'(\d+(?:\.\d+)?)\s*(?:mm|cm|m)?\s*(?:chamfer|bevel)', description, re.IGNORECASE)
286
+ if chamfer_match:
287
+ parameters["chamfer_distance"] = float(chamfer_match.group(1))
288
+ else:
289
+ parameters["chamfer_distance"] = 1.0 # Default
290
+
291
+ if re.search(r'\b(shell|hollow|thin)\b', description, re.IGNORECASE):
292
+ parameters["is_shelled"] = True
293
+
294
+ # Try to extract shell thickness
295
+ shell_match = re.search(r'(\d+(?:\.\d+)?)\s*(?:mm|cm|m)?\s*(?:shell|wall|thick)', description, re.IGNORECASE)
296
+ if shell_match:
297
+ parameters["shell_thickness"] = float(shell_match.group(1))
298
+ else:
299
+ parameters["shell_thickness"] = 1.0 # Default
300
+
301
+ return model_type, parameters
302
+
303
+
304
+ def _generate_cadquery_code(model_type: str, parameters: Dict[str, Any], description: str) -> str:
305
+ """Generate CadQuery Python code based on the model type and parameters.
306
+
307
+ Args:
308
+ model_type: The type of model to generate.
309
+ parameters: A dictionary of parameters for the model.
310
+ description: The original text description.
311
+
312
+ Returns:
313
+ Python code that uses CadQuery to create the model.
314
+ """
315
+ code_lines = [
316
+ "import cadquery as cq",
317
+ "",
318
+ f"# Model generated from description: {description}",
319
+ ""
320
+ ]
321
+
322
+ if model_type == "box":
323
+ length = parameters.get("length", 10.0)
324
+ width = parameters.get("width", 10.0)
325
+ height = parameters.get("height", 10.0)
326
+
327
+ code_lines.extend([
328
+ f"# Create a box with dimensions: {length} x {width} x {height}",
329
+ "result = cq.Workplane(\"XY\")",
330
+ f"result = result.box({length}, {width}, {height}, centered=True)"
331
+ ])
332
+
333
+ elif model_type == "sphere":
334
+ radius = parameters.get("radius", 5.0)
335
+
336
+ code_lines.extend([
337
+ f"# Create a sphere with radius: {radius}",
338
+ "result = cq.Workplane(\"XY\")",
339
+ f"result = result.sphere({radius})"
340
+ ])
341
+
342
+ elif model_type == "cylinder":
343
+ radius = parameters.get("radius", 5.0)
344
+ height = parameters.get("height", 10.0)
345
+
346
+ code_lines.extend([
347
+ f"# Create a cylinder with radius: {radius} and height: {height}",
348
+ "result = cq.Workplane(\"XY\")",
349
+ f"result = result.cylinder({height}, {radius}, centered=True)"
350
+ ])
351
+
352
+ elif model_type == "cone":
353
+ radius1 = parameters.get("radius1", 5.0)
354
+ radius2 = parameters.get("radius2", 0.0)
355
+ height = parameters.get("height", 10.0)
356
+
357
+ code_lines.extend([
358
+ f"# Create a cone with base radius: {radius1}, top radius: {radius2}, and height: {height}",
359
+ "result = cq.Workplane(\"XY\")",
360
+ f"result = result.cone({height}, {radius1}, {radius2}, centered=True)"
361
+ ])
362
+
363
+ elif model_type == "gear":
364
+ num_teeth = parameters.get("num_teeth", 20)
365
+ pitch_diameter = parameters.get("pitch_diameter", 50.0)
366
+ pressure_angle = parameters.get("pressure_angle", 20.0)
367
+ thickness = parameters.get("height", 5.0)
368
+
369
+ code_lines.extend([
370
+ f"# Create a gear with {num_teeth} teeth, pitch diameter: {pitch_diameter}, and pressure angle: {pressure_angle}",
371
+ "import math",
372
+ "",
373
+ f"num_teeth = {num_teeth}",
374
+ f"pitch_diameter = {pitch_diameter}",
375
+ f"pressure_angle = {pressure_angle}",
376
+ f"thickness = {thickness}",
377
+ "",
378
+ "# Calculate gear parameters",
379
+ "module_val = pitch_diameter / num_teeth",
380
+ "addendum = module_val",
381
+ "dedendum = 1.25 * module_val",
382
+ "outer_diameter = pitch_diameter + 2 * addendum",
383
+ "root_diameter = pitch_diameter - 2 * dedendum",
384
+ "base_diameter = pitch_diameter * math.cos(math.radians(pressure_angle))",
385
+ "tooth_angle = 360 / num_teeth",
386
+ "",
387
+ "# Create the gear",
388
+ "result = cq.Workplane(\"XY\")",
389
+ "",
390
+ "# Create the base cylinder",
391
+ f"result = result.circle(pitch_diameter / 2).extrude({thickness})",
392
+ "",
393
+ "# Add hub/mounting hole (optional)",
394
+ "hub_diameter = pitch_diameter * 0.3",
395
+ "result = result.faces(\">Z\").workplane().circle(hub_diameter / 2).extrude(thickness * 0.5)",
396
+ "",
397
+ "# Create a mounting hole",
398
+ "hole_diameter = pitch_diameter * 0.2",
399
+ "result = result.faces(\">Z\").workplane().circle(hole_diameter / 2).cutThruAll()"
400
+ ])
401
+
402
+ elif model_type == "screw":
403
+ diameter = parameters.get("diameter", 5.0)
404
+ length = parameters.get("length", 20.0)
405
+ head_diameter = diameter * 1.8
406
+ head_height = diameter * 0.6
407
+
408
+ code_lines.extend([
409
+ f"# Create a screw with diameter: {diameter} and length: {length}",
410
+ f"shaft_diameter = {diameter}",
411
+ f"shaft_length = {length}",
412
+ f"head_diameter = {head_diameter}",
413
+ f"head_height = {head_height}",
414
+ "",
415
+ "# Create the shaft",
416
+ "result = cq.Workplane(\"XY\")",
417
+ "result = result.circle(shaft_diameter / 2).extrude(shaft_length)",
418
+ "",
419
+ "# Create the head",
420
+ "result = result.faces(\">Z\").workplane().circle(head_diameter / 2).extrude(head_height)",
421
+ "",
422
+ "# Add a slot to the head",
423
+ "slot_width = head_diameter * 0.2",
424
+ "slot_depth = head_height * 0.5",
425
+ "result = result.faces(\">Z\").workplane()",
426
+ "result = result.slot2D(slot_width, head_diameter - 1, 0).cutBlind(-slot_depth)"
427
+ ])
428
+
429
+ else:
430
+ # Default to a simple box if model type is not recognized
431
+ code_lines.extend([
432
+ "# Create a default box",
433
+ "result = cq.Workplane(\"XY\")",
434
+ "result = result.box(10, 10, 10, centered=True)"
435
+ ])
436
+
437
+ # Add optional features based on parameters
438
+ if parameters.get("has_holes", False):
439
+ hole_diameter = parameters.get("hole_diameter", 5.0)
440
+ code_lines.extend([
441
+ "",
442
+ f"# Add a through hole with diameter: {hole_diameter}",
443
+ f"result = result.faces(\">Z\").workplane().circle({hole_diameter / 2}).cutThruAll()"
444
+ ])
445
+
446
+ if parameters.get("has_fillets", False):
447
+ fillet_radius = parameters.get("fillet_radius", 1.0)
448
+ code_lines.extend([
449
+ "",
450
+ f"# Add fillets with radius: {fillet_radius}",
451
+ f"result = result.edges().fillet({fillet_radius})"
452
+ ])
453
+
454
+ if parameters.get("has_chamfers", False):
455
+ chamfer_distance = parameters.get("chamfer_distance", 1.0)
456
+ code_lines.extend([
457
+ "",
458
+ f"# Add chamfers with distance: {chamfer_distance}",
459
+ f"result = result.edges().chamfer({chamfer_distance})"
460
+ ])
461
+
462
+ if parameters.get("is_shelled", False):
463
+ shell_thickness = parameters.get("shell_thickness", 1.0)
464
+ code_lines.extend([
465
+ "",
466
+ f"# Create a shell with thickness: {shell_thickness}",
467
+ f"result = result.shell(-{shell_thickness})"
468
+ ])
469
+
470
+ # Return the generated code
471
+ return "\n".join(code_lines)