cyclecad 0.8.6 → 0.9.6

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.
package/app/js/ai-chat.js CHANGED
@@ -1,9 +1,15 @@
1
1
  /**
2
- * ai-chat.js - Natural Language to CAD Command Parser
2
+ * ai-chat.js - Smart CAD Assistant with LLM + Local Fallback
3
3
  * cycleCAD: Browser-based parametric 3D modeler
4
4
  *
5
- * Parses natural language descriptions into structured CAD commands.
6
- * Supports primitives, mechanical parts, operations, and optional LLM enhancement.
5
+ * Features:
6
+ * - Conversational AI that understands follow-ups ("make it bigger", "remove it")
7
+ * - Scene-aware: knows what parts exist and their dimensions
8
+ * - Scene operations: delete, move, rotate, scale, hide, show, undo, redo
9
+ * - Boolean ops: intersect, subtract, union
10
+ * - Multi-step: "box 100x50x30 with a 20mm hole and 5mm fillet"
11
+ * - Gemini 2.0 Flash (free tier) + Groq Llama + smart local fallback
12
+ * - Command history with ArrowUp/Down (last 20, persisted)
7
13
  */
8
14
 
9
15
  // ============================================================================
@@ -11,90 +17,43 @@
11
17
  // ============================================================================
12
18
 
13
19
  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'],
20
+ cube: ['cube', 'box', 'block', 'square', 'square block', 'rectangular block', 'cuboid', 'qube', 'boks', 'sqare', 'squere'],
21
+ cylinder: ['cylinder', 'cylindr', 'cylindre', 'cylnder', 'cyliner', 'rod', 'post', 'pin', 'shaft', 'tube', 'pipe', 'bar', 'piston', 'axle'],
22
+ sphere: ['sphere', 'spehre', 'shere', 'spehere', 'ball', 'round', 'globe', 'orb'],
23
+ cone: ['cone', 'conical', 'funnel', 'tapered'],
24
+ plate: ['plate', 'plat', 'flat plate', 'mounting plate', 'base plate', 'flat base', 'slab', 'panel'],
25
+ washer: ['washer', 'wahser', 'flat washer'],
26
+ spacer: ['spacer', 'spacr', 'shim', 'ring spacer', 'standoff', 'bushing'],
27
+ bracket: ['bracket', 'brcket', 'braket', 'brackt', 'L-bracket', 'angle bracket', 'support bracket', 'corner bracket', 'L bracket', 'angle iron'],
28
+ flange: ['flange', 'flnge', 'flanged bearing', 'flanged housing', 'hub', 'collar'],
29
+ gear: ['gear', 'geer', 'spur gear', 'pinion', 'toothed wheel', 'cog', 'sprocket'],
30
+ torus: ['torus', 'torous', 'donut', 'doughnut', 'ring', 'o-ring'],
34
31
  };
35
32
 
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
- };
33
+ const UNIT_FACTORS = { mm: 1, m: 1000, cm: 10, in: 25.4, inch: 25.4, ft: 304.8, foot: 304.8 };
69
34
 
70
35
  // ============================================================================
71
- // STATE MANAGEMENT
36
+ // STATE
72
37
  // ============================================================================
73
38
 
74
39
  let chatState = {
75
40
  messages: [],
41
+ conversationHistory: [], // for LLM context
76
42
  messagesEl: null,
77
43
  inputEl: null,
78
44
  sendBtn: null,
79
45
  onCommand: null,
80
- apiKeys: {
81
- gemini: null,
82
- groq: null,
83
- },
46
+ apiKeys: { gemini: null, groq: null },
84
47
  isLoading: false,
48
+ commandHistory: [],
49
+ historyIndex: -1,
50
+ tempInput: '',
85
51
  };
86
52
 
87
53
  // ============================================================================
88
54
  // INITIALIZATION
89
55
  // ============================================================================
90
56
 
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
57
  export function initChat(messagesEl, inputEl, sendBtn, onCommand) {
99
58
  chatState.messagesEl = messagesEl;
100
59
  chatState.inputEl = inputEl;
@@ -104,852 +63,1065 @@ export function initChat(messagesEl, inputEl, sendBtn, onCommand) {
104
63
  // Load stored API keys
105
64
  const stored = localStorage.getItem('cyclecad_api_keys');
106
65
  if (stored) {
107
- try {
108
- chatState.apiKeys = JSON.parse(stored);
109
- } catch (e) {
110
- console.warn('Failed to load stored API keys:', e);
111
- }
66
+ try { chatState.apiKeys = JSON.parse(stored); } catch (e) {}
112
67
  }
113
68
 
114
- // Wire up send button
115
- if (sendBtn) {
116
- sendBtn.addEventListener('click', () => handleSendMessage());
117
- }
69
+ // Send button
70
+ if (sendBtn) sendBtn.addEventListener('click', () => handleSendMessage());
71
+
72
+ // Command history (last 20 commands, persisted)
73
+ chatState.commandHistory = JSON.parse(localStorage.getItem('cyclecad_chat_history') || '[]').slice(-20);
74
+ chatState.historyIndex = -1;
75
+ chatState.tempInput = '';
118
76
 
119
- // Wire up input field Enter key
77
+ // Wire up input field Enter + ArrowUp/Down history
120
78
  if (inputEl) {
121
79
  inputEl.addEventListener('keydown', (e) => {
122
80
  if (e.key === 'Enter' && !e.shiftKey) {
123
81
  e.preventDefault();
124
82
  handleSendMessage();
83
+ } else if (e.key === 'ArrowUp') {
84
+ e.preventDefault();
85
+ const hist = chatState.commandHistory;
86
+ if (hist.length === 0) return;
87
+ if (chatState.historyIndex === -1) {
88
+ chatState.tempInput = inputEl.value;
89
+ chatState.historyIndex = hist.length - 1;
90
+ } else if (chatState.historyIndex > 0) {
91
+ chatState.historyIndex--;
92
+ }
93
+ inputEl.value = hist[chatState.historyIndex] || '';
94
+ setTimeout(() => inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length), 0);
95
+ } else if (e.key === 'ArrowDown') {
96
+ e.preventDefault();
97
+ const hist = chatState.commandHistory;
98
+ if (chatState.historyIndex === -1) return;
99
+ if (chatState.historyIndex < hist.length - 1) {
100
+ chatState.historyIndex++;
101
+ inputEl.value = hist[chatState.historyIndex] || '';
102
+ } else {
103
+ chatState.historyIndex = -1;
104
+ inputEl.value = chatState.tempInput || '';
105
+ }
106
+ setTimeout(() => inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length), 0);
125
107
  }
126
108
  });
127
109
  }
128
110
 
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".');
111
+ addMessage('ai', 'Hi! I\'m your CAD assistant. I can create parts, modify them, and answer questions.\n\nTry: "cylinder 50mm diameter 80 tall", "bracket 80x40x5", "remove it", "undo", or "what can you do?"');
130
112
  }
131
113
 
132
- /**
133
- * Handle sending a user message
134
- */
114
+ // ============================================================================
115
+ // MESSAGE HANDLING
116
+ // ============================================================================
117
+
135
118
  async function handleSendMessage() {
136
119
  const text = chatState.inputEl?.value.trim();
137
120
  if (!text) return;
138
121
 
139
- // Clear input and add user message
140
- if (chatState.inputEl) {
141
- chatState.inputEl.value = '';
142
- }
122
+ // Save to command history
123
+ chatState.commandHistory.push(text);
124
+ if (chatState.commandHistory.length > 20) chatState.commandHistory.shift();
125
+ chatState.historyIndex = -1;
126
+ chatState.tempInput = '';
127
+ try { localStorage.setItem('cyclecad_chat_history', JSON.stringify(chatState.commandHistory)); } catch(e) {}
128
+
129
+ if (chatState.inputEl) chatState.inputEl.value = '';
143
130
  addMessage('user', text);
144
131
 
145
- // Show loading state
146
132
  chatState.isLoading = true;
147
- if (chatState.sendBtn) {
148
- chatState.sendBtn.disabled = true;
149
- }
133
+ if (chatState.sendBtn) chatState.sendBtn.disabled = true;
150
134
 
151
135
  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(', ');
136
+ const result = await processMessage(text);
160
137
 
161
- addMessage('ai', `Got it! ${response}`);
138
+ if (result.reply) {
139
+ addMessage('ai', result.reply);
140
+ }
162
141
 
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.');
142
+ if (result.commands && result.commands.length > 0 && chatState.onCommand) {
143
+ result.commands.forEach(cmd => chatState.onCommand(cmd));
169
144
  }
170
145
  } catch (error) {
171
146
  console.error('Chat error:', error);
172
- addMessage('ai', 'Sorry, I encountered an error. Please try again.');
147
+ addMessage('ai', 'Something went wrong. Try again or rephrase your request.');
173
148
  } finally {
174
149
  chatState.isLoading = false;
175
- if (chatState.sendBtn) {
176
- chatState.sendBtn.disabled = false;
177
- }
150
+ if (chatState.sendBtn) chatState.sendBtn.disabled = false;
178
151
  }
179
152
  }
180
153
 
181
- /**
182
- * Add a message to the chat
183
- * @param {string} role - 'user' or 'ai'
184
- * @param {string} text - Message text
185
- */
186
154
  export function addMessage(role, text) {
187
155
  chatState.messages.push({ role, text });
188
156
 
157
+ // Keep conversation history for LLM context (last 10 turns)
158
+ chatState.conversationHistory.push({ role: role === 'ai' ? 'model' : 'user', text });
159
+ if (chatState.conversationHistory.length > 20) chatState.conversationHistory.splice(0, 2);
160
+
189
161
  if (!chatState.messagesEl) return;
190
162
 
191
163
  const msgDiv = document.createElement('div');
192
164
  msgDiv.className = `chat-message chat-message-${role}`;
193
- msgDiv.textContent = text;
165
+ // Support basic formatting
166
+ msgDiv.innerHTML = text
167
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
168
+ .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
169
+ .replace(/\n/g, '<br>');
194
170
 
195
171
  chatState.messagesEl.appendChild(msgDiv);
196
172
  chatState.messagesEl.scrollTop = chatState.messagesEl.scrollHeight;
197
173
  }
198
174
 
199
175
  // ============================================================================
200
- // MAIN PARSING ENGINE
176
+ // FUZZY ACTION KEYWORD MATCHING
201
177
  // ============================================================================
202
178
 
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();
179
+ const ACTION_KEYWORDS = {
180
+ intersect: ['intersect', 'interset', 'intersec', 'interect', 'intersct', 'intesect', 'inersect', 'intersekt', 'intersection', 'overlap'],
181
+ subtract: ['subtract', 'subtrat', 'substract', 'subract', 'subtarct', 'subt', 'cut', 'difference', 'minus'],
182
+ union: ['union', 'combine', 'merge', 'join', 'fuse', 'unoin', 'uniom'],
183
+ delete: ['delete', 'delet', 'deleet', 'remove', 'remov', 'erase', 'trash', 'destroy', 'get rid of'],
184
+ move: ['move', 'mov', 'shift', 'translate', 'drag', 'push', 'pull', 'nudge'],
185
+ rotate: ['rotate', 'rotat', 'rotaet', 'spin', 'turn', 'twist'],
186
+ scale: ['scale', 'scael', 'resize', 'bigger', 'smaller', 'larger', 'grow', 'shrink'],
187
+ duplicate: ['duplicate', 'duplicat', 'copy', 'clone'],
188
+ hide: ['hide', 'hid', 'invisible'],
189
+ undo: ['undo', 'unod', 'undoo'],
190
+ redo: ['redo', 'redoo'],
191
+ fillet: ['fillet', 'filet', 'fillt', 'round edge', 'rounded'],
192
+ chamfer: ['chamfer', 'chamfr', 'chamfar', 'bevel'],
193
+ color: ['color', 'colour', 'paint', 'material'],
194
+ export: ['export', 'exprot', 'exprt', 'download', 'save as'],
195
+ };
210
196
 
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);
197
+ function fuzzyMatchAction(text) {
198
+ const lower = text.toLowerCase();
199
+ // 1. Direct keyword match
200
+ for (const [action, keywords] of Object.entries(ACTION_KEYWORDS)) {
201
+ for (const kw of keywords) {
202
+ if (lower.includes(kw)) return action;
220
203
  }
221
204
  }
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;
205
+ // 2. Levenshtein fuzzy match on individual words
206
+ const words = lower.replace(/[^a-z\s]/g, '').split(/\s+/).filter(w => w.length >= 3);
207
+ const targets = [];
208
+ for (const [action, keywords] of Object.entries(ACTION_KEYWORDS)) {
209
+ for (const kw of keywords) {
210
+ if (!kw.includes(' ')) targets.push({ word: kw, action });
211
+ }
212
+ }
213
+ let best = null, bestDist = Infinity;
214
+ for (const w of words) {
215
+ for (const t of targets) {
216
+ const d = levenshtein(w, t.word);
217
+ const threshold = t.word.length <= 4 ? 1 : 2;
218
+ if (d <= threshold && d < bestDist) { bestDist = d; best = t.action; }
219
+ }
220
+ }
221
+ return best;
278
222
  }
