cyclecad 0.8.7 → 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/duo-manifest.json +7375 -0
- package/app/index.html +574 -39
- package/app/js/ai-chat.js +1295 -711
- package/app/js/operations.js +99 -1
- package/app/js/token-dashboard.js +1 -1
- package/app/js/tree.js +14 -1
- package/package.json +1 -1
package/app/js/ai-chat.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ai-chat.js -
|
|
2
|
+
* ai-chat.js - Smart CAD Assistant with LLM + Local Fallback
|
|
3
3
|
* cycleCAD: Browser-based parametric 3D modeler
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
115
|
-
if (sendBtn)
|
|
116
|
-
|
|
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
|
|
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', '
|
|
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
|
-
|
|
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
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
+
if (result.reply) {
|
|
139
|
+
addMessage('ai', result.reply);
|
|
140
|
+
}
|
|
162
141
|
|
|
163
|
-
|
|
164
|
-
|
|
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', '
|
|
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
|
-
|
|
165
|
+
// Support basic formatting
|
|
166
|
+
msgDiv.innerHTML = text
|
|
167
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
-
//
|
|
176
|
+
// FUZZY ACTION KEYWORD MATCHING
|
|
201
177
|
// ============================================================================
|
|
202
178
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
//
|
|
225
|
+
// SMART MESSAGE PROCESSING
|
|
282
226
|
// ============================================================================
|
|
283
227
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
320
|
-
if (
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
349
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
function parseSphereCommand(text, numbers) {
|
|
358
|
-
let radius;
|
|
279
|
+
// ============================================================================
|
|
280
|
+
// SCENE ACTION HANDLER (delete, undo, move, hide, booleans, etc.)
|
|
281
|
+
// ============================================================================
|
|
359
282
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
371
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
395
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
//
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
470
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
//
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
//
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
//
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
518
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
//
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
551
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
*
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
569
|
-
if (
|
|
570
|
-
|
|
571
|
-
}
|
|
572
|
-
|
|
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
|
-
|
|
576
|
-
if (
|
|
577
|
-
|
|
578
|
-
}
|
|
579
|
-
|
|
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
|
-
|
|
583
|
-
if (
|
|
584
|
-
|
|
585
|
-
}
|
|
586
|
-
|
|
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
|
-
|
|
590
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
*
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
608
|
-
if (
|
|
609
|
-
|
|
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
|
-
|
|
615
|
-
if (
|
|
616
|
-
|
|
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
|
-
|
|
622
|
-
if (
|
|
623
|
-
|
|
624
|
-
}
|
|
625
|
-
|
|
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
|
-
|
|
629
|
-
|
|
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
|
|
704
|
+
// BOOLEAN OPERATION HANDLER
|
|
642
705
|
// ============================================================================
|
|
643
706
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
708
|
-
const
|
|
709
|
-
if (
|
|
710
|
-
const
|
|
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
|
-
|
|
713
|
-
|
|
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
|
-
//
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
728
|
-
|
|
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
|
-
//
|
|
734
|
-
const
|
|
735
|
-
if (
|
|
736
|
-
const
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
|
|
744
|
-
|
|
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
|
|
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
|
-
//
|
|
783
|
+
// TARGET RESOLUTION (which part does the user mean?)
|
|
755
784
|
// ============================================================================
|
|
756
785
|
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
767
|
-
|
|
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
|
-
|
|
774
|
-
|
|
792
|
+
// "the last" / "last one" / "it"
|
|
793
|
+
if (/\b(last|it|that)\b/.test(text)) return features.length - 1;
|
|
775
794
|
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
//
|
|
784
|
-
const
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
//
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
-
|
|
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
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
875
|
+
// INLINE SHAPE PARSER (for "intersect with a box 20mm")
|
|
828
876
|
// ============================================================================
|
|
829
877
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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
|
-
//
|
|
900
|
+
// DIRECTION PARSING
|
|
870
901
|
// ============================================================================
|
|
871
902
|
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
-
|
|
914
|
+
function parseFirstNumber(text) {
|
|
915
|
+
const m = text.match(/(\d+(?:\.\d+)?)/);
|
|
916
|
+
return m ? parseFloat(m[1]) : 0;
|
|
880
917
|
}
|
|
881
918
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
-
//
|
|
971
|
+
// SCENE CONTEXT
|
|
891
972
|
// ============================================================================
|
|
892
973
|
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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
|
-
|
|
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
|
-
|
|
905
|
-
|
|
906
|
-
"
|
|
907
|
-
"
|
|
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
|
-
|
|
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
|
|
1070
|
+
return await queryGeminiSmart(userText, recentHistory, contextNote);
|
|
915
1071
|
}
|
|
916
|
-
|
|
917
|
-
// Try Groq Llama
|
|
918
1072
|
if (chatState.apiKeys.groq) {
|
|
919
|
-
return await
|
|
1073
|
+
return await queryGroqSmart(userText, recentHistory, contextNote);
|
|
920
1074
|
}
|
|
921
|
-
} catch (
|
|
922
|
-
console.warn('LLM query failed:',
|
|
923
|
-
throw
|
|
1075
|
+
} catch (e) {
|
|
1076
|
+
console.warn('Smart LLM query failed:', e);
|
|
1077
|
+
throw e;
|
|
924
1078
|
}
|
|
925
1079
|
}
|
|
926
1080
|
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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:
|
|
936
|
-
contents
|
|
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
|
-
|
|
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
|
|
1112
|
+
return parseLLMResponse(text);
|
|
947
1113
|
}
|
|
948
1114
|
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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
|
-
|
|
963
|
-
|
|
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
|
|
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
|
-
//
|
|
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,
|