cyclecad 0.1.0

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,992 @@
1
+ /**
2
+ * ai-chat.js - Natural Language to CAD Command Parser
3
+ * cycleCAD: Browser-based parametric 3D modeler
4
+ *
5
+ * Parses natural language descriptions into structured CAD commands.
6
+ * Supports primitives, mechanical parts, operations, and optional LLM enhancement.
7
+ */
8
+
9
+ // ============================================================================
10
+ // CONFIGURATION & DICTIONARIES
11
+ // ============================================================================
12
+
13
+ const PART_TYPE_SYNONYMS = {
14
+ // Primitives
15
+ cube: ['cube', 'box', 'block', 'square block', 'rectangular block'],
16
+ box: ['box', 'rectangular box', 'cuboid'],
17
+ cylinder: ['cylinder', 'rod', 'post', 'pin', 'shaft', 'tube'],
18
+ sphere: ['sphere', 'ball', 'round', 'globe'],
19
+ cone: ['cone', 'conical'],
20
+
21
+ // Plates & Flats
22
+ plate: ['plate', 'flat plate', 'mounting plate', 'base plate', 'flat base'],
23
+ washer: ['washer', 'flat washer', 'ring'],
24
+ spacer: ['spacer', 'shim', 'ring spacer'],
25
+
26
+ // Mechanical Parts
27
+ bracket: ['bracket', 'L-bracket', 'angle bracket', 'support bracket', 'corner bracket'],
28
+ flange: ['flange', 'flanged bearing', 'flanged housing', 'hub'],
29
+ bearing: ['bearing', 'ball bearing', 'roller bearing'],
30
+ gear: ['gear', 'spur gear', 'pinion', 'toothed wheel'],
31
+ pulley: ['pulley', 'wheel', 'sheave'],
32
+ fastener: ['bolt', 'screw', 'stud', 'pin', 'rivet'],
33
+ housing: ['housing', 'enclosure', 'case', 'body'],
34
+ };
35
+
36
+ const DIMENSION_KEYWORDS = {
37
+ radius: ['radius', 'r', 'rad'],
38
+ diameter: ['diameter', 'd', 'dia', 'od', 'outer diameter', 'id', 'inner diameter'],
39
+ height: ['height', 'h', 'tall', 'thickness'],
40
+ width: ['width', 'w', 'wide'],
41
+ depth: ['depth', 'dp'],
42
+ length: ['length', 'l', 'long'],
43
+ thickness: ['thickness', 'thick', 't'],
44
+ count: ['count', 'number of', 'qty', 'quantity'],
45
+ teeth: ['teeth', 'tooth', 'tooth count'],
46
+ angle: ['angle', 'degrees', 'deg'],
47
+ };
48
+
49
+ const OPERATION_KEYWORDS = {
50
+ fillet: ['fillet', 'round', 'rounded edge'],
51
+ chamfer: ['chamfer', 'beveled edge', 'bevel'],
52
+ hole: ['hole', 'bore', 'drill', 'perforation'],
53
+ cut: ['cut', 'remove', 'subtract', 'pocket'],
54
+ extrude: ['extrude', 'extend', 'raise', 'pull'],
55
+ revolve: ['revolve', 'rotate', 'sweep', 'spin'],
56
+ pattern: ['pattern', 'array', 'repeat', 'duplicate'],
57
+ mirror: ['mirror', 'flip', 'symmetric'],
58
+ };
59
+
60
+ const UNIT_FACTORS = {
61
+ mm: 1,
62
+ m: 1000,
63
+ cm: 10,
64
+ in: 25.4,
65
+ inch: 25.4,
66
+ ft: 304.8,
67
+ foot: 304.8,
68
+ };
69
+
70
+ // ============================================================================
71
+ // STATE MANAGEMENT
72
+ // ============================================================================
73
+
74
+ let chatState = {
75
+ messages: [],
76
+ messagesEl: null,
77
+ inputEl: null,
78
+ sendBtn: null,
79
+ onCommand: null,
80
+ apiKeys: {
81
+ gemini: null,
82
+ groq: null,
83
+ },
84
+ isLoading: false,
85
+ };
86
+
87
+ // ============================================================================
88
+ // INITIALIZATION
89
+ // ============================================================================
90
+
91
+ /**
92
+ * Initialize chat UI and wire up event handlers
93
+ * @param {HTMLElement} messagesEl - Container for chat messages
94
+ * @param {HTMLElement} inputEl - Input field for user messages
95
+ * @param {HTMLElement} sendBtn - Send button
96
+ * @param {Function} onCommand - Callback for parsed CAD commands
97
+ */
98
+ export function initChat(messagesEl, inputEl, sendBtn, onCommand) {
99
+ chatState.messagesEl = messagesEl;
100
+ chatState.inputEl = inputEl;
101
+ chatState.sendBtn = sendBtn;
102
+ chatState.onCommand = onCommand;
103
+
104
+ // Load stored API keys
105
+ const stored = localStorage.getItem('cyclecad_api_keys');
106
+ if (stored) {
107
+ try {
108
+ chatState.apiKeys = JSON.parse(stored);
109
+ } catch (e) {
110
+ console.warn('Failed to load stored API keys:', e);
111
+ }
112
+ }
113
+
114
+ // Wire up send button
115
+ if (sendBtn) {
116
+ sendBtn.addEventListener('click', () => handleSendMessage());
117
+ }
118
+
119
+ // Wire up input field Enter key
120
+ if (inputEl) {
121
+ inputEl.addEventListener('keydown', (e) => {
122
+ if (e.key === 'Enter' && !e.shiftKey) {
123
+ e.preventDefault();
124
+ handleSendMessage();
125
+ }
126
+ });
127
+ }
128
+
129
+ addMessage('ai', 'Hello! I\'m your CAD assistant. Describe the part you want to create, and I\'ll generate CAD commands. Try things like: "50mm cube", "cylinder 30mm radius 60mm tall", or "bracket 80x40x5".');
130
+ }
131
+
132
+ /**
133
+ * Handle sending a user message
134
+ */
135
+ async function handleSendMessage() {
136
+ const text = chatState.inputEl?.value.trim();
137
+ if (!text) return;
138
+
139
+ // Clear input and add user message
140
+ if (chatState.inputEl) {
141
+ chatState.inputEl.value = '';
142
+ }
143
+ addMessage('user', text);
144
+
145
+ // Show loading state
146
+ chatState.isLoading = true;
147
+ if (chatState.sendBtn) {
148
+ chatState.sendBtn.disabled = true;
149
+ }
150
+
151
+ try {
152
+ // Parse CAD commands
153
+ const commands = await parseCADPrompt(text);
154
+
155
+ if (commands && commands.length > 0) {
156
+ // Generate AI response
157
+ const response = commands
158
+ .map((cmd) => generateDescription(cmd))
159
+ .join(', ');
160
+
161
+ addMessage('ai', `Got it! ${response}`);
162
+
163
+ // Call callback for each command
164
+ if (chatState.onCommand) {
165
+ commands.forEach((cmd) => chatState.onCommand(cmd));
166
+ }
167
+ } else {
168
+ addMessage('ai', 'I couldn\'t parse that description. Try being more specific: "100x60x20 box", "cylinder r30 h60", "add a 10mm fillet", etc.');
169
+ }
170
+ } catch (error) {
171
+ console.error('Chat error:', error);
172
+ addMessage('ai', 'Sorry, I encountered an error. Please try again.');
173
+ } finally {
174
+ chatState.isLoading = false;
175
+ if (chatState.sendBtn) {
176
+ chatState.sendBtn.disabled = false;
177
+ }
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Add a message to the chat
183
+ * @param {string} role - 'user' or 'ai'
184
+ * @param {string} text - Message text
185
+ */
186
+ export function addMessage(role, text) {
187
+ chatState.messages.push({ role, text });
188
+
189
+ if (!chatState.messagesEl) return;
190
+
191
+ const msgDiv = document.createElement('div');
192
+ msgDiv.className = `chat-message chat-message-${role}`;
193
+ msgDiv.textContent = text;
194
+
195
+ chatState.messagesEl.appendChild(msgDiv);
196
+ chatState.messagesEl.scrollTop = chatState.messagesEl.scrollHeight;
197
+ }
198
+
199
+ // ============================================================================
200
+ // MAIN PARSING ENGINE
201
+ // ============================================================================
202
+
203
+ /**
204
+ * Parse natural language into CAD commands
205
+ * @param {string} text - Natural language description
206
+ * @returns {Promise<Array>} Array of CAD command objects
207
+ */
208
+ export async function parseCADPrompt(text) {
209
+ text = text.toLowerCase().trim();
210
+
211
+ // Try LLM first if available
212
+ if (chatState.apiKeys.gemini || chatState.apiKeys.groq) {
213
+ try {
214
+ const llmCommands = await queryLLM(text);
215
+ if (llmCommands && llmCommands.length > 0) {
216
+ return llmCommands;
217
+ }
218
+ } catch (error) {
219
+ console.warn('LLM query failed, falling back to local parser:', error);
220
+ }
221
+ }
222
+
223
+ // Fall back to local parsing
224
+ return localParseCADPrompt(text);
225
+ }
226
+
227
+ /**
228
+ * Local parsing fallback (no LLM required)
229
+ */
230
+ function localParseCADPrompt(text) {
231
+ const commands = [];
232
+
233
+ // Detect primary part type
234
+ const partType = detectPartType(text);
235
+ const numbers = parseNumbers(text);
236
+ const dims = parseDimensions(text);
237
+
238
+ // Handle primitives
239
+ if (partType === 'cube' || partType === 'box') {
240
+ const cmd = parseBoxCommand(text, numbers, dims);
241
+ if (cmd) commands.push(cmd);
242
+ } else if (partType === 'cylinder') {
243
+ const cmd = parseCylinderCommand(text, numbers);
244
+ if (cmd) commands.push(cmd);
245
+ } else if (partType === 'sphere') {
246
+ const cmd = parseSphereCommand(text, numbers);
247
+ if (cmd) commands.push(cmd);
248
+ } else if (partType === 'cone') {
249
+ const cmd = parseConeCommand(text, numbers);
250
+ if (cmd) commands.push(cmd);
251
+ }
252
+
253
+ // Handle mechanical parts
254
+ if (partType === 'plate') {
255
+ const cmd = parsePlateCommand(text, numbers, dims);
256
+ if (cmd) commands.push(cmd);
257
+ } else if (partType === 'bracket') {
258
+ const cmd = parseBracketCommand(text, numbers, dims);
259
+ if (cmd) commands.push(cmd);
260
+ } else if (partType === 'flange') {
261
+ const cmd = parseFlangeCommand(text, numbers);
262
+ if (cmd) commands.push(cmd);
263
+ } else if (partType === 'washer') {
264
+ const cmd = parseWasherCommand(text, numbers);
265
+ if (cmd) commands.push(cmd);
266
+ } else if (partType === 'spacer') {
267
+ const cmd = parseSpacerCommand(text, numbers);
268
+ if (cmd) commands.push(cmd);
269
+ } else if (partType === 'gear') {
270
+ const cmd = parseGearCommand(text, numbers);
271
+ if (cmd) commands.push(cmd);
272
+ }
273
+
274
+ // Handle operations (holes, fillets, etc.)
275
+ parseOperations(text, numbers, commands);
276
+
277
+ return commands;
278
+ }
279
+
280
+ // ============================================================================
281
+ // PRIMITIVE PARSERS
282
+ // ============================================================================
283
+
284
+ /**
285
+ * Parse box/cube command
286
+ */
287
+ function parseBoxCommand(text, numbers, dims) {
288
+ let width, height, depth;
289
+
290
+ if (dims.length === 3) {
291
+ [width, height, depth] = dims;
292
+ } else if (dims.length === 1) {
293
+ // Assume cube
294
+ width = height = depth = dims[0];
295
+ } else if (numbers.length >= 3) {
296
+ width = numbers[0];
297
+ height = numbers[1];
298
+ depth = numbers[2];
299
+ } else if (numbers.length === 1) {
300
+ width = height = depth = numbers[0];
301
+ } else {
302
+ return null;
303
+ }
304
+
305
+ return {
306
+ type: 'box',
307
+ width: Math.round(width * 10) / 10,
308
+ height: Math.round(height * 10) / 10,
309
+ depth: Math.round(depth * 10) / 10,
310
+ };
311
+ }
312
+
313
+ /**
314
+ * Parse cylinder command
315
+ */
316
+ function parseCylinderCommand(text, numbers) {
317
+ let radius, height;
318
+
319
+ // Try to extract radius and height from keywords
320
+ if (text.match(/radius|r\s*(\d+)|rod/i)) {
321
+ const rMatch = text.match(/radius\s*(\d+(?:\.\d+)?)|r\s*(\d+(?:\.\d+)?)/i);
322
+ if (rMatch) {
323
+ radius = parseFloat(rMatch[1] || rMatch[2]);
324
+ }
325
+ }
326
+
327
+ if (text.match(/height|h\s*(\d+)|tall/i)) {
328
+ const hMatch = text.match(/height\s*(\d+(?:\.\d+)?)|h\s*(\d+(?:\.\d+)?)/i);
329
+ if (hMatch) {
330
+ height = parseFloat(hMatch[1] || hMatch[2]);
331
+ }
332
+ }
333
+
334
+ // Fall back to first two numbers
335
+ if (!radius && !height && numbers.length >= 2) {
336
+ radius = numbers[0];
337
+ height = numbers[1];
338
+ } else if (!radius && numbers.length >= 1) {
339
+ radius = numbers[0];
340
+ height = radius;
341
+ }
342
+
343
+ if (!radius || !height) {
344
+ return null;
345
+ }
346
+
347
+ return {
348
+ type: 'cylinder',
349
+ radius: Math.round(radius * 10) / 10,
350
+ height: Math.round(height * 10) / 10,
351
+ };
352
+ }
353
+
354
+ /**
355
+ * Parse sphere command
356
+ */
357
+ function parseSphereCommand(text, numbers) {
358
+ let radius;
359
+
360
+ if (numbers.length >= 1) {
361
+ const val = numbers[0];
362
+ // Check if it's diameter or radius
363
+ if (text.match(/diameter|dia|d\s*(\d+)/i)) {
364
+ radius = val / 2;
365
+ } else {
366
+ radius = val;
367
+ }
368
+ }
369
+
370
+ if (!radius) {
371
+ return null;
372
+ }
373
+
374
+ return {
375
+ type: 'sphere',
376
+ radius: Math.round(radius * 10) / 10,
377
+ };
378
+ }
379
+
380
+ /**
381
+ * Parse cone command
382
+ */
383
+ function parseConeCommand(text, numbers) {
384
+ let radius, height;
385
+
386
+ if (numbers.length >= 2) {
387
+ radius = numbers[0];
388
+ height = numbers[1];
389
+ } else if (numbers.length === 1) {
390
+ radius = numbers[0];
391
+ height = radius;
392
+ }
393
+
394
+ if (!radius || !height) {
395
+ return null;
396
+ }
397
+
398
+ return {
399
+ type: 'cone',
400
+ radius: Math.round(radius * 10) / 10,
401
+ height: Math.round(height * 10) / 10,
402
+ };
403
+ }
404
+
405
+ // ============================================================================
406
+ // MECHANICAL PART PARSERS
407
+ // ============================================================================
408
+
409
+ /**
410
+ * Parse plate command
411
+ */
412
+ function parsePlateCommand(text, numbers, dims) {
413
+ let width, depth, thickness;
414
+
415
+ if (dims.length >= 3) {
416
+ width = dims[0];
417
+ depth = dims[1];
418
+ thickness = dims[2];
419
+ } else if (dims.length === 2) {
420
+ width = dims[0];
421
+ depth = dims[1];
422
+ thickness = 5; // default
423
+ } else if (numbers.length >= 3) {
424
+ width = numbers[0];
425
+ depth = numbers[1];
426
+ thickness = numbers[2];
427
+ } else if (numbers.length >= 2) {
428
+ width = numbers[0];
429
+ depth = numbers[1];
430
+ thickness = 5;
431
+ }
432
+
433
+ if (!width || !depth) {
434
+ return null;
435
+ }
436
+
437
+ const cmd = {
438
+ type: 'box',
439
+ width: Math.round(width * 10) / 10,
440
+ height: Math.round(thickness * 10) / 10,
441
+ depth: Math.round(depth * 10) / 10,
442
+ };
443
+
444
+ // Parse hole pattern
445
+ const holePattern = parseHolePattern(text);
446
+ if (holePattern) {
447
+ cmd.holes = holePattern;
448
+ }
449
+
450
+ return cmd;
451
+ }
452
+
453
+ /**
454
+ * Parse bracket command
455
+ */
456
+ function parseBracketCommand(text, numbers, dims) {
457
+ let width, height, thickness;
458
+
459
+ if (dims.length >= 3) {
460
+ width = dims[0];
461
+ height = dims[1];
462
+ thickness = dims[2];
463
+ } else if (numbers.length >= 3) {
464
+ width = numbers[0];
465
+ height = numbers[1];
466
+ thickness = numbers[2];
467
+ }
468
+
469
+ if (!width || !height) {
470
+ return null;
471
+ }
472
+
473
+ return {
474
+ type: 'bracket',
475
+ width: Math.round(width * 10) / 10,
476
+ height: Math.round(height * 10) / 10,
477
+ thickness: Math.round((thickness || 5) * 10) / 10,
478
+ };
479
+ }
480
+
481
+ /**
482
+ * Parse flange command
483
+ */
484
+ function parseFlangeCommand(text, numbers) {
485
+ let outerDiameter, innerDiameter, height, boltCount;
486
+
487
+ // OD first
488
+ const odMatch = text.match(/od\s*(\d+(?:\.\d+)?)|outer\s*diameter\s*(\d+(?:\.\d+)?)/i);
489
+ if (odMatch) {
490
+ outerDiameter = parseFloat(odMatch[1] || odMatch[2]);
491
+ } else if (numbers.length >= 1) {
492
+ outerDiameter = numbers[0];
493
+ }
494
+
495
+ // ID
496
+ const idMatch = text.match(/id\s*(\d+(?:\.\d+)?)|inner\s*diameter\s*(\d+(?:\.\d+)?)/i);
497
+ if (idMatch) {
498
+ innerDiameter = parseFloat(idMatch[1] || idMatch[2]);
499
+ } else if (numbers.length >= 2) {
500
+ innerDiameter = numbers[1];
501
+ }
502
+
503
+ // Height
504
+ const hMatch = text.match(/height\s*(\d+(?:\.\d+)?)|h\s*(\d+(?:\.\d+)?)|tall\s*(\d+(?:\.\d+)?)/i);
505
+ if (hMatch) {
506
+ height = parseFloat(hMatch[1] || hMatch[2] || hMatch[3]);
507
+ } else if (numbers.length >= 3) {
508
+ height = numbers[2];
509
+ }
510
+
511
+ // Bolt count
512
+ const boltMatch = text.match(/(\d+)\s*bolt/i);
513
+ if (boltMatch) {
514
+ boltCount = parseInt(boltMatch[1]);
515
+ }
516
+
517
+ if (!outerDiameter) {
518
+ return null;
519
+ }
520
+
521
+ return {
522
+ type: 'flange',
523
+ outerDiameter: Math.round(outerDiameter * 10) / 10,
524
+ innerDiameter: innerDiameter ? Math.round(innerDiameter * 10) / 10 : outerDiameter * 0.5,
525
+ height: height ? Math.round(height * 10) / 10 : 10,
526
+ boltCount: boltCount || 4,
527
+ };
528
+ }
529
+
530
+ /**
531
+ * Parse washer command
532
+ */
533
+ function parseWasherCommand(text, numbers) {
534
+ let outerDiameter, innerDiameter, thickness;
535
+
536
+ // Try M-size matching (e.g., M10, M8)
537
+ const mMatch = text.match(/m(\d+)/i);
538
+ if (mMatch) {
539
+ const size = parseInt(mMatch[1]);
540
+ // Standard washer dimensions (approximate)
541
+ outerDiameter = size * 2.5;
542
+ innerDiameter = size + 0.5;
543
+ thickness = 2;
544
+ } else if (numbers.length >= 2) {
545
+ outerDiameter = numbers[0];
546
+ innerDiameter = numbers[1];
547
+ thickness = numbers[2] || 2;
548
+ }
549
+
550
+ if (!outerDiameter || !innerDiameter) {
551
+ return null;
552
+ }
553
+
554
+ return {
555
+ type: 'washer',
556
+ outerDiameter: Math.round(outerDiameter * 10) / 10,
557
+ innerDiameter: Math.round(innerDiameter * 10) / 10,
558
+ thickness: Math.round(thickness * 10) / 10,
559
+ };
560
+ }
561
+
562
+ /**
563
+ * Parse spacer command
564
+ */
565
+ function parseSpacerCommand(text, numbers) {
566
+ let outerDiameter, innerDiameter, height;
567
+
568
+ const odMatch = text.match(/od\s*(\d+(?:\.\d+)?)|outer\s*(\d+(?:\.\d+)?)/i);
569
+ if (odMatch) {
570
+ outerDiameter = parseFloat(odMatch[1] || odMatch[2]);
571
+ } else if (numbers.length >= 1) {
572
+ outerDiameter = numbers[0];
573
+ }
574
+
575
+ const idMatch = text.match(/id\s*(\d+(?:\.\d+)?)|inner\s*(\d+(?:\.\d+)?)/i);
576
+ if (idMatch) {
577
+ innerDiameter = parseFloat(idMatch[1] || idMatch[2]);
578
+ } else if (numbers.length >= 2) {
579
+ innerDiameter = numbers[1];
580
+ }
581
+
582
+ const hMatch = text.match(/height\s*(\d+(?:\.\d+)?)|thick\s*(\d+(?:\.\d+)?)/i);
583
+ if (hMatch) {
584
+ height = parseFloat(hMatch[1] || hMatch[2]);
585
+ } else if (numbers.length >= 3) {
586
+ height = numbers[2];
587
+ }
588
+
589
+ if (!outerDiameter || !innerDiameter) {
590
+ return null;
591
+ }
592
+
593
+ return {
594
+ type: 'spacer',
595
+ outerDiameter: Math.round(outerDiameter * 10) / 10,
596
+ innerDiameter: Math.round(innerDiameter * 10) / 10,
597
+ height: height ? Math.round(height * 10) / 10 : 5,
598
+ };
599
+ }
600
+
601
+ /**
602
+ * Parse gear command
603
+ */
604
+ function parseGearCommand(text, numbers) {
605
+ let diameter, toothCount, thickness;
606
+
607
+ const diamMatch = text.match(/diameter\s*(\d+(?:\.\d+)?)|dia\s*(\d+(?:\.\d+)?)/i);
608
+ if (diamMatch) {
609
+ diameter = parseFloat(diamMatch[1] || diamMatch[2]);
610
+ } else if (numbers.length >= 1) {
611
+ diameter = numbers[0];
612
+ }
613
+
614
+ const toothMatch = text.match(/(\d+)\s*teeth|tooth\s*(\d+)/i);
615
+ if (toothMatch) {
616
+ toothCount = parseInt(toothMatch[1] || toothMatch[2]);
617
+ } else if (numbers.length >= 2) {
618
+ toothCount = numbers[1];
619
+ }
620
+
621
+ const thickMatch = text.match(/thick\s*(\d+(?:\.\d+)?)|height\s*(\d+(?:\.\d+)?)/i);
622
+ if (thickMatch) {
623
+ thickness = parseFloat(thickMatch[1] || thickMatch[2]);
624
+ } else if (numbers.length >= 3) {
625
+ thickness = numbers[2];
626
+ }
627
+
628
+ if (!diameter || !toothCount) {
629
+ return null;
630
+ }
631
+
632
+ return {
633
+ type: 'gear',
634
+ diameter: Math.round(diameter * 10) / 10,
635
+ teeth: toothCount,
636
+ thickness: thickness ? Math.round(thickness * 10) / 10 : 10,
637
+ };
638
+ }
639
+
640
+ // ============================================================================
641
+ // OPERATION PARSERS
642
+ // ============================================================================
643
+
644
+ /**
645
+ * Parse operations (holes, fillets, chamfers, etc.)
646
+ */
647
+ function parseOperations(text, numbers, commands) {
648
+ // Fillet
649
+ if (text.match(/fillet|round\s*edge|rounded/i)) {
650
+ const match = text.match(/(\d+(?:\.\d+)?)\s*mm\s*fillet|fillet\s*(\d+(?:\.\d+)?)/i);
651
+ const radius = match
652
+ ? parseFloat(match[1] || match[2])
653
+ : (numbers.length > 0 ? numbers[numbers.length - 1] : 2);
654
+
655
+ commands.push({
656
+ type: 'fillet',
657
+ radius: Math.round(radius * 10) / 10,
658
+ });
659
+ }
660
+
661
+ // Chamfer
662
+ if (text.match(/chamfer|beveled|bevel/i)) {
663
+ const match = text.match(/(\d+(?:\.\d+)?)\s*mm\s*chamfer|chamfer\s*(\d+(?:\.\d+)?)/i);
664
+ const distance = match
665
+ ? parseFloat(match[1] || match[2])
666
+ : (numbers.length > 0 ? numbers[numbers.length - 1] : 1);
667
+
668
+ commands.push({
669
+ type: 'chamfer',
670
+ distance: Math.round(distance * 10) / 10,
671
+ });
672
+ }
673
+
674
+ // Extrude
675
+ if (text.match(/extrude|extend|pull|raise/i)) {
676
+ const match = text.match(/extrude\s*(\d+(?:\.\d+)?)|(\d+(?:\.\d+)?)\s*mm\s*extrude/i);
677
+ const height = match
678
+ ? parseFloat(match[1] || match[2])
679
+ : (numbers.length > 0 ? numbers[numbers.length - 1] : 10);
680
+
681
+ commands.push({
682
+ type: 'extrude',
683
+ height: Math.round(height * 10) / 10,
684
+ });
685
+ }
686
+
687
+ // Revolve
688
+ if (text.match(/revolve|rotate|sweep|spin/i)) {
689
+ const match = text.match(/(\d+(?:\.\d+)?)\s*degree|revolve\s*(\d+)/i);
690
+ const angle = match
691
+ ? parseFloat(match[1] || match[2])
692
+ : 360;
693
+
694
+ commands.push({
695
+ type: 'revolve',
696
+ angle: Math.round(angle * 10) / 10,
697
+ });
698
+ }
699
+ }
700
+
701
+ /**
702
+ * Parse hole patterns (through holes, corner holes, bolt circles)
703
+ */
704
+ function parseHolePattern(text) {
705
+ const pattern = {};
706
+
707
+ // Through hole
708
+ const throughMatch = text.match(/(\d+(?:\.\d+)?)\s*mm\s*hole\s*through|hole\s*through\s*(?:center)?/i);
709
+ if (throughMatch) {
710
+ const diameter = parseFloat(throughMatch[1]) || 10;
711
+ return {
712
+ type: 'through',
713
+ diameter,
714
+ centerX: 0,
715
+ centerY: 0,
716
+ };
717
+ }
718
+
719
+ // Corner holes
720
+ const cornerMatch = text.match(/(\d+)\s*holes?(?:\s+at)?\s*corners?/i);
721
+ if (cornerMatch) {
722
+ const count = parseInt(cornerMatch[1]);
723
+ const diamMatch = text.match(/(\d+(?:\.\d+)?)\s*mm\s*(?:diameter|dia)/i);
724
+ const diameter = diamMatch ? parseFloat(diamMatch[1]) : 8;
725
+
726
+ return {
727
+ type: 'corners',
728
+ count,
729
+ diameter,
730
+ };
731
+ }
732
+
733
+ // Bolt circle
734
+ const boltMatch = text.match(/(\d+)\s*bolt\s*(?:hole)?s?|bolt\s*circle\s*(\d+)/i);
735
+ if (boltMatch) {
736
+ const count = parseInt(boltMatch[1] || boltMatch[2]);
737
+ const diamMatch = text.match(/(\d+(?:\.\d+)?)\s*mm\s*(?:diameter|dia|bolt)/i);
738
+ const diameter = diamMatch ? parseFloat(diamMatch[1]) : 8;
739
+ const circleMatch = text.match(/circle\s*(\d+(?:\.\d+)?)/i);
740
+ const circleDia = circleMatch ? parseFloat(circleMatch[1]) : 60;
741
+
742
+ return {
743
+ type: 'boltcircle',
744
+ count,
745
+ diameter,
746
+ circleDiameter: circleDia,
747
+ };
748
+ }
749
+
750
+ return null;
751
+ }
752
+
753
+ // ============================================================================
754
+ // UTILITY PARSERS
755
+ // ============================================================================
756
+
757
+ /**
758
+ * Parse all numbers from text with unit conversion
759
+ * @returns {Array<number>} Array of converted numbers (in mm)
760
+ */
761
+ export function parseNumbers(text) {
762
+ const numbers = [];
763
+ const numberRegex = /(\d+(?:\.\d+)?)\s*(mm|cm|m|in|inch|ft|foot)?/gi;
764
+ let match;
765
+
766
+ while ((match = numberRegex.exec(text)) !== null) {
767
+ const value = parseFloat(match[1]);
768
+ const unit = (match[2] || 'mm').toLowerCase();
769
+ const factor = UNIT_FACTORS[unit] || 1;
770
+ numbers.push(value * factor);
771
+ }
772
+
773
+ return numbers;
774
+ }
775
+
776
+ /**
777
+ * Parse dimension patterns like "100x60x20" or "100 by 60 by 20"
778
+ * @returns {Array<number>} Array of dimensions
779
+ */
780
+ export function parseDimensions(text) {
781
+ const dimensions = [];
782
+
783
+ // Try X pattern: "100x60x20"
784
+ const xMatch = text.match(/(\d+(?:\.\d+)?)\s*x\s*(\d+(?:\.\d+)?)\s*(?:x\s*(\d+(?:\.\d+)?))?/i);
785
+ if (xMatch) {
786
+ dimensions.push(parseFloat(xMatch[1]));
787
+ dimensions.push(parseFloat(xMatch[2]));
788
+ if (xMatch[3]) {
789
+ dimensions.push(parseFloat(xMatch[3]));
790
+ }
791
+ return dimensions;
792
+ }
793
+
794
+ // Try "by" pattern: "100 by 60 by 20"
795
+ const byMatch = text.match(/(\d+(?:\.\d+)?)\s+by\s+(\d+(?:\.\d+)?)\s+(?:by\s+(\d+(?:\.\d+)?))?/i);
796
+ if (byMatch) {
797
+ dimensions.push(parseFloat(byMatch[1]));
798
+ dimensions.push(parseFloat(byMatch[2]));
799
+ if (byMatch[3]) {
800
+ dimensions.push(parseFloat(byMatch[3]));
801
+ }
802
+ return dimensions;
803
+ }
804
+
805
+ return dimensions;
806
+ }
807
+
808
+ /**
809
+ * Detect primary part type from text
810
+ * @returns {string} Part type identifier
811
+ */
812
+ export function detectPartType(text) {
813
+ text = text.toLowerCase();
814
+
815
+ for (const [type, synonyms] of Object.entries(PART_TYPE_SYNONYMS)) {
816
+ for (const synonym of synonyms) {
817
+ if (text.includes(synonym)) {
818
+ return type;
819
+ }
820
+ }
821
+ }
822
+
823
+ return 'box'; // default
824
+ }
825
+
826
+ // ============================================================================
827
+ // DESCRIPTION GENERATION
828
+ // ============================================================================
829
+
830
+ /**
831
+ * Convert CAD command back to human-readable description
832
+ */
833
+ export function generateDescription(command) {
834
+ switch (command.type) {
835
+ case 'box':
836
+ return `${command.width}x${command.height}x${command.depth}mm box`;
837
+ case 'cylinder':
838
+ return `cylinder r${command.radius}mm h${command.height}mm`;
839
+ case 'sphere':
840
+ return `sphere r${command.radius}mm`;
841
+ case 'cone':
842
+ return `cone r${command.radius}mm h${command.height}mm`;
843
+ case 'bracket':
844
+ return `${command.width}x${command.height}x${command.thickness}mm bracket`;
845
+ case 'flange':
846
+ return `flange OD${command.outerDiameter}mm ID${command.innerDiameter}mm h${command.height}mm`;
847
+ case 'washer':
848
+ return `washer OD${command.outerDiameter}mm ID${command.innerDiameter}mm`;
849
+ case 'spacer':
850
+ return `spacer OD${command.outerDiameter}mm ID${command.innerDiameter}mm h${command.height}mm`;
851
+ case 'gear':
852
+ return `${command.teeth}-tooth gear d${command.diameter}mm`;
853
+ case 'fillet':
854
+ return `fillet r${command.radius}mm`;
855
+ case 'chamfer':
856
+ return `chamfer ${command.distance}mm`;
857
+ case 'extrude':
858
+ return `extrude ${command.height}mm`;
859
+ case 'revolve':
860
+ return `revolve ${command.angle}°`;
861
+ case 'cut':
862
+ return `cut ${command.shape} hole`;
863
+ default:
864
+ return JSON.stringify(command);
865
+ }
866
+ }
867
+
868
+ // ============================================================================
869
+ // API KEY MANAGEMENT
870
+ // ============================================================================
871
+
872
+ /**
873
+ * Set LLM API keys for enhanced parsing
874
+ */
875
+ export function setAPIKeys(geminiKey, groqKey) {
876
+ chatState.apiKeys.gemini = geminiKey || null;
877
+ chatState.apiKeys.groq = groqKey || null;
878
+
879
+ localStorage.setItem('cyclecad_api_keys', JSON.stringify(chatState.apiKeys));
880
+ }
881
+
882
+ /**
883
+ * Get current API keys
884
+ */
885
+ export function getAPIKeys() {
886
+ return { ...chatState.apiKeys };
887
+ }
888
+
889
+ // ============================================================================
890
+ // LLM INTEGRATION
891
+ // ============================================================================
892
+
893
+ /**
894
+ * Query LLM for complex CAD parsing
895
+ * Falls back to local parser on failure
896
+ */
897
+ async function queryLLM(prompt) {
898
+ const systemPrompt = `You are a CAD command parser. Convert natural language descriptions into JSON CAD commands.
899
+
900
+ Return a JSON array of command objects. Each command has:
901
+ - type: 'box', 'cylinder', 'sphere', 'cone', 'bracket', 'flange', 'gear', 'washer', 'spacer', 'fillet', 'chamfer', 'extrude', 'revolve', 'cut'
902
+ - Relevant dimensions (width, height, depth, radius, diameter, teeth, etc.)
903
+
904
+ Examples:
905
+ "50mm cube" → [{"type":"box","width":50,"height":50,"depth":50}]
906
+ "cylinder r30 h60" → [{"type":"cylinder","radius":30,"height":60}]
907
+ "100x60x20 box with 10mm fillet" → [{"type":"box","width":100,"height":60,"depth":20},{"type":"fillet","radius":10}]
908
+
909
+ Return ONLY valid JSON, no other text.`;
910
+
911
+ try {
912
+ // Try Gemini Flash first
913
+ if (chatState.apiKeys.gemini) {
914
+ return await queryGemini(prompt, systemPrompt);
915
+ }
916
+
917
+ // Try Groq Llama
918
+ if (chatState.apiKeys.groq) {
919
+ return await queryGroq(prompt, systemPrompt);
920
+ }
921
+ } catch (error) {
922
+ console.warn('LLM query failed:', error);
923
+ throw error;
924
+ }
925
+ }
926
+
927
+ /**
928
+ * Query Google Gemini Flash API
929
+ */
930
+ async function queryGemini(prompt, systemPrompt) {
931
+ const response = await fetch('https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=' + chatState.apiKeys.gemini, {
932
+ method: 'POST',
933
+ headers: { 'Content-Type': 'application/json' },
934
+ body: JSON.stringify({
935
+ system_instruction: { parts: [{ text: systemPrompt }] },
936
+ contents: [{ parts: [{ text: prompt }] }],
937
+ }),
938
+ });
939
+
940
+ if (!response.ok) {
941
+ throw new Error(`Gemini API error: ${response.statusText}`);
942
+ }
943
+
944
+ const data = await response.json();
945
+ const text = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
946
+ return JSON.parse(text);
947
+ }
948
+
949
+ /**
950
+ * Query Groq Llama 3.1 API
951
+ */
952
+ async function queryGroq(prompt, systemPrompt) {
953
+ const response = await fetch('https://api.groq.com/openai/v1/chat/completions', {
954
+ method: 'POST',
955
+ headers: {
956
+ 'Content-Type': 'application/json',
957
+ Authorization: `Bearer ${chatState.apiKeys.groq}`,
958
+ },
959
+ body: JSON.stringify({
960
+ model: 'llama-3.1-8b-instant',
961
+ messages: [
962
+ { role: 'system', content: systemPrompt },
963
+ { role: 'user', content: prompt },
964
+ ],
965
+ temperature: 0,
966
+ }),
967
+ });
968
+
969
+ if (!response.ok) {
970
+ throw new Error(`Groq API error: ${response.statusText}`);
971
+ }
972
+
973
+ const data = await response.json();
974
+ const text = data.choices?.[0]?.message?.content || '';
975
+ return JSON.parse(text);
976
+ }
977
+
978
+ // ============================================================================
979
+ // EXPORTS
980
+ // ============================================================================
981
+
982
+ export default {
983
+ initChat,
984
+ addMessage,
985
+ parseCADPrompt,
986
+ parseNumbers,
987
+ parseDimensions,
988
+ detectPartType,
989
+ generateDescription,
990
+ setAPIKeys,
991
+ getAPIKeys,
992
+ };