279
223
 
280
224
  // ============================================================================
281
- // PRIMITIVE PARSERS
225
+ // SMART MESSAGE PROCESSING
282
226
  // ============================================================================
283
227
 
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;
228
+ async function processMessage(text) {
229
+ const lower = text.toLowerCase().trim();
230
+
231
+ // 1. Check for scene action commands (delete, undo, move, etc.)
232
+ const actionResult = handleSceneAction(lower, text);
233
+ if (actionResult) return actionResult;
234
+
235
+ // 1b. Fuzzy action matching — catches typos like "interset", "subtrat", "deleet"
236
+ const fuzzyAction = fuzzyMatchAction(lower);
237
+ if (fuzzyAction) {
238
+ const corrected = lower.replace(
239
+ new RegExp(lower.split(/\s+/).find(w => {
240
+ for (const kw of (ACTION_KEYWORDS[fuzzyAction] || [])) {
241
+ if (levenshtein(w, kw) <= 2) return true;
242
+ }
243
+ return false;
244
+ }) || '', 'i'),
245
+ fuzzyAction
246
+ );
247
+ const retryResult = handleSceneAction(corrected, text);
248
+ if (retryResult) return retryResult;
303
249
  }
304
250
 
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;
251
+ // 2. Check for questions / conversational messages
252
+ const conversationalReply = handleConversational(lower, text);
253
+ if (conversationalReply) return { reply: conversationalReply, commands: [] };
318
254
 
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]);
255
+ // 3. Try LLM if API key available
256
+ if (chatState.apiKeys.gemini || chatState.apiKeys.groq) {
257
+ try {
258
+ const llmResult = await querySmartLLM(text);
259
+ if (llmResult) return llmResult;
260
+ } catch (e) {
261
+ console.warn('LLM failed, using local parser:', e.message);
331
262
  }
332
263
  }
333
264
 
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;
265
+ // 4. Smart local parsing for creation commands
266
+ const commands = localParseCADPrompt(lower);
267
+ if (commands.length > 0) {
268
+ const desc = commands.map(c => generateDescription(c)).join(', then ');
269
+ return { reply: `Creating: ${desc}`, commands };
345
270
  }
346
271
 
272
+ // 5. Nothing matched — give helpful response
347
273
  return {
348
- type: 'cylinder',
349
- radius: Math.round(radius * 10) / 10,
350
- height: Math.round(height * 10) / 10,
274
+ reply: `I didn't understand "${text}". Here's what I can do:\n\n**Create:** "box 100x50x30", "cylinder r25 h60", "bracket 80x40x5"\n**Modify:** "remove it", "delete the box", "undo", "redo"\n**Transform:** "move it up 20", "rotate 45", "scale 2x"\n**Operations:** "fillet 5mm", "chamfer 3mm"\n**Booleans:** "subtract box from cylinder", "intersect"\n**Scene:** "hide it", "show all", "select the cylinder", "clear scene"\n**Questions:** "what shapes can you make?", "what's in the scene?"`,
275
+ commands: []
351
276
  };
352
277
  }
353
278
 
354
- /**
355
- * Parse sphere command
356
- */
357
- function parseSphereCommand(text, numbers) {
358
- let radius;
279
+ // ============================================================================
280
+ // SCENE ACTION HANDLER (delete, undo, move, hide, booleans, etc.)
281
+ // ============================================================================
359
282
 
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;
283
+ function handleSceneAction(lower, original) {
284
+ const features = window.APP?.features || [];
285
+ const selectedIdx = window.APP?.selectedFeatureIndex ?? -1;
286
+
287
+ // --- DELETE / REMOVE ---
288
+ if (/^(delet|delete|remov|remove|erase|trash|get rid of|destroy)\w*\b/.test(lower) ||
289
+ /\b(delet|delete|remov|remove|erase)\w*\s+(it|this|that|the last|last|selected|current)\b/.test(lower) ||
290
+ /^(remove|delete|remov|delet)\w*\s+it\s*$/.test(lower)) {
291
+
292
+ // Find which part to delete
293
+ const targetIdx = resolveTarget(lower, features, selectedIdx);
294
+ if (targetIdx >= 0 && targetIdx < features.length) {
295
+ const name = features[targetIdx].name || 'Part';
296
+ return {
297
+ reply: `Removed "${name}".`,
298
+ commands: [{ action: 'delete', index: targetIdx }]
299
+ };
367
300
  }
301
+ if (features.length > 0) {
302
+ // Default: remove last part
303
+ const last = features[features.length - 1];
304
+ return {
305
+ reply: `Removed "${last.name || 'last part'}".`,
306
+ commands: [{ action: 'delete', index: features.length - 1 }]
307
+ };
308
+ }
309
+ return { reply: 'Nothing to remove — the scene is empty.', commands: [] };
368
310
  }
369
311
 
370
- if (!radius) {
371
- return null;
312
+ // --- UNDO ---
313
+ if (/^undo\s*$/.test(lower) || /^(ctrl\+?z|go back|step back)\s*$/.test(lower)) {
314
+ return { reply: 'Undone.', commands: [{ action: 'undo' }] };
372
315
  }
373
316
 
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;
317
+ // --- REDO ---
318
+ if (/^redo\s*$/.test(lower) || /^(ctrl\+?y|step forward)\s*$/.test(lower)) {
319
+ return { reply: 'Redone.', commands: [{ action: 'redo' }] };
392
320
  }
393
321
 
394
- if (!radius || !height) {
395
- return null;
322
+ // --- CLEAR SCENE ---
323
+ if (/^(clear|clean|empty|reset)\s*(scene|all|everything|viewport)?\s*$/.test(lower)) {
324
+ return { reply: 'Scene cleared.', commands: [{ action: 'clearScene' }] };
396
325
  }
397
326
 
398
- return {
399
- type: 'cone',
400
- radius: Math.round(radius * 10) / 10,
401
- height: Math.round(height * 10) / 10,
402
- };
403
- }
327
+ // --- HIDE ---
328
+ if (/^hide\b/.test(lower) || /\bhide\s+(it|this|that|selected|the)\b/.test(lower)) {
329
+ const targetIdx = resolveTarget(lower, features, selectedIdx);
330
+ const idx = targetIdx >= 0 ? targetIdx : (selectedIdx >= 0 ? selectedIdx : features.length - 1);
331
+ if (idx >= 0 && idx < features.length) {
332
+ return {
333
+ reply: `Hidden "${features[idx].name || 'Part'}".`,
334
+ commands: [{ action: 'hide', index: idx }]
335
+ };
336
+ }
337
+ return { reply: 'Nothing to hide.', commands: [] };
338
+ }
404
339
 
405
- // ============================================================================
406
- // MECHANICAL PART PARSERS
407
- // ============================================================================
340
+ // --- SHOW ALL ---
341
+ if (/^show\s*all\s*$/.test(lower) || /^unhide\s*all\s*$/.test(lower)) {
342
+ return { reply: 'All parts visible.', commands: [{ action: 'showAll' }] };
343
+ }
408
344
 
409
- /**
410
- * Parse plate command
411
- */
412
- function parsePlateCommand(text, numbers, dims) {
413
- let width, depth, thickness;
345
+ // --- SELECT ---
346
+ if (/^select\b/.test(lower)) {
347
+ const targetIdx = resolveTarget(lower, features, selectedIdx);
348
+ if (targetIdx >= 0) {
349
+ return {
350
+ reply: `Selected "${features[targetIdx].name || 'Part'}".`,
351
+ commands: [{ action: 'select', index: targetIdx }]
352
+ };
353
+ }
354
+ return { reply: 'Could not find that part. Use "what\'s in the scene?" to see available parts.', commands: [] };
355
+ }
414
356
 
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
- };
357
+ // --- MOVE ---
358
+ if (/^move\b/.test(lower) || /\bmove\s+(it|this|that|the)\b/.test(lower)) {
359
+ const dir = parseDirection(lower);
360
+ const dist = parseFirstNumber(lower) || 20;
361
+ const targetIdx = resolveTarget(lower, features, selectedIdx);
362
+ const idx = targetIdx >= 0 ? targetIdx : (selectedIdx >= 0 ? selectedIdx : features.length - 1);
363
+ if (idx >= 0 && idx < features.length) {
364
+ return {
365
+ reply: `Moved "${features[idx].name || 'Part'}" ${dir.label} by ${dist}mm.`,
366
+ commands: [{ action: 'move', index: idx, axis: dir.axis, distance: dist * dir.sign }]
367
+ };
368
+ }
369
+ return { reply: 'Nothing to move.', commands: [] };
370
+ }
443
371
 
444
- // Parse hole pattern
445
- const holePattern = parseHolePattern(text);
446
- if (holePattern) {
447
- cmd.holes = holePattern;
372
+ // --- ROTATE ---
373
+ if (/^rotate\b/.test(lower) || /\brotate\s+(it|this|that|the)\b/.test(lower)) {
374
+ const angle = parseFirstNumber(lower) || 90;
375
+ const axis = /\b[xX]\b/.test(lower) ? 'x' : /\b[zZ]\b/.test(lower) ? 'z' : 'y';
376
+ const targetIdx = resolveTarget(lower, features, selectedIdx);
377
+ const idx = targetIdx >= 0 ? targetIdx : (selectedIdx >= 0 ? selectedIdx : features.length - 1);
378
+ if (idx >= 0 && idx < features.length) {
379
+ return {
380
+ reply: `Rotated "${features[idx].name || 'Part'}" ${angle}° around ${axis.toUpperCase()}.`,
381
+ commands: [{ action: 'rotate', index: idx, axis, angle }]
382
+ };
383
+ }
384
+ return { reply: 'Nothing to rotate.', commands: [] };
448
385
  }
449
386
 
450
- return cmd;
451
- }
387
+ // --- MODIFY / RESIZE / CHANGE DIMENSION ---
388
+ // "reduce the height to 20", "set width to 50", "change radius to 15", "make it 30mm tall"
389
+ if (/\b(reduce|increase|set|change|make|adjust|modify)\b.*\b(height|width|depth|radius|diameter|size|thickness|tall|wide|long|high)\b/.test(lower) ||
390
+ /\b(height|width|depth|radius|diameter|thickness)\s*(to|=)\s*\d/.test(lower)) {
391
+ const val = parseFirstNumber(lower.replace(/.*?(to|=)\s*/i, 'to '));
392
+ const num = val || parseFirstNumber(lower);
393
+ if (num > 0) {
394
+ const targetIdx = resolveTarget(lower, features, selectedIdx);
395
+ const idx = targetIdx >= 0 ? targetIdx : (selectedIdx >= 0 ? selectedIdx : features.length - 1);
396
+ if (idx >= 0 && idx < features.length) {
397
+ const f = features[idx];
398
+ // Determine which dimension to change
399
+ let dim = 'height';
400
+ if (/\b(width|wide)\b/.test(lower)) dim = 'width';
401
+ else if (/\b(depth|deep|long|length)\b/.test(lower)) dim = 'depth';
402
+ else if (/\b(radius|rad)\b/.test(lower)) dim = 'radius';
403
+ else if (/\b(diameter|dia)\b/.test(lower)) dim = 'diameter';
404
+ else if (/\b(thickness|thick)\b/.test(lower)) dim = 'thickness';
405
+ else if (/\b(height|tall|high)\b/.test(lower)) dim = 'height';
406
+ else if (/\b(size)\b/.test(lower)) dim = 'size';
407
+
408
+ return {
409
+ reply: `Changed ${dim} of "${f.name || 'Part'}" to ${num}mm.`,
410
+ commands: [{ action: 'modifyDimension', index: idx, dimension: dim, value: num }]
411
+ };
412
+ }
413
+ }
414
+ }
452
415
 
453
- /**
454
- * Parse bracket command
455
- */
456
- function parseBracketCommand(text, numbers, dims) {
457
- let width, height, thickness;
416
+ // --- SCALE / MAKE BIGGER/SMALLER ---
417
+ if (/\b(scale|bigger|smaller|larger|resize|grow|shrink)\b/.test(lower) ||
418
+ /\bmake\s+it\s+(bigger|smaller|larger|taller|shorter|wider|thinner)\b/.test(lower)) {
419
+ let factor = parseFirstNumber(lower);
420
+ if (!factor) {
421
+ if (/bigger|larger|grow|taller|wider/i.test(lower)) factor = 1.5;
422
+ else if (/smaller|shrink|shorter|thinner/i.test(lower)) factor = 0.67;
423
+ else factor = 1.5;
424
+ }
425
+ // If user said "scale 2x" or "2x bigger"
426
+ if (/(\d+(?:\.\d+)?)\s*x\b/.test(lower)) {
427
+ factor = parseFloat(lower.match(/(\d+(?:\.\d+)?)\s*x\b/)[1]);
428
+ }
429
+ const targetIdx = resolveTarget(lower, features, selectedIdx);
430
+ const idx = targetIdx >= 0 ? targetIdx : (selectedIdx >= 0 ? selectedIdx : features.length - 1);
431
+ if (idx >= 0 && idx < features.length) {
432
+ return {
433
+ reply: `Scaled "${features[idx].name || 'Part'}" by ${factor}x.`,
434
+ commands: [{ action: 'scale', index: idx, factor }]
435
+ };
436
+ }
437
+ return { reply: 'Nothing to scale.', commands: [] };
438
+ }
458
439
 
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];
440
+ // --- DUPLICATE / COPY ---
441
+ if (/^(duplicate|copy|clone)\b/.test(lower)) {
442
+ const targetIdx = resolveTarget(lower, features, selectedIdx);
443
+ const idx = targetIdx >= 0 ? targetIdx : (selectedIdx >= 0 ? selectedIdx : features.length - 1);
444
+ if (idx >= 0 && idx < features.length) {
445
+ return {
446
+ reply: `Duplicated "${features[idx].name || 'Part'}".`,
447
+ commands: [{ action: 'duplicate', index: idx }]
448
+ };
449
+ }
450
+ return { reply: 'Nothing to duplicate.', commands: [] };
467
451
  }
468
452
 
469
- if (!width || !height) {
470
- return null;
453
+ // --- BOOLEAN OPERATIONS (subtract / intersect / union) ---
454
+ // Detect boolean intent via exact match OR fuzzy
455
+ const booleanOp = detectBooleanOp(lower);
456
+ if (booleanOp) {
457
+ return handleBoolean(booleanOp, lower, features, selectedIdx);
471
458
  }
472
459
 
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
- }
460
+ // --- COLOR / MATERIAL ---
461
+ if (/\b(color|colour|paint|material)\b/.test(lower)) {
462
+ const colorMatch = lower.match(/\b(red|green|blue|yellow|orange|purple|white|black|gray|grey|silver|gold|pink|cyan|magenta)\b/);
463
+ if (colorMatch) {
464
+ const targetIdx = resolveTarget(lower, features, selectedIdx);
465
+ const idx = targetIdx >= 0 ? targetIdx : (selectedIdx >= 0 ? selectedIdx : features.length - 1);
466
+ if (idx >= 0 && idx < features.length) {
467
+ return {
468
+ reply: `Changed color of "${features[idx].name || 'Part'}" to ${colorMatch[1]}.`,
469
+ commands: [{ action: 'color', index: idx, color: colorMatch[1] }]
470
+ };
471
+ }
472
+ }
473
+ return { reply: 'Specify a color: "color it red", "make it blue", "paint the box green".', commands: [] };
474
+ }
480
475
 
481
- /**
482
- * Parse flange command
483
- */
484
- function parseFlangeCommand(text, numbers) {
485
- let outerDiameter, innerDiameter, height, boltCount;
476
+ // --- RENAME ---
477
+ if (/^rename\b/.test(lower)) {
478
+ const nameMatch = original.match(/rename\s+(?:it\s+)?(?:to\s+)?["']?([^"']+?)["']?\s*$/i);
479
+ if (nameMatch) {
480
+ const idx = selectedIdx >= 0 ? selectedIdx : features.length - 1;
481
+ if (idx >= 0 && idx < features.length) {
482
+ return {
483
+ reply: `Renamed to "${nameMatch[1].trim()}".`,
484
+ commands: [{ action: 'rename', index: idx, name: nameMatch[1].trim() }]
485
+ };
486
+ }
487
+ }
488
+ return { reply: 'Usage: "rename to My Part Name"', commands: [] };
489
+ }
486
490
 
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];
491
+ // --- FIT VIEW / ZOOM TO FIT ---
492
+ if (/^(fit|zoom to fit|fit all|zoom all|reset view|home view|reset camera)\s*$/.test(lower)) {
493
+ return { reply: 'View reset.', commands: [{ action: 'fitAll' }] };
493
494
  }
494
495
 
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];
496
+ // --- WIREFRAME ---
497
+ if (/^(wireframe|toggle wireframe)\s*$/.test(lower)) {
498
+ return { reply: 'Wireframe toggled.', commands: [{ action: 'wireframe' }] };
501
499
  }
502
500
 
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];
501
+ // --- GRID ---
502
+ if (/^(grid|toggle grid)\s*$/.test(lower)) {
503
+ return { reply: 'Grid toggled.', commands: [{ action: 'grid' }] };
509
504
  }
510
505
 
511
- // Bolt count
512
- const boltMatch = text.match(/(\d+)\s*bolt/i);
513
- if (boltMatch) {
514
- boltCount = parseInt(boltMatch[1]);
506
+ // --- EXPORT ---
507
+ if (/^export\b/.test(lower)) {
508
+ const fmt = /stl/i.test(lower) ? 'stl' : /obj/i.test(lower) ? 'obj' : /gltf|glb/i.test(lower) ? 'gltf' : /dxf/i.test(lower) ? 'dxf' : /json/i.test(lower) ? 'json' : 'stl';
509
+ return { reply: `Exporting as ${fmt.toUpperCase()}...`, commands: [{ action: 'export', format: fmt }] };
515
510
  }
516
511
 
517
- if (!outerDiameter) {
518
- return null;
512
+ // --- SKETCH TOOLS ---
513
+ if (/^(start|begin|new)\s*sketch\s*$/i.test(lower) || /^sketch\s*$/i.test(lower)) {
514
+ return { reply: 'Starting sketch mode. Draw on the grid plane.', commands: [{ action: 'startSketch' }] };
515
+ }
516
+ if (/^(end|finish|done|close)\s*sketch\s*$/i.test(lower)) {
517
+ return { reply: 'Sketch completed.', commands: [{ action: 'endSketch' }] };
518
+ }
519
+ if (/^(draw\s+)?line\s*$/i.test(lower)) {
520
+ return { reply: 'Line tool active. Click to place points.', commands: [{ action: 'sketchTool', tool: 'line' }] };
521
+ }
522
+ if (/^(draw\s+)?rect(angle)?\s*$/i.test(lower)) {
523
+ return { reply: 'Rectangle tool active. Click to place corners.', commands: [{ action: 'sketchTool', tool: 'rect' }] };
524
+ }
525
+ if (/^(draw\s+)?circle\s*$/i.test(lower)) {
526
+ return { reply: 'Circle tool active. Click center, then radius.', commands: [{ action: 'sketchTool', tool: 'circle' }] };
527
+ }
528
+ if (/^(draw\s+)?arc\s*$/i.test(lower)) {
529
+ return { reply: 'Arc tool active. Click start, mid, end.', commands: [{ action: 'sketchTool', tool: 'arc' }] };
519
530
  }
520
531
 
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
- }
532
+ // --- 3D OPERATIONS ---
533
+ if (/^extrude\s*$/i.test(lower) || /^extrude\s+sketch\s*$/i.test(lower)) {
534
+ return { reply: 'Extruding sketch...', commands: [{ action: 'extrude' }] };
535
+ }
536
+ if (/^revolve\s*$/i.test(lower)) {
537
+ return { reply: 'Revolving sketch...', commands: [{ action: 'revolve' }] };
538
+ }
539
+ if (/^(cut|boolean cut)\s*$/i.test(lower)) {
540
+ return { reply: 'Cut mode active. Select tool body.', commands: [{ action: 'cut' }] };
541
+ }
529
542
 
530
- /**
531
- * Parse washer command
532
- */
533
- function parseWasherCommand(text, numbers) {
534
- let outerDiameter, innerDiameter, thickness;
543
+ // --- ADVANCED OPERATIONS ---
544
+ if (/\b(sweep)\b/i.test(lower)) {
545
+ return { reply: 'Sweep operation. Select profile and path.', commands: [{ action: 'sweep' }] };
546
+ }
547
+ if (/\b(loft)\b/i.test(lower)) {
548
+ return { reply: 'Loft operation. Select profiles to blend.', commands: [{ action: 'loft' }] };
549
+ }
550
+ if (/\b(shell|hollow)\b/i.test(lower)) {
551
+ const t = parseFirstNumber(lower) || 2;
552
+ return { reply: `Shell with ${t}mm wall thickness.`, commands: [{ action: 'shell', thickness: t }] };
553
+ }
554
+ if (/\b(pattern|array)\b/i.test(lower)) {
555
+ const n = parseFirstNumber(lower) || 4;
556
+ return { reply: `Pattern: ${n} copies.`, commands: [{ action: 'pattern', count: n }] };
557
+ }
558
+ if (/\b(mirror)\b/i.test(lower)) {
559
+ const plane = /\b[xX]\b/.test(lower) ? 'x' : /\b[zZ]\b/.test(lower) ? 'z' : 'y';
560
+ return { reply: `Mirrored across ${plane.toUpperCase()} plane.`, commands: [{ action: 'mirror', plane }] };
561
+ }
562
+ if (/\b(thread)\b/i.test(lower)) {
563
+ return { reply: 'Adding thread to selected cylinder.', commands: [{ action: 'thread' }] };
564
+ }
565
+ if (/\b(spring)\b/i.test(lower)) {
566
+ const d = parseFirstNumber(lower) || 20;
567
+ return { reply: `Creating spring (d=${d}mm).`, commands: [{ action: 'spring', diameter: d }] };
568
+ }
535
569
 
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;
570
+ // --- SHEET METAL ---
571
+ if (/\b(bend|sheet\s*metal\s*bend)\b/i.test(lower)) {
572
+ return { reply: 'Sheet metal bend.', commands: [{ action: 'bend' }] };
573
+ }
574
+ if (/\b(unfold|flat\s*pattern)\b/i.test(lower)) {
575
+ return { reply: 'Unfolding sheet metal to flat pattern.', commands: [{ action: 'unfold' }] };
548
576
  }
549
577
 
550
- if (!outerDiameter || !innerDiameter) {
551
- return null;
578
+ // --- VIEWS ---
579
+ if (/^(front|top|right|left|back|bottom|isometric|iso)\s*(view)?\s*$/i.test(lower)) {
580
+ const view = lower.replace(/\s*view\s*$/, '').trim();
581
+ return { reply: `${view.charAt(0).toUpperCase() + view.slice(1)} view.`, commands: [{ action: 'setView', view }] };
582
+ }
583
+ if (/\b(zoom\s*in)\b/i.test(lower)) {
584
+ return { reply: 'Zoomed in.', commands: [{ action: 'zoomIn' }] };
585
+ }
586
+ if (/\b(zoom\s*out)\b/i.test(lower)) {
587
+ return { reply: 'Zoomed out.', commands: [{ action: 'zoomOut' }] };
588
+ }
589
+ if (/\b(dark\s*mode|light\s*mode|toggle\s*theme)\b/i.test(lower)) {
590
+ return { reply: 'Theme toggled.', commands: [{ action: 'toggleTheme' }] };
552
591
  }
553
592
 
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
- }
593
+ // --- PANELS ---
594
+ if (/\b(open|show)\s*(help|keyboard|shortcuts)\b/i.test(lower)) {
595
+ return { reply: 'Opening help panel.', commands: [{ action: 'openPanel', panel: 'help' }] };
596
+ }
597
+ if (/\b(open|show)\s*(properties|params|parameters)\b/i.test(lower)) {
598
+ return { reply: 'Showing properties panel.', commands: [{ action: 'openPanel', panel: 'properties' }] };
599
+ }
600
+ if (/\b(open|show)\s*(guide|rebuild)\b/i.test(lower)) {
601
+ return { reply: 'Opening guide panel.', commands: [{ action: 'openPanel', panel: 'guide' }] };
602
+ }
603
+ if (/\b(open|show)\s*(token|billing)\b/i.test(lower)) {
604
+ return { reply: 'Opening tokens panel.', commands: [{ action: 'openPanel', panel: 'tokens' }] };
605
+ }
606
+ if (/\b(open|show)\s*(marketplace|store)\b/i.test(lower)) {
607
+ return { reply: 'Opening marketplace.', commands: [{ action: 'openPanel', panel: 'marketplace' }] };
608
+ }
609
+ if (/\b(open|show)\s*(gd&?t|gdt|tolerance)\b/i.test(lower)) {
610
+ return { reply: 'Opening GD&T training.', commands: [{ action: 'openPanel', panel: 'gdt' }] };
611
+ }
612
+ if (/\b(open|show)\s*(misumi|catalog)\b/i.test(lower)) {
613
+ return { reply: 'Opening MISUMI catalog.', commands: [{ action: 'openPanel', panel: 'misumi' }] };
614
+ }
615
+ if (/\b(open|show)\s*(console|log)\b/i.test(lower)) {
616
+ return { reply: 'Opening console.', commands: [{ action: 'openPanel', panel: 'console' }] };
617
+ }
561
618
 
562
- /**
563
- * Parse spacer command
564
- */
565
- function parseSpacerCommand(text, numbers) {
566
- let outerDiameter, innerDiameter, height;
619
+ // --- IMPORT ---
620
+ if (/\b(import|open|load)\s*(step|stp|inventor|ipt|iam|stl|obj)\b/i.test(lower)) {
621
+ const format = /step|stp/i.test(lower) ? 'step' : /inventor|ipt|iam/i.test(lower) ? 'inventor' : /stl/i.test(lower) ? 'stl' : 'obj';
622
+ return { reply: `Opening ${format} import dialog...`, commands: [{ action: 'import', format }] };
623
+ }
567
624
 
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];
625
+ // --- AI TOOLS ---
626
+ if (/\b(dfm|manufacturab|design\s*for\s*manuf)/i.test(lower)) {
627
+ return { reply: 'Running DFM analysis...', commands: [{ action: 'openPanel', panel: 'dfm' }] };
628
+ }
629
+ if (/\b(copilot|ai\s*assist|suggest)\b/i.test(lower)) {
630
+ return { reply: 'Opening AI Copilot.', commands: [{ action: 'openPanel', panel: 'copilot' }] };
631
+ }
632
+ if (/\b(reverse\s*engineer)/i.test(lower)) {
633
+ return { reply: 'Opening reverse engineering tool.', commands: [{ action: 'openPanel', panel: 'reverseEngineer' }] };
634
+ }
635
+ if (/\b(material\s*library|materials?\s*selector)\b/i.test(lower)) {
636
+ return { reply: 'Opening material library.', commands: [{ action: 'openPanel', panel: 'materials' }] };
637
+ }
638
+ if (/\b(generative\s*design)\b/i.test(lower)) {
639
+ return { reply: 'Opening generative design tool.', commands: [{ action: 'openPanel', panel: 'generative' }] };
573
640
  }
574
641
 
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];
642
+ // --- CAM ---
643
+ if (/\b(cam|toolpath|cnc|machining)\b/i.test(lower)) {
644
+ return { reply: 'Opening CAM pipeline.', commands: [{ action: 'openPanel', panel: 'cam' }] };
645
+ }
646
+ if (/\b(g-?code|gcode)\b/i.test(lower)) {
647
+ return { reply: 'Opening G-code viewer.', commands: [{ action: 'openPanel', panel: 'gcode' }] };
580
648
  }
581
649
 
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];
650
+ // --- COLLABORATION ---
651
+ if (/\b(collab|share|collaboration)\b/i.test(lower)) {
652
+ return { reply: 'Opening collaboration panel.', commands: [{ action: 'openPanel', panel: 'collab' }] };
653
+ }
654
+ if (/\b(vr|virtual\s*reality|immersive)\b/i.test(lower)) {
655
+ return { reply: 'Opening VR mode.', commands: [{ action: 'openPanel', panel: 'vr' }] };
587
656
  }
588
657
 
589
- if (!outerDiameter || !innerDiameter) {
590
- return null;
658
+ // --- ASSEMBLY ---
659
+ if (/^(assembly|assembly mode)\s*$/i.test(lower)) {
660
+ return { reply: 'Switching to assembly mode.', commands: [{ action: 'assemblyMode' }] };
661
+ }
662
+ if (/\b(explode|exploded\s*view)\b/i.test(lower)) {
663
+ return { reply: 'Toggling exploded view.', commands: [{ action: 'explode' }] };
591
664
  }
592
665
 
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
- }
666
+ // --- MEASURE ---
667
+ if (/\b(measure|distance|dimension|ruler)\b/i.test(lower)) {
668
+ return { reply: 'Measure tool active. Click two points to measure distance.', commands: [{ action: 'measure' }] };
669
+ }
600
670
 
601
- /**
602
- * Parse gear command
603
- */
604
- function parseGearCommand(text, numbers) {
605
- let diameter, toothCount, thickness;
671
+ // --- SECTION VIEW ---
672
+ if (/\b(section|cross\s*section|section\s*cut|slice)\b/i.test(lower)) {
673
+ return { reply: 'Section cut tool active.', commands: [{ action: 'section' }] };
674
+ }
606
675
 
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];
676
+ // --- SCREENSHOT ---
677
+ if (/\b(screenshot|capture|snapshot|save\s*image)\b/i.test(lower)) {
678
+ return { reply: 'Capturing screenshot...', commands: [{ action: 'screenshot' }] };
612
679
  }
613
680
 
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];
681
+ // --- DXF EXPORT ---
682
+ if (/\b(dxf|engineering\s*drawing|2d\s*drawing)\b/i.test(lower)) {
683
+ return { reply: 'Exporting DXF engineering drawing...', commands: [{ action: 'export', format: 'dxf' }] };
619
684
  }
620
685
 
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];
686
+ // --- SAVE / LOAD ---
687
+ if (/^save\s*(project|file|model)?\s*$/i.test(lower)) {
688
+ return { reply: 'Saving project...', commands: [{ action: 'save' }] };
689
+ }
690
+ if (/^(load|open)\s*(project|file|model)?\s*$/i.test(lower)) {
691
+ return { reply: 'Opening file picker...', commands: [{ action: 'load' }] };
626
692
  }
627
693
 
628
- if (!diameter || !toothCount) {
629
- return null;
694
+ // --- CONSTRAINT COMMANDS ---
695
+ if (/\b(constrain|constraint|lock|fix)\s*(horizontal|vertical|equal|parallel|perpendicular|tangent|coincident|concentric|symmetric)?\b/i.test(lower)) {
696
+ const type = (lower.match(/(horizontal|vertical|equal|parallel|perpendicular|tangent|coincident|concentric|symmetric)/)?.[1]) || 'fixed';
697
+ return { reply: `Adding ${type} constraint.`, commands: [{ action: 'addConstraint', type }] };
630
698
  }
631
699
 
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
- };
700
+ return null; // not a scene action
638
701
  }
639
702
 
640
703
  // ============================================================================
641
- // OPERATION PARSERS
704
+ // BOOLEAN OPERATION HANDLER
642
705
  // ============================================================================
643
706
 
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
- });
707
+ function detectBooleanOp(text) {
708
+ // Exact keyword match
709
+ if (/\b(subtract|substract|cut\s+from|difference|minus)\b/i.test(text)) return 'booleanSubtract';
710
+ if (/\b(intersect|intersection|overlap)\b/i.test(text)) return 'booleanIntersect';
711
+ if (/\b(union|combine|merge|join|fuse)\b/i.test(text)) return 'booleanUnion';
712
+
713
+ // Fuzzy match for typos like "interset", "subtrat", "intersec"
714
+ const boolKeywords = {
715
+ booleanIntersect: ['intersect', 'intersection', 'overlap'],
716
+ booleanSubtract: ['subtract', 'substract', 'difference'],
717
+ booleanUnion: ['union', 'combine', 'merge', 'join', 'fuse'],
718
+ };
719
+ const words = text.replace(/[^a-z\s]/g, '').split(/\s+/).filter(w => w.length >= 4);
720
+ for (const w of words) {
721
+ for (const [op, keywords] of Object.entries(boolKeywords)) {
722
+ for (const kw of keywords) {
723
+ if (levenshtein(w, kw) <= 2) return op;
724
+ }
725
+ }
698
726
  }
727
+ return null;
699
728
  }
700
729
 
701
- /**
702
- * Parse hole patterns (through holes, corner holes, bolt circles)
703
- */
704
- function parseHolePattern(text) {
705
- const pattern = {};
730
+ function handleBoolean(booleanOp, lower, features, selectedIdx) {
731
+ const opName = booleanOp.replace('boolean', '').toLowerCase();
706
732
 
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;
733
+ // 1. Check for inline shape with dimensions: "intersect with a box 20mm"
734
+ const inlineShape = tryParseInlineShape(lower);
735
+ if (inlineShape && features.length >= 1) {
736
+ const targetIdx = selectedIdx >= 0 ? selectedIdx : features.length - 1;
711
737
  return {
712
- type: 'through',
713
- diameter,
714
- centerX: 0,
715
- centerY: 0,
738
+ reply: `Creating ${generateDescription(inlineShape)}, then ${opName}ing with "${features[targetIdx]?.name || 'Part'}".`,
739
+ commands: [inlineShape, { action: booleanOp, toolIndex: -1, targetIndex: targetIdx }]
716
740
  };
717
741
  }
718
742
 
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
-
743
+ // 2. Check for "intersect with box" / "intersect box and cylinder" — referring to existing parts
744
+ // Try to resolve named parts from the text
745
+ const { tool, target } = resolveBooleanPair(lower, features);
746
+ if (tool >= 0 && target >= 0 && tool !== target) {
726
747
  return {
727
- type: 'corners',
728
- count,
729
- diameter,
748
+ reply: `${opName.charAt(0).toUpperCase() + opName.slice(1)}ed "${features[tool]?.name || 'Part A'}" with "${features[target]?.name || 'Part B'}".`,
749
+ commands: [{ action: booleanOp, toolIndex: tool, targetIndex: target }]
730
750
  };
731
751
  }
732
752
 
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;
753
+ // 3. "intersect with [partname]" — one part named, use selected/last as the other
754
+ const withMatch = lower.match(/(?:with|and)\s+(?:the\s+)?(\w+)/);
755
+ if (withMatch && features.length >= 2) {
756
+ const namedIdx = findFeatureByKeyword(withMatch[1], features);
757
+ if (namedIdx >= 0) {
758
+ const otherIdx = selectedIdx >= 0 && selectedIdx !== namedIdx ? selectedIdx :
759
+ (features.length - 1 !== namedIdx ? features.length - 1 : features.length - 2);
760
+ if (otherIdx >= 0 && otherIdx !== namedIdx) {
761
+ return {
762
+ reply: `${opName.charAt(0).toUpperCase() + opName.slice(1)}ed "${features[otherIdx]?.name || 'Part A'}" with "${features[namedIdx]?.name || 'Part B'}".`,
763
+ commands: [{ action: booleanOp, toolIndex: namedIdx, targetIndex: otherIdx }]
764
+ };
765
+ }
766
+ }
767
+ }
741
768
 
769
+ // 4. Just "intersect" with 2+ parts — use last two
770
+ if (features.length >= 2) {
771
+ const t = features.length - 1;
772
+ const s = features.length - 2;
742
773
  return {
743
- type: 'boltcircle',
744
- count,
745
- diameter,
746
- circleDiameter: circleDia,
774
+ reply: `${opName.charAt(0).toUpperCase() + opName.slice(1)}ed "${features[s]?.name || 'Part A'}" with "${features[t]?.name || 'Part B'}".`,
775
+ commands: [{ action: booleanOp, toolIndex: t, targetIndex: s }]
747
776
  };
748
777
  }
749
778
 
750
- return null;
779
+ return { reply: `Need at least 2 parts for ${opName}. Create a second part first, or say "${opName} with a box 20mm".`, commands: [] };
751
780
  }
752
781
 
753
782
  // ============================================================================
754
- // UTILITY PARSERS
783
+ // TARGET RESOLUTION (which part does the user mean?)
755
784
  // ============================================================================
756
785
 
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;
786
+ function resolveTarget(text, features, selectedIdx) {
787
+ if (!features || features.length === 0) return -1;
765
788
 
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
- }
789
+ // "the selected" / "current" / "this"
790
+ if (/\b(selected|current|this)\b/.test(text) && selectedIdx >= 0) return selectedIdx;
772
791
 
773
- return numbers;
774
- }
792
+ // "the last" / "last one" / "it"
793
+ if (/\b(last|it|that)\b/.test(text)) return features.length - 1;
775
794
 
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 = [];
795
+ // "the first" / "first one"
796
+ if (/\b(first)\b/.test(text)) return 0;
782
797
 
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]));
798
+ // "the second" / "third" etc
799
+ const ordinals = { second: 1, third: 2, fourth: 3, fifth: 4, sixth: 5 };
800
+ for (const [word, idx] of Object.entries(ordinals)) {
801
+ if (text.includes(word) && idx < features.length) return idx;
802
+ }
803
+
804
+ // "the box" / "the cylinder" / part by type name
805
+ for (let i = features.length - 1; i >= 0; i--) {
806
+ const name = (features[i].name || '').toLowerCase();
807
+ const type = (features[i].type || '').toLowerCase();
808
+ // Check each part type synonym
809
+ for (const [pType, synonyms] of Object.entries(PART_TYPE_SYNONYMS)) {
810
+ for (const syn of synonyms) {
811
+ if (text.includes(syn) && (name.includes(syn) || name.includes(pType) || type === pType)) {
812
+ return i;
813
+ }
814
+ }
790
815
  }
791
- return dimensions;
792
816
  }
793
817
 
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]));
818
+ // "the square" box
819
+ if (/\bsquare\b/.test(text)) {
820
+ for (let i = features.length - 1; i >= 0; i--) {
821
+ const name = (features[i].name || '').toLowerCase();
822
+ const type = (features[i].type || '').toLowerCase();
823
+ if (type === 'box' || name.includes('box') || name.includes('cube') || name.includes('square')) return i;
801
824
  }
802
- return dimensions;
803
825
  }
804
826
 
805
- return dimensions;
827
+ // By name substring
828
+ const nameMatch = text.match(/(?:the|named?)\s+["']?(\w+)["']?/);
829
+ if (nameMatch) {
830
+ const search = nameMatch[1].toLowerCase();
831
+ for (let i = features.length - 1; i >= 0; i--) {
832
+ if ((features[i].name || '').toLowerCase().includes(search)) return i;
833
+ }
834
+ }
835
+
836
+ return -1;
806
837
  }
807
838
 
808
- /**
809
- * Detect primary part type from text
810
- * @returns {string} Part type identifier
811
- */
812
- export function detectPartType(text) {
813
- text = text.toLowerCase();
839
+ function resolveBooleanPair(text, features) {
840
+ // Try "subtract A from B" pattern
841
+ const fromMatch = text.match(/(?:subtract|cut|intersect)\s+(?:the\s+)?(\w+)\s+from\s+(?:the\s+)?(\w+)/i);
842
+ if (fromMatch) {
843
+ const toolIdx = findFeatureByKeyword(fromMatch[1], features);
844
+ const targetIdx = findFeatureByKeyword(fromMatch[2], features);
845
+ if (toolIdx >= 0 && targetIdx >= 0) return { tool: toolIdx, target: targetIdx };
846
+ }
814
847
 
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
- }
848
+ // Try "intersect A and B" / "intersect A with B"
849
+ const andMatch = text.match(/(?:intersect|union|combine|merge)\s+(?:the\s+)?(\w+)\s+(?:and|with)\s+(?:the\s+)?(\w+)/i);
850
+ if (andMatch) {
851
+ const a = findFeatureByKeyword(andMatch[1], features);
852
+ const b = findFeatureByKeyword(andMatch[2], features);
853
+ if (a >= 0 && b >= 0) return { tool: a, target: b };
821
854
  }
822
855
 
823
- return 'box'; // default
856
+ // Default: last two parts
857
+ return { tool: features.length - 1, target: features.length - 2 };
858
+ }
859
+
860
+ function findFeatureByKeyword(keyword, features) {
861
+ const kw = keyword.toLowerCase();
862
+ // "square" → box
863
+ const typeMap = { square: 'box', cube: 'box', block: 'box', rod: 'cylinder', shaft: 'cylinder', ball: 'sphere', ring: 'torus', donut: 'torus' };
864
+ const mapped = typeMap[kw] || kw;
865
+
866
+ for (let i = features.length - 1; i >= 0; i--) {
867
+ const name = (features[i].name || '').toLowerCase();
868
+ const type = (features[i].type || '').toLowerCase();
869
+ if (name.includes(mapped) || type.includes(mapped) || name.includes(kw)) return i;
870
+ }
871
+ return -1;
824
872
  }
825
873
 
826
874
  // ============================================================================
827
- // DESCRIPTION GENERATION
875
+ // INLINE SHAPE PARSER (for "intersect with a box 20mm")
828
876
  // ============================================================================
829
877
 
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
- }
878
+ function tryParseInlineShape(text) {
879
+ // Strip the boolean keyword and prepositions to find shape description
880
+ const cleaned = text
881
+ .replace(/\b(intersect|subtract|cut|union|combine|merge|join|fuse|difference|minus|overlap)\b/gi, '')
882
+ .replace(/\b(it|this|that|the|from|with|and|a|an|of)\b/gi, '')
883
+ .trim();
884
+
885
+ if (!cleaned) return null;
886
+
887
+ const partType = detectPartType(cleaned);
888
+ if (!partType) return null;
889
+
890
+ // Only create inline shape if dimensions are specified
891
+ // "intersect cube and cylinder" should NOT create new shapes — it refers to existing parts
892
+ const numbers = parseNumbers(cleaned);
893
+ const dims = parseDimensions(cleaned);
894
+ if (numbers.length === 0 && dims.length === 0) return null; // no dimensions = refers to existing part
895
+
896
+ return parseByType(partType, cleaned, numbers, dims);
866
897
  }
867
898
 
868
899
  // ============================================================================
869
- // API KEY MANAGEMENT
900
+ // DIRECTION PARSING
870
901
  // ============================================================================
871
902
 
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;
903
+ function parseDirection(text) {
904
+ if (/\b(up|above|higher)\b/.test(text)) return { axis: 'y', sign: 1, label: 'up' };
905
+ if (/\b(down|below|lower)\b/.test(text)) return { axis: 'y', sign: -1, label: 'down' };
906
+ if (/\b(left)\b/.test(text)) return { axis: 'x', sign: -1, label: 'left' };
907
+ if (/\b(right)\b/.test(text)) return { axis: 'x', sign: 1, label: 'right' };
908
+ if (/\b(forward|front)\b/.test(text)) return { axis: 'z', sign: 1, label: 'forward' };
909
+ if (/\b(back|backward|behind)\b/.test(text)) return { axis: 'z', sign: -1, label: 'back' };
910
+ if (/\b(away|outside|out|apart)\b/.test(text)) return { axis: 'x', sign: 1, label: 'away' };
911
+ return { axis: 'y', sign: 1, label: 'up' }; // default
912
+ }
878
913
 
879
- localStorage.setItem('cyclecad_api_keys', JSON.stringify(chatState.apiKeys));
914
+ function parseFirstNumber(text) {
915
+ const m = text.match(/(\d+(?:\.\d+)?)/);
916
+ return m ? parseFloat(m[1]) : 0;
880
917
  }
881
918
 
882
- /**
883
- * Get current API keys
884
- */
885
- export function getAPIKeys() {
886
- return { ...chatState.apiKeys };
919
+ // ============================================================================
920
+ // CONVERSATIONAL HANDLER (no LLM needed)
921
+ // ============================================================================
922
+
923
+ function handleConversational(lower, original) {
924
+ // Greetings
925
+ if (/^(hi|hello|hey|sup|yo|good morning|good evening|what's up)[\s!.?]*$/i.test(lower)) {
926
+ return 'Hey! Ready to design something. What would you like to create?';
927
+ }
928
+
929
+ // Help / what can you do
930
+ if (/what can you (do|make|create|build)|help me|help$|what.*shapes|what.*types|capabilities/i.test(lower)) {
931
+ return `I can help you design in 3D!\n\n**Create shapes:** box, cylinder, sphere, cone, torus, bracket, plate, flange, washer, spacer, gear\n\n**Modify parts:** "remove it", "delete the box", "undo", "redo"\n**Transform:** "move it up 20", "rotate 45°", "scale 2x", "make it bigger"\n**Booleans:** "subtract box from cylinder", "intersect", "union"\n**Scene:** "hide it", "show all", "select the cylinder", "clear scene"\n**View:** "wireframe", "grid", "fit all", "export stl"\n\n**Examples:**\n• "cylinder 30mm diameter 80mm tall"\n• "bracket 80x40x5"\n• "gear 60mm diameter 24 teeth"\n• "move it left 50"\n• "delete the box"\n\nJust describe what you want!`;
932
+ }
933
+
934
+ // Thanks
935
+ if (/^(thanks|thank you|thx|cheers|nice|cool|great|awesome|perfect)[\s!.]*$/i.test(lower)) {
936
+ return 'You\'re welcome! What\'s next?';
937
+ }
938
+
939
+ // Clear / reset
940
+ if (/^(clear|reset|start over|new|clean)[\s!.]*$/i.test(lower)) {
941
+ return 'Ready for a fresh start. What would you like to create?';
942
+ }
943
+
944
+ // How to use
945
+ if (/how (do i|to|does)|tutorial|getting started|explain/i.test(lower)) {
946
+ return `**Quick start:**\n1. Type a shape: "cylinder 40mm diameter 100 tall"\n2. I\'ll create it in the 3D viewport\n3. Modify: "fillet 5mm", "move it up 20", "make it bigger"\n4. Combine: "subtract box from cylinder"\n5. Clean up: "remove it", "undo"\n\nPress **?** for the full help panel.`;
947
+ }
948
+
949
+ // What did you make / last part / what's in scene
950
+ if (/what('s| is)?\s+(in|on)\s+(the\s+)?(scene|viewport|view)|what did you (make|create|build)|list\s*parts|scene\s*info|inventory/i.test(lower)) {
951
+ const scene = getSceneContext();
952
+ if (scene.length === 0) return 'The scene is empty. Try creating something: "box 50mm"';
953
+ return `**Parts in scene (${scene.length}):**\n${scene.map((p, i) => `${i + 1}. ${p}`).join('\n')}`;
954
+ }
955
+
956
+ // API key setting
957
+ if (/set.*key|api.*key|gemini.*key|groq.*key/i.test(lower)) {
958
+ const geminiMatch = original.match(/gemini[:\s]+([A-Za-z0-9_-]{20,})/i);
959
+ const groqMatch = original.match(/groq[:\s]+([A-Za-z0-9_-]{20,})/i);
960
+ if (geminiMatch || groqMatch) {
961
+ setAPIKeys(geminiMatch?.[1] || chatState.apiKeys.gemini, groqMatch?.[1] || chatState.apiKeys.groq);
962
+ return 'API key saved! I\'ll use AI for smarter responses now.';
963
+ }
964
+ return `To enable AI mode, set your API key:\n\n**Gemini (free):** "set gemini key AIza..."\n**Groq (free):** "set groq key gsk_..."\n\nGet a free Gemini key at: aistudio.google.com/apikey`;
965
+ }
966
+
967
+ return null; // not conversational
887
968
  }
888
969
 
889
970
  // ============================================================================
890
- // LLM INTEGRATION
971
+ // SCENE CONTEXT
891
972
  // ============================================================================
892
973
 
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.
974
+ function getSceneContext() {
975
+ const parts = [];
976
+ try {
977
+ const features = window.APP?.features || [];
978
+ features.forEach(f => {
979
+ const p = f.params || {};
980
+ let desc = f.name || f.type || 'Part';
981
+ if (p.width && p.height) desc += ` (${p.width}×${p.height}${p.depth ? '×' + p.depth : ''}mm)`;
982
+ else if (p.radius) desc += ` (r${p.radius}mm${p.height ? ' h' + p.height + 'mm' : ''})`;
983
+ parts.push(desc);
984
+ });
985
+ } catch (e) {}
986
+ return parts;
987
+ }
988
+
989
+ // ============================================================================
990
+ // SMART LLM INTEGRATION
991
+ // ============================================================================
899
992
 
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.)
993
+ const CAD_SYSTEM_PROMPT = `You are a CAD assistant for cycleCAD, a browser-based 3D modeler. You help users create and modify 3D parts.
903
994
 
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}]
995
+ RESPONSE FORMAT: Always respond with valid JSON:
996
+ {
997
+ "reply": "Your conversational response to the user",
998
+ "commands": [array of command objects, or empty array if just chatting]
999
+ }
908
1000
 
909
- Return ONLY valid JSON, no other text.`;
1001
+ AVAILABLE COMMANDS:
1002
+
1003
+ Create shapes:
1004
+ - {"type":"box","width":N,"height":N,"depth":N}
1005
+ - {"type":"cylinder","radius":N,"height":N}
1006
+ - {"type":"sphere","radius":N}
1007
+ - {"type":"cone","radius":N,"height":N}
1008
+ - {"type":"torus","radius":N,"tube":N}
1009
+ - {"type":"bracket","width":N,"height":N,"thickness":N}
1010
+ - {"type":"plate","width":N,"height":N,"thickness":N}
1011
+ - {"type":"flange","outerDiameter":N,"innerDiameter":N,"height":N}
1012
+ - {"type":"washer","outerDiameter":N,"innerDiameter":N,"thickness":N}
1013
+ - {"type":"spacer","outerDiameter":N,"innerDiameter":N,"height":N}
1014
+ - {"type":"gear","diameter":N,"teeth":N,"thickness":N}
1015
+
1016
+ Operations on existing parts:
1017
+ - {"type":"fillet","radius":N}
1018
+ - {"type":"chamfer","distance":N}
1019
+
1020
+ Scene actions:
1021
+ - {"action":"delete","index":N} — delete part at index (0-based), or -1 for last
1022
+ - {"action":"undo"}
1023
+ - {"action":"redo"}
1024
+ - {"action":"clearScene"}
1025
+ - {"action":"hide","index":N}
1026
+ - {"action":"showAll"}
1027
+ - {"action":"select","index":N}
1028
+ - {"action":"move","index":N,"axis":"x|y|z","distance":N} — negative for opposite direction
1029
+ - {"action":"rotate","index":N,"axis":"x|y|z","angle":N}
1030
+ - {"action":"scale","index":N,"factor":N}
1031
+ - {"action":"duplicate","index":N}
1032
+ - {"action":"color","index":N,"color":"red|blue|green|..."}
1033
+ - {"action":"rename","index":N,"name":"New Name"}
1034
+ - {"action":"booleanSubtract","toolIndex":N,"targetIndex":N}
1035
+ - {"action":"booleanIntersect","toolIndex":N,"targetIndex":N}
1036
+ - {"action":"booleanUnion","toolIndex":N,"targetIndex":N}
1037
+ - {"action":"fitAll"}
1038
+ - {"action":"wireframe"}
1039
+ - {"action":"grid"}
1040
+ - {"action":"export","format":"stl|obj|gltf"}
1041
+
1042
+ RULES:
1043
+ 1. All dimensions in mm. Convert from other units if needed.
1044
+ 2. "diameter X" → radius = X/2 for cylinders/spheres.
1045
+ 3. For multi-step: "box with hole and fillet" → create multiple commands.
1046
+ 4. For questions/chat, set commands to [] and put answer in reply.
1047
+ 5. Be concise (1-2 sentences for creation, more for explanations).
1048
+ 6. Use -1 for index to mean "last/most recent part".
1049
+ 7. "it" / "that" / "the last one" = the most recently created part.
1050
+ 8. "the box" / "the cylinder" = find by type name in scene.
1051
+ 9. "remove it" / "delete it" → {"action":"delete","index":-1}
1052
+ 10. "move it up 20" → {"action":"move","index":-1,"axis":"y","distance":20}
1053
+ 11. "make it bigger" → {"action":"scale","index":-1,"factor":1.5}
1054
+
1055
+ SCENE CONTEXT will be appended to user messages showing what parts exist.`;
1056
+
1057
+ async function querySmartLLM(userText) {
1058
+ const recentHistory = chatState.conversationHistory.slice(-10);
1059
+ const sceneCtx = getSceneContext();
1060
+
1061
+ let contextNote = '';
1062
+ if (sceneCtx.length > 0) {
1063
+ contextNote = `\n[Scene has ${sceneCtx.length} parts: ${sceneCtx.map((p, i) => `[${i}] ${p}`).join(', ')}]`;
1064
+ } else {
1065
+ contextNote = '\n[Scene is empty]';
1066
+ }
910
1067
 
911
1068
  try {
912
- // Try Gemini Flash first
913
1069
  if (chatState.apiKeys.gemini) {
914
- return await queryGemini(prompt, systemPrompt);
1070
+ return await queryGeminiSmart(userText, recentHistory, contextNote);
915
1071
  }
916
-
917
- // Try Groq Llama
918
1072
  if (chatState.apiKeys.groq) {
919
- return await queryGroq(prompt, systemPrompt);
1073
+ return await queryGroqSmart(userText, recentHistory, contextNote);
920
1074
  }
921
- } catch (error) {
922
- console.warn('LLM query failed:', error);
923
- throw error;
1075
+ } catch (e) {
1076
+ console.warn('Smart LLM query failed:', e);
1077
+ throw e;
924
1078
  }
925
1079
  }
926
1080
 
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, {
1081
+ async function queryGeminiSmart(userText, history, contextNote) {
1082
+ const contents = [];
1083
+ for (const msg of history.slice(0, -1)) {
1084
+ contents.push({
1085
+ role: msg.role === 'model' ? 'model' : 'user',
1086
+ parts: [{ text: msg.text }]
1087
+ });
1088
+ }
1089
+ contents.push({ role: 'user', parts: [{ text: userText + contextNote }] });
1090
+
1091
+ const model = 'gemini-2.0-flash';
1092
+ const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${chatState.apiKeys.gemini}`, {
932
1093
  method: 'POST',
933
1094
  headers: { 'Content-Type': 'application/json' },
934
1095
  body: JSON.stringify({
935
- system_instruction: { parts: [{ text: systemPrompt }] },
936
- contents: [{ parts: [{ text: prompt }] }],
1096
+ system_instruction: { parts: [{ text: CAD_SYSTEM_PROMPT }] },
1097
+ contents,
1098
+ generationConfig: {
1099
+ temperature: 0.3,
1100
+ responseMimeType: 'application/json',
1101
+ }
937
1102
  }),
938
1103
  });
939
1104
 
940
1105
  if (!response.ok) {
941
- throw new Error(`Gemini API error: ${response.statusText}`);
1106
+ const err = await response.text().catch(() => response.statusText);
1107
+ throw new Error(`Gemini error: ${err}`);
942
1108
  }
943
1109
 
944
1110
  const data = await response.json();
945
1111
  const text = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
946
- return JSON.parse(text);
1112
+ return parseLLMResponse(text);
947
1113
  }
948
1114
 
949
- /**
950
- * Query Groq Llama 3.1 API
951
- */
952
- async function queryGroq(prompt, systemPrompt) {
1115
+ async function queryGroqSmart(userText, history, contextNote) {
1116
+ const messages = [{ role: 'system', content: CAD_SYSTEM_PROMPT }];
1117
+ for (const msg of history.slice(0, -1)) {
1118
+ messages.push({
1119
+ role: msg.role === 'model' ? 'assistant' : 'user',
1120
+ content: msg.text
1121
+ });
1122
+ }
1123
+ messages.push({ role: 'user', content: userText + contextNote });
1124
+
953
1125
  const response = await fetch('https://api.groq.com/openai/v1/chat/completions', {
954
1126
  method: 'POST',
955
1127
  headers: {
@@ -958,27 +1130,439 @@ async function queryGroq(prompt, systemPrompt) {
958
1130
  },
959
1131
  body: JSON.stringify({
960
1132
  model: 'llama-3.1-8b-instant',
961
- messages: [
962
- { role: 'system', content: systemPrompt },
963
- { role: 'user', content: prompt },
964
- ],
965
- temperature: 0,
1133
+ messages,
1134
+ temperature: 0.3,
1135
+ response_format: { type: 'json_object' },
966
1136
  }),
967
1137
  });
968
1138
 
969
- if (!response.ok) {
970
- throw new Error(`Groq API error: ${response.statusText}`);
971
- }
1139
+ if (!response.ok) throw new Error(`Groq error: ${response.statusText}`);
972
1140
 
973
1141
  const data = await response.json();
974
1142
  const text = data.choices?.[0]?.message?.content || '';
975
- return JSON.parse(text);
1143
+ return parseLLMResponse(text);
1144
+ }
1145
+
1146
+ function parseLLMResponse(text) {
1147
+ try {
1148
+ let clean = text.trim();
1149
+ if (clean.startsWith('```json')) clean = clean.slice(7);
1150
+ if (clean.startsWith('```')) clean = clean.slice(3);
1151
+ if (clean.endsWith('```')) clean = clean.slice(0, -3);
1152
+ clean = clean.trim();
1153
+
1154
+ const parsed = JSON.parse(clean);
1155
+
1156
+ if (parsed.reply && Array.isArray(parsed.commands)) {
1157
+ return { reply: parsed.reply, commands: parsed.commands };
1158
+ }
1159
+
1160
+ if (Array.isArray(parsed)) {
1161
+ const desc = parsed.map(c => generateDescription(c)).join(', ');
1162
+ return { reply: `Creating: ${desc}`, commands: parsed };
1163
+ }
1164
+ } catch (e) {
1165
+ console.warn('Failed to parse LLM response:', text, e);
1166
+ }
1167
+ return null;
976
1168
  }
977
1169
 
978
1170
  // ============================================================================
979
- // EXPORTS
1171
+ // SMART LOCAL PARSING (creation commands fallback)
980
1172
  // ============================================================================
981
1173
 
1174
+ function localParseCADPrompt(text) {
1175
+ const commands = [];
1176
+
1177
+ // Strip "create/make/draw/build/generate" prefix for cleaner parsing
1178
+ const cleaned = text.replace(/^(create|make|draw|build|generate|add|design|model|give me|i want|i need)\s+(a\s+|an\s+|me\s+a\s+|me\s+an\s+)?/i, '');
1179
+
1180
+ // FIRST: try parsing the FULL sentence (handles "cylinder with 30mm diameter and 45mm height")
1181
+ const fullType = detectPartType(cleaned);
1182
+ if (fullType) {
1183
+ const allNumbers = parseNumbers(cleaned);
1184
+ const allDims = parseDimensions(cleaned);
1185
+ const cmd = parseByType(fullType, cleaned, allNumbers, allDims);
1186
+ if (cmd) {
1187
+ commands.push(cmd);
1188
+ // Also check for trailing operations like "with 5mm fillet"
1189
+ parseOperations(cleaned, allNumbers, commands);
1190
+ return commands;
1191
+ }
1192
+ }
1193
+
1194
+ // FALLBACK: Split on "and" / "with" / "then" for multi-step
1195
+ const parts = cleaned.split(/\s+(?:and|with|then|plus|\+)\s+/);
1196
+
1197
+ for (const part of parts) {
1198
+ const partType = detectPartType(part);
1199
+ const numbers = parseNumbers(part);
1200
+ const dims = parseDimensions(part);
1201
+
1202
+ const cmd = partType ? parseByType(partType, part, numbers, dims) : null;
1203
+
1204
+ if (cmd) {
1205
+ commands.push(cmd);
1206
+ } else {
1207
+ // Try as operation (fillet, chamfer, extrude, revolve, hole)
1208
+ parseOperations(part, numbers, commands);
1209
+ }
1210
+ }
1211
+
1212
+ return commands;
1213
+ }
1214
+
1215
+ function parseByType(partType, text, numbers, dims) {
1216
+ switch (partType) {
1217
+ case 'cube':
1218
+ case 'box': return parseBoxCommand(text, numbers, dims);
1219
+ case 'cylinder': return parseCylinderCommand(text, numbers);
1220
+ case 'sphere': return parseSphereCommand(text, numbers);
1221
+ case 'cone': return parseConeCommand(text, numbers);
1222
+ case 'torus': return parseTorusCommand(text, numbers);
1223
+ case 'plate': return parsePlateCommand(text, numbers, dims);
1224
+ case 'bracket': return parseBracketCommand(text, numbers, dims);
1225
+ case 'flange': return parseFlangeCommand(text, numbers);
1226
+ case 'washer': return parseWasherCommand(text, numbers);
1227
+ case 'spacer': return parseSpacerCommand(text, numbers);
1228
+ case 'gear': return parseGearCommand(text, numbers);
1229
+ default: return null;
1230
+ }
1231
+ }
1232
+
1233
+ // ============================================================================
1234
+ // PRIMITIVE PARSERS
1235
+ // ============================================================================
1236
+
1237
+ function parseBoxCommand(text, numbers, dims) {
1238
+ let width, height, depth;
1239
+
1240
+ // Check for named dimensions: "width 50", "height 30", "depth 20"
1241
+ const wMatch = text.match(/(?:width|wide|w)\s*(?:of\s*)?(\d+(?:\.\d+)?)/i);
1242
+ const hMatch = text.match(/(?:height|tall|high|h)\s*(?:of\s*)?(\d+(?:\.\d+)?)/i);
1243
+ const dMatch = text.match(/(?:depth|deep|long|length|d)\s*(?:of\s*)?(\d+(?:\.\d+)?)/i);
1244
+
1245
+ if (wMatch || hMatch || dMatch) {
1246
+ width = wMatch ? parseFloat(wMatch[1]) : null;
1247
+ height = hMatch ? parseFloat(hMatch[1]) : null;
1248
+ depth = dMatch ? parseFloat(dMatch[1]) : null;
1249
+ // Fill missing dims with first available or default
1250
+ const known = width || height || depth || 20;
1251
+ width = width || known;
1252
+ height = height || known;
1253
+ depth = depth || Math.min(width, height);
1254
+ } else if (dims.length === 3) [width, height, depth] = dims;
1255
+ else if (dims.length === 2) { width = dims[0]; height = dims[1]; depth = Math.min(dims[0], dims[1]); }
1256
+ else if (dims.length === 1) width = height = depth = dims[0];
1257
+ else if (numbers.length >= 3) { width = numbers[0]; height = numbers[1]; depth = numbers[2]; }
1258
+ else if (numbers.length === 2) { width = numbers[0]; height = numbers[0]; depth = numbers[1]; }
1259
+ else if (numbers.length === 1) width = height = depth = numbers[0];
1260
+ else return null;
1261
+ return { type: 'box', width: r(width), height: r(height), depth: r(depth) };
1262
+ }
1263
+
1264
+ function parseCylinderCommand(text, numbers) {
1265
+ let radius, height;
1266
+ const diaMatch = text.match(/(?:diameter|dia)\s*(?:of\s*)?(\d+(?:\.\d+)?)/i);
1267
+ if (diaMatch) radius = parseFloat(diaMatch[1]) / 2;
1268
+ const rMatch = text.match(/(?:radius|rad)\s*(?:of\s*)?(\d+(?:\.\d+)?)/i);
1269
+ if (rMatch) radius = parseFloat(rMatch[1]);
1270
+ if (!radius) { const rm = text.match(/\br\s*(\d+(?:\.\d+)?)/i); if (rm) radius = parseFloat(rm[1]); }
1271
+
1272
+ const hMatch = text.match(/(?:height|tall|long|h)\s*(?:of\s*)?(\d+(?:\.\d+)?)/i);
1273
+ if (hMatch) height = parseFloat(hMatch[1]);
1274
+
1275
+ if (!radius && !height && numbers.length >= 2) { radius = numbers[0]; height = numbers[1]; }
1276
+ else if (!radius && numbers.length >= 1) { radius = numbers[0]; height = radius * 2; }
1277
+ if (!height && radius) height = radius * 2;
1278
+ if (!radius || !height) return null;
1279
+ return { type: 'cylinder', radius: r(radius), height: r(height) };
1280
+ }
1281
+
1282
+ function parseSphereCommand(text, numbers) {
1283
+ let radius;
1284
+ const diaMatch = text.match(/(?:diameter|dia)\s*(?:of\s*)?(\d+(?:\.\d+)?)/i);
1285
+ if (diaMatch) radius = parseFloat(diaMatch[1]) / 2;
1286
+ else if (numbers.length >= 1) radius = numbers[0];
1287
+ if (!radius) return null;
1288
+ return { type: 'sphere', radius: r(radius) };
1289
+ }
1290
+
1291
+ function parseConeCommand(text, numbers) {
1292
+ let radius, height;
1293
+ if (numbers.length >= 2) { radius = numbers[0]; height = numbers[1]; }
1294
+ else if (numbers.length === 1) { radius = numbers[0]; height = radius * 1.5; }
1295
+ if (!radius) return null;
1296
+ return { type: 'cone', radius: r(radius), height: r(height) };
1297
+ }
1298
+
1299
+ function parseTorusCommand(text, numbers) {
1300
+ let radius, tube;
1301
+ if (numbers.length >= 2) { radius = numbers[0]; tube = numbers[1]; }
1302
+ else if (numbers.length === 1) { radius = numbers[0]; tube = radius * 0.3; }
1303
+ if (!radius) return null;
1304
+ return { type: 'torus', radius: r(radius), tube: r(tube) };
1305
+ }
1306
+
1307
+ function parsePlateCommand(text, numbers, dims) {
1308
+ let width, depth, thickness;
1309
+ if (dims.length >= 3) { width = dims[0]; depth = dims[1]; thickness = dims[2]; }
1310
+ else if (dims.length === 2) { width = dims[0]; depth = dims[1]; thickness = 5; }
1311
+ else if (numbers.length >= 2) { width = numbers[0]; depth = numbers[1]; thickness = numbers[2] || 5; }
1312
+ if (!width || !depth) return null;
1313
+ return { type: 'plate', width: r(width), height: r(depth), thickness: r(thickness) };
1314
+ }
1315
+
1316
+ function parseBracketCommand(text, numbers, dims) {
1317
+ let width, height, thickness;
1318
+ if (dims.length >= 3) { width = dims[0]; height = dims[1]; thickness = dims[2]; }
1319
+ else if (dims.length === 2) { width = dims[0]; height = dims[1]; thickness = 5; }
1320
+ else if (numbers.length >= 2) { width = numbers[0]; height = numbers[1]; thickness = numbers[2] || 5; }
1321
+ if (!width || !height) return null;
1322
+ return { type: 'bracket', width: r(width), height: r(height), thickness: r(thickness) };
1323
+ }
1324
+
1325
+ function parseFlangeCommand(text, numbers) {
1326
+ let od, id, height;
1327
+ const odMatch = text.match(/(?:od|outer\s*(?:diameter)?)\s*(\d+(?:\.\d+)?)/i);
1328
+ if (odMatch) od = parseFloat(odMatch[1]);
1329
+ const idMatch = text.match(/(?:id|inner\s*(?:diameter)?)\s*(\d+(?:\.\d+)?)/i);
1330
+ if (idMatch) id = parseFloat(idMatch[1]);
1331
+ const hMatch = text.match(/(?:h|height|thick)\s*(\d+(?:\.\d+)?)/i);
1332
+ if (hMatch) height = parseFloat(hMatch[1]);
1333
+ if (!od && numbers.length >= 1) od = numbers[0];
1334
+ if (!id && numbers.length >= 2) id = numbers[1];
1335
+ if (!height && numbers.length >= 3) height = numbers[2];
1336
+ if (!od) return null;
1337
+ return { type: 'flange', outerDiameter: r(od), innerDiameter: r(id || od * 0.4), height: r(height || 10) };
1338
+ }
1339
+
1340
+ function parseWasherCommand(text, numbers) {
1341
+ let od, id, thickness;
1342
+ const mMatch = text.match(/m(\d+)/i);
1343
+ if (mMatch) { const s = parseInt(mMatch[1]); od = s * 2.5; id = s + 0.5; thickness = 2; }
1344
+ else if (numbers.length >= 2) { od = numbers[0]; id = numbers[1]; thickness = numbers[2] || 2; }
1345
+ if (!od || !id) return null;
1346
+ return { type: 'washer', outerDiameter: r(od), innerDiameter: r(id), thickness: r(thickness) };
1347
+ }
1348
+
1349
+ function parseSpacerCommand(text, numbers) {
1350
+ let od, id, height;
1351
+ if (numbers.length >= 3) { od = numbers[0]; id = numbers[1]; height = numbers[2]; }
1352
+ else if (numbers.length >= 2) { od = numbers[0]; id = numbers[1]; height = 10; }
1353
+ if (!od || !id) return null;
1354
+ return { type: 'spacer', outerDiameter: r(od), innerDiameter: r(id), height: r(height) };
1355
+ }
1356
+
1357
+ function parseGearCommand(text, numbers) {
1358
+ let diameter, teeth, thickness;
1359
+ const diaMatch = text.match(/(?:diameter|dia)\s*(\d+(?:\.\d+)?)/i);
1360
+ if (diaMatch) diameter = parseFloat(diaMatch[1]);
1361
+ const teethMatch = text.match(/(\d+)\s*(?:teeth|tooth)/i);
1362
+ if (teethMatch) teeth = parseInt(teethMatch[1]);
1363
+ if (!diameter && numbers.length >= 1) diameter = numbers[0];
1364
+ if (!teeth && numbers.length >= 2) teeth = numbers[1];
1365
+ if (!diameter || !teeth) return null;
1366
+ thickness = numbers.length >= 3 ? numbers[2] : 10;
1367
+ return { type: 'gear', diameter: r(diameter), teeth, thickness: r(thickness) };
1368
+ }
1369
+
1370
+ // ============================================================================
1371
+ // OPERATION PARSERS
1372
+ // ============================================================================
1373
+
1374
+ function parseOperations(text, numbers, commands) {
1375
+ if (/fillet|round\s*edge|rounded/i.test(text)) {
1376
+ const m = text.match(/(\d+(?:\.\d+)?)\s*(?:mm)?\s*fillet|fillet\s*(\d+(?:\.\d+)?)/i);
1377
+ commands.push({ type: 'fillet', radius: r(m ? parseFloat(m[1] || m[2]) : (numbers[0] || 3)) });
1378
+ }
1379
+ if (/chamfer|bevel/i.test(text)) {
1380
+ const m = text.match(/(\d+(?:\.\d+)?)\s*(?:mm)?\s*chamfer|chamfer\s*(\d+(?:\.\d+)?)/i);
1381
+ commands.push({ type: 'chamfer', distance: r(m ? parseFloat(m[1] || m[2]) : (numbers[0] || 2)) });
1382
+ }
1383
+ if (/extrude|extend|pull|raise/i.test(text)) {
1384
+ const m = text.match(/(?:extrude|pull|raise)\s*(\d+(?:\.\d+)?)|(\d+(?:\.\d+)?)\s*(?:mm)?\s*extrude/i);
1385
+ commands.push({ type: 'extrude', height: r(m ? parseFloat(m[1] || m[2]) : (numbers[0] || 10)) });
1386
+ }
1387
+ if (/revolve|spin/i.test(text)) {
1388
+ const m = text.match(/(\d+(?:\.\d+)?)\s*deg/i);
1389
+ commands.push({ type: 'revolve', angle: m ? parseFloat(m[1]) : 360 });
1390
+ }
1391
+ // Hole
1392
+ if (/\bhole\b/i.test(text)) {
1393
+ const m = text.match(/(\d+(?:\.\d+)?)\s*(?:mm)?\s*(?:hole|diameter)|hole\s*(?:of\s*)?(\d+(?:\.\d+)?)/i);
1394
+ const r_val = m ? parseFloat(m[1] || m[2]) / 2 : (numbers[0] ? numbers[0] / 2 : 5);
1395
+ commands.push({ type: 'hole', radius: r(r_val) });
1396
+ }
1397
+ }
1398
+
1399
+ // ============================================================================
1400
+ // UTILITIES
1401
+ // ============================================================================
1402
+
1403
+ function r(n) { return Math.round((n || 0) * 10) / 10; }
1404
+
1405
+ export function parseNumbers(text) {
1406
+ const numbers = [];
1407
+ const regex = /(\d+(?:\.\d+)?)\s*(mm|cm|m|in|inch|ft|foot)?/gi;
1408
+ let match;
1409
+ while ((match = regex.exec(text)) !== null) {
1410
+ const val = parseFloat(match[1]);
1411
+ const unit = (match[2] || 'mm').toLowerCase();
1412
+ numbers.push(val * (UNIT_FACTORS[unit] || 1));
1413
+ }
1414
+ return numbers;
1415
+ }
1416
+
1417
+ export function parseDimensions(text) {
1418
+ const xMatch = text.match(/(\d+(?:\.\d+)?)\s*x\s*(\d+(?:\.\d+)?)\s*(?:x\s*(\d+(?:\.\d+)?))?/i);
1419
+ if (xMatch) {
1420
+ const d = [parseFloat(xMatch[1]), parseFloat(xMatch[2])];
1421
+ if (xMatch[3]) d.push(parseFloat(xMatch[3]));
1422
+ return d;
1423
+ }
1424
+ const byMatch = text.match(/(\d+(?:\.\d+)?)\s+by\s+(\d+(?:\.\d+?))\s*(?:by\s+(\d+(?:\.\d+)?))?/i);
1425
+ if (byMatch) {
1426
+ const d = [parseFloat(byMatch[1]), parseFloat(byMatch[2])];
1427
+ if (byMatch[3]) d.push(parseFloat(byMatch[3]));
1428
+ return d;
1429
+ }
1430
+ return [];
1431
+ }
1432
+
1433
+ export function detectPartType(text) {
1434
+ text = text.toLowerCase();
1435
+
1436
+ // 1. Exact synonym match (fast path)
1437
+ for (const [type, synonyms] of Object.entries(PART_TYPE_SYNONYMS)) {
1438
+ for (const syn of synonyms) {
1439
+ if (text.includes(syn)) return type;
1440
+ }
1441
+ }
1442
+
1443
+ // 2. Dimension-only input like "100x50x20" → box
1444
+ if (/\d+\s*x\s*\d+/.test(text)) return 'box';
1445
+
1446
+ // 3. Fuzzy match — handle typos like "cylindr", "spehre", "brcket"
1447
+ const words = text.replace(/[^a-z\s]/g, '').split(/\s+/).filter(w => w.length >= 3);
1448
+ const allTargets = [];
1449
+ for (const [type, synonyms] of Object.entries(PART_TYPE_SYNONYMS)) {
1450
+ for (const syn of synonyms) {
1451
+ if (syn.includes(' ')) continue; // skip multi-word for fuzzy
1452
+ allTargets.push({ word: syn, type });
1453
+ }
1454
+ }
1455
+
1456
+ let bestMatch = null;
1457
+ let bestDist = Infinity;
1458
+ for (const w of words) {
1459
+ for (const target of allTargets) {
1460
+ const dist = levenshtein(w, target.word);
1461
+ const threshold = target.word.length <= 4 ? 1 : 2; // stricter for short words
1462
+ if (dist <= threshold && dist < bestDist) {
1463
+ bestDist = dist;
1464
+ bestMatch = target.type;
1465
+ }
1466
+ }
1467
+ }
1468
+
1469
+ return bestMatch;
1470
+ }
1471
+
1472
+ // Levenshtein distance for fuzzy matching
1473
+ function levenshtein(a, b) {
1474
+ if (a.length === 0) return b.length;
1475
+ if (b.length === 0) return a.length;
1476
+ const matrix = [];
1477
+ for (let i = 0; i <= b.length; i++) matrix[i] = [i];
1478
+ for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
1479
+ for (let i = 1; i <= b.length; i++) {
1480
+ for (let j = 1; j <= a.length; j++) {
1481
+ const cost = a[j - 1] === b[i - 1] ? 0 : 1;
1482
+ matrix[i][j] = Math.min(
1483
+ matrix[i - 1][j] + 1, // deletion
1484
+ matrix[i][j - 1] + 1, // insertion
1485
+ matrix[i - 1][j - 1] + cost // substitution
1486
+ );
1487
+ }
1488
+ }
1489
+ return matrix[b.length][a.length];
1490
+ }
1491
+
1492
+ export function generateDescription(command) {
1493
+ if (command.action) {
1494
+ switch (command.action) {
1495
+ case 'delete': return 'delete part';
1496
+ case 'undo': return 'undo';
1497
+ case 'redo': return 'redo';
1498
+ case 'move': return `move ${command.axis} ${command.distance}mm`;
1499
+ case 'rotate': return `rotate ${command.angle}° ${command.axis}`;
1500
+ case 'scale': return `scale ${command.factor}x`;
1501
+ case 'hide': return 'hide part';
1502
+ case 'showAll': return 'show all';
1503
+ case 'clearScene': return 'clear scene';
1504
+ case 'duplicate': return 'duplicate';
1505
+ case 'color': return `color ${command.color}`;
1506
+ case 'booleanSubtract': return 'boolean subtract';
1507
+ case 'booleanIntersect': return 'boolean intersect';
1508
+ case 'booleanUnion': return 'boolean union';
1509
+ case 'fitAll': return 'fit view';
1510
+ case 'export': return `export ${command.format}`;
1511
+ default: return command.action;
1512
+ }
1513
+ }
1514
+ switch (command.type) {
1515
+ case 'box': return `${command.width}×${command.height}×${command.depth}mm box`;
1516
+ case 'cylinder': return `cylinder r${command.radius} h${command.height}mm`;
1517
+ case 'sphere': return `sphere r${command.radius}mm`;
1518
+ case 'cone': return `cone r${command.radius} h${command.height}mm`;
1519
+ case 'torus': return `torus r${command.radius} tube${command.tube}mm`;
1520
+ case 'bracket': return `${command.width}×${command.height}×${command.thickness}mm L-bracket`;
1521
+ case 'plate': return `${command.width}×${command.height}×${command.thickness}mm plate`;
1522
+ case 'flange': return `flange OD${command.outerDiameter} ID${command.innerDiameter} h${command.height}mm`;
1523
+ case 'washer': return `washer OD${command.outerDiameter} ID${command.innerDiameter}mm`;
1524
+ case 'spacer': return `spacer OD${command.outerDiameter} ID${command.innerDiameter} h${command.height}mm`;
1525
+ case 'gear': return `${command.teeth}T gear d${command.diameter}mm`;
1526
+ case 'fillet': return `${command.radius}mm fillet`;
1527
+ case 'chamfer': return `${command.distance}mm chamfer`;
1528
+ case 'extrude': return `extrude ${command.height}mm`;
1529
+ case 'revolve': return `revolve ${command.angle}°`;
1530
+ case 'hole': return `hole r${command.radius}mm`;
1531
+ default: return JSON.stringify(command);
1532
+ }
1533
+ }
1534
+
1535
+ // ============================================================================
1536
+ // API KEY MANAGEMENT
1537
+ // ============================================================================
1538
+
1539
+ export function setAPIKeys(geminiKey, groqKey) {
1540
+ chatState.apiKeys.gemini = geminiKey || null;
1541
+ chatState.apiKeys.groq = groqKey || null;
1542
+ localStorage.setItem('cyclecad_api_keys', JSON.stringify(chatState.apiKeys));
1543
+ }
1544
+
1545
+ export function getAPIKeys() {
1546
+ return { ...chatState.apiKeys };
1547
+ }
1548
+
1549
+ // ============================================================================
1550
+ // LEGACY EXPORTS (backward compat)
1551
+ // ============================================================================
1552
+
1553
+ export async function parseCADPrompt(text) {
1554
+ const lower = text.toLowerCase().trim();
1555
+ if (chatState.apiKeys.gemini || chatState.apiKeys.groq) {
1556
+ try {
1557
+ const result = await querySmartLLM(text);
1558
+ if (result?.commands?.length > 0) return result.commands;
1559
+ } catch (e) {
1560
+ console.warn('LLM fallback to local:', e.message);
1561
+ }
1562
+ }
1563
+ return localParseCADPrompt(lower);
1564
+ }
1565
+
982
1566
  export default {
983
1567
  initChat,
984
1568
  addMessage,