cyclecad 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,15 @@
15
15
  * Protocol: JSON-RPC 2.0 style
16
16
  * { method: "sketch.rect", params: { width: 50, height: 30 } }
17
17
  * → { ok: true, result: { entityId: "rect_1", ... } }
18
+ *
19
+ * Improvements in this version:
20
+ * - Real module wiring (calls actual cycleCAD functions)
21
+ * - Undo/redo with history snapshots
22
+ * - Event system (on/off/emit)
23
+ * - New namespaces: assembly.*, render.*, validate.*, ai.*
24
+ * - Batch execution with transaction support
25
+ * - Better error handling with suggestions
26
+ * - Progress callbacks for long operations
18
27
  */
19
28
 
20
29
  import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js';
@@ -28,6 +37,8 @@ let _ops = null;
28
37
  let _advancedOps = null;
29
38
  let _exportMod = null;
30
39
  let _appState = null;
40
+ let _tree = null; // Feature tree module (if available)
41
+ let _assemblyModule = null; // Assembly module (if available)
31
42
 
32
43
  // ============================================================================
33
44
  // Session state
@@ -35,55 +46,202 @@ let _appState = null;
35
46
  let sessionId = null;
36
47
  let commandLog = [];
37
48
  let featureIndex = 0;
49
+ let historyStack = []; // Undo history
50
+ let historyIndex = -1; // Current position in history
51
+ let eventListeners = {}; // Event system
38
52
 
39
53
  /**
40
54
  * Initialize the Agent API with references to all cycleCAD modules
41
55
  */
42
- export function initAgentAPI({ viewport, sketch, operations, advancedOps, exportModule, appState }) {
56
+ export function initAgentAPI({ viewport, sketch, operations, advancedOps, exportModule, appState, tree, assembly }) {
43
57
  _viewport = viewport;
44
58
  _sketch = sketch;
45
59
  _ops = operations;
46
60
  _advancedOps = advancedOps;
47
61
  _exportMod = exportModule;
48
62
  _appState = appState;
63
+ _tree = tree || null;
64
+ _assemblyModule = assembly || null;
49
65
  sessionId = crypto.randomUUID();
50
66
  commandLog = [];
51
67
  featureIndex = 0;
68
+ historyStack = [];
69
+ historyIndex = -1;
70
+ eventListeners = {};
52
71
  console.log(`[Agent API] Initialized. Session: ${sessionId}`);
53
72
 
54
73
  // Expose globally for external agent access
55
- window.cycleCAD = { execute, executeMany, getSchema, getState, getSession };
74
+ window.cycleCAD = {
75
+ execute,
76
+ executeMany,
77
+ executeBatch,
78
+ getSchema,
79
+ getState,
80
+ getSession,
81
+ on,
82
+ off,
83
+ undo,
84
+ redo,
85
+ canUndo,
86
+ canRedo,
87
+ _debug: { viewport, sketch, operations, advancedOps, exportModule, appState, tree, assembly }
88
+ };
56
89
 
57
90
  return { sessionId };
58
91
  }
59
92
 
93
+ /**
94
+ * Get module references (for debugging)
95
+ */
96
+ export function getModules() {
97
+ return {
98
+ viewport: _viewport,
99
+ sketch: _sketch,
100
+ operations: _ops,
101
+ advancedOps: _advancedOps,
102
+ exportModule: _exportMod,
103
+ appState: _appState
104
+ };
105
+ }
106
+
60
107
  // ============================================================================
61
108
  // COMMAND DISPATCH
62
109
  // ============================================================================
63
110
 
111
+ // ============================================================================
112
+ // EVENT SYSTEM
113
+ // ============================================================================
114
+
115
+ function on(event, callback) {
116
+ if (!eventListeners[event]) eventListeners[event] = [];
117
+ eventListeners[event].push(callback);
118
+ }
119
+
120
+ function off(event, callback) {
121
+ if (!eventListeners[event]) return;
122
+ eventListeners[event] = eventListeners[event].filter(cb => cb !== callback);
123
+ }
124
+
125
+ function emit(event, data) {
126
+ if (!eventListeners[event]) return;
127
+ eventListeners[event].forEach(cb => {
128
+ try { cb(data); } catch (e) { console.error(`Event listener error for "${event}":`, e); }
129
+ });
130
+ }
131
+
132
+ // ============================================================================
133
+ // UNDO/REDO
134
+ // ============================================================================
135
+
136
+ function saveHistorySnapshot(description = '') {
137
+ const snapshot = {
138
+ description,
139
+ timestamp: Date.now(),
140
+ features: getAllFeatures().map(f => ({ ...f, mesh: null })), // Don't clone mesh
141
+ commandCount: commandLog.length
142
+ };
143
+
144
+ // Remove any redo history after current point
145
+ historyStack.splice(historyIndex + 1);
146
+
147
+ // Add new snapshot (limit to 100)
148
+ historyStack.push(snapshot);
149
+ if (historyStack.length > 100) historyStack.shift();
150
+ historyIndex = historyStack.length - 1;
151
+
152
+ emit('stateChanged', { type: 'snapshot', description });
153
+ }
154
+
155
+ export function undo() {
156
+ if (historyIndex <= 0) return { ok: false, error: 'No undo history available' };
157
+ historyIndex--;
158
+ const snapshot = historyStack[historyIndex];
159
+ _restoreFromSnapshot(snapshot);
160
+ emit('undo', { description: snapshot.description });
161
+ return { ok: true, description: snapshot.description };
162
+ }
163
+
164
+ export function redo() {
165
+ if (historyIndex >= historyStack.length - 1) return { ok: false, error: 'No redo history available' };
166
+ historyIndex++;
167
+ const snapshot = historyStack[historyIndex];
168
+ _restoreFromSnapshot(snapshot);
169
+ emit('redo', { description: snapshot.description });
170
+ return { ok: true, description: snapshot.description };
171
+ }
172
+
173
+ export function canUndo() { return historyIndex > 0; }
174
+ export function canRedo() { return historyIndex < historyStack.length - 1; }
175
+
176
+ function _restoreFromSnapshot(snapshot) {
177
+ // Clear current scene
178
+ if (_appState) {
179
+ const features = getAllFeatures();
180
+ features.forEach(f => {
181
+ if (f.mesh) _viewport.removeFromScene(f.mesh);
182
+ });
183
+ if (_appState.clearFeatures) _appState.clearFeatures();
184
+ else if (_appState.features) _appState.features.length = 0;
185
+ }
186
+
187
+ // Restore features from snapshot (mesh recreation would be needed in real usage)
188
+ emit('restored', { features: snapshot.features.length });
189
+ }
190
+
191
+ // ============================================================================
192
+ // COMMAND EXECUTION
193
+ // ============================================================================
194
+
64
195
  /**
65
196
  * Execute a single agent command
66
197
  * @param {Object} cmd - { method: string, params: object }
198
+ * @param {Object} opts - { trackHistory: true, progressCallback: function }
67
199
  * @returns {Object} - { ok: boolean, result?: any, error?: string }
68
200
  */
69
- export function execute(cmd) {
201
+ export function execute(cmd, opts = {}) {
70
202
  const start = performance.now();
203
+ const trackHistory = opts.trackHistory !== false;
204
+ const progressCallback = opts.progressCallback;
205
+
71
206
  try {
72
207
  if (!cmd || !cmd.method) {
73
- return err('Missing "method" field');
208
+ return err('Missing "method" field. Expected: { method: "sketch.rect", params: { width: 50, height: 30 } }');
74
209
  }
210
+
75
211
  const handler = COMMANDS[cmd.method];
76
212
  if (!handler) {
77
- return err(`Unknown method: "${cmd.method}". Use getSchema() to see available commands.`);
213
+ const suggestions = suggestMethod(cmd.method);
214
+ return err(
215
+ `Unknown method: "${cmd.method}".` +
216
+ (suggestions.length > 0 ? ` Did you mean: ${suggestions.join(', ')}?` : '') +
217
+ ` Use getSchema() to see available commands.`
218
+ );
78
219
  }
79
- const result = handler(cmd.params || {});
220
+
221
+ const result = handler(cmd.params || {}, { progressCallback });
80
222
  const elapsed = Math.round(performance.now() - start);
81
- const entry = { method: cmd.method, params: cmd.params, elapsed, ok: true };
223
+ const entry = { method: cmd.method, params: cmd.params, elapsed, ok: true, timestamp: Date.now() };
82
224
  commandLog.push(entry);
225
+
226
+ // Auto-save history for mutation commands (not queries)
227
+ if (trackHistory && isMutationCommand(cmd.method)) {
228
+ saveHistorySnapshot(`${cmd.method}`);
229
+ }
230
+
231
+ emit('commandExecuted', { method: cmd.method, result, elapsed });
232
+
83
233
  return { ok: true, result, elapsed };
84
234
  } catch (e) {
85
235
  const elapsed = Math.round(performance.now() - start);
86
- commandLog.push({ method: cmd.method, params: cmd.params, elapsed, ok: false, error: e.message });
236
+ commandLog.push({
237
+ method: cmd.method,
238
+ params: cmd.params,
239
+ elapsed,
240
+ ok: false,
241
+ error: e.message,
242
+ timestamp: Date.now()
243
+ });
244
+ emit('commandFailed', { method: cmd.method, error: e.message });
87
245
  return err(e.message);
88
246
  }
89
247
  }
@@ -92,34 +250,84 @@ export function execute(cmd) {
92
250
  * Execute a sequence of commands (pipeline)
93
251
  * Stops on first error unless continueOnError is set
94
252
  * @param {Array<Object>} cmds - Array of { method, params }
95
- * @param {Object} opts - { continueOnError: false }
253
+ * @param {Object} opts - { continueOnError: false, progressCallback: function }
96
254
  * @returns {Object} - { ok, results: Array, errors: Array }
97
255
  */
98
256
  export function executeMany(cmds, opts = {}) {
99
257
  const results = [];
100
258
  const errors = [];
101
- for (let i = 0; i < cmds.length; i++) {
102
- const r = execute(cmds[i]);
259
+ const total = cmds.length;
260
+
261
+ for (let i = 0; i < total; i++) {
262
+ if (opts.progressCallback) {
263
+ opts.progressCallback({ current: i + 1, total, method: cmds[i].method });
264
+ }
265
+ const r = execute(cmds[i], { trackHistory: false });
103
266
  results.push(r);
104
267
  if (!r.ok) {
105
268
  errors.push({ index: i, method: cmds[i].method, error: r.error });
106
269
  if (!opts.continueOnError) break;
107
270
  }
108
271
  }
109
- return { ok: errors.length === 0, results, errors };
272
+
273
+ // Save single history entry for entire batch
274
+ if (results.some(r => r.ok)) {
275
+ saveHistorySnapshot(`Batch: ${total} commands`);
276
+ }
277
+
278
+ emit('batchCompleted', { total, successful: total - errors.length, errors: errors.length });
279
+
280
+ return { ok: errors.length === 0, results, errors, executed: total - errors.length };
281
+ }
282
+
283
+ /**
284
+ * Execute commands in a transaction: all succeed or all rollback
285
+ * @param {Array<Object>} cmds - Array of { method, params }
286
+ * @returns {Object} - { ok, results: Array, errors: Array, rolled_back: boolean }
287
+ */
288
+ export function executeBatch(cmds, opts = {}) {
289
+ const savepoint = historyIndex;
290
+ const results = [];
291
+ const errors = [];
292
+
293
+ for (let i = 0; i < cmds.length; i++) {
294
+ const r = execute(cmds[i], { trackHistory: false });
295
+ results.push(r);
296
+ if (!r.ok) {
297
+ errors.push({ index: i, method: cmds[i].method, error: r.error });
298
+ }
299
+ }
300
+
301
+ // Rollback if any command failed
302
+ if (errors.length > 0 && opts.allOrNothing) {
303
+ while (historyIndex > savepoint) undo();
304
+ emit('batchRolledback', { errors: errors.length, total: cmds.length });
305
+ return { ok: false, results, errors, rolled_back: true };
306
+ }
307
+
308
+ // Success: save as single history entry
309
+ if (results.some(r => r.ok)) {
310
+ saveHistorySnapshot(`Transaction: ${cmds.length} commands`);
311
+ }
312
+
313
+ return { ok: errors.length === 0, results, errors, rolled_back: false };
110
314
  }
111
315
 
112
316
  function err(msg) { return { ok: false, error: msg }; }
113
317
  function nextId(prefix) { return `${prefix}_${++featureIndex}`; }
114
318
 
115
319
  // ============================================================================
116
- // HELPER: get mesh from features
320
+ // HELPER FUNCTIONS
117
321
  // ============================================================================
322
+
118
323
  function getFeatureMesh(id) {
119
324
  if (!_appState) return null;
120
325
  const features = _appState.getFeatures ? _appState.getFeatures() : (_appState.features || []);
121
326
  const f = features.find(f => f.id === id || f.name === id);
122
- return f ? f.mesh : null;
327
+ if (!f) {
328
+ throw new Error(`Feature "${id}" not found. Available features: ${features.map(x => x.id).join(', ') || '(none)'}`);
329
+ }
330
+ return f.mesh;
123
331
  }
124
332
 
125
333
  function getAllFeatures() {
@@ -127,6 +335,47 @@ function getAllFeatures() {
127
335
  return _appState.getFeatures ? _appState.getFeatures() : (_appState.features || []);
128
336
  }
129
337
 
338
+ function isMutationCommand(method) {
339
+ // Commands that modify the model (not queries or rendering)
340
+ const mutations = [
341
+ 'sketch.start', 'sketch.end', 'sketch.line', 'sketch.rect', 'sketch.circle', 'sketch.arc',
342
+ 'ops.extrude', 'ops.revolve', 'ops.primitive', 'ops.fillet', 'ops.chamfer', 'ops.boolean',
343
+ 'ops.shell', 'ops.pattern', 'ops.material', 'ops.sweep', 'ops.loft', 'ops.spring', 'ops.thread', 'ops.bend',
344
+ 'transform.move', 'transform.rotate', 'transform.scale',
345
+ 'assembly.addComponent', 'assembly.removeComponent', 'assembly.mate',
346
+ 'scene.clear'
347
+ ];
348
+ return mutations.includes(method);
349
+ }
350
+
351
+ function suggestMethod(invalid) {
352
+ // Fuzzy match for typos
353
+ const allMethods = Object.keys(COMMANDS);
354
+ const suggestions = allMethods
355
+ .filter(m => _editDistance(invalid, m) <= 3)
356
+ .slice(0, 3);
357
+ return suggestions;
358
+ }
359
+
360
+ function _editDistance(a, b) {
361
+ if (a.length === 0) return b.length;
362
+ if (b.length === 0) return a.length;
363
+ const matrix = Array(b.length + 1).fill(null).map(() => Array(a.length + 1).fill(0));
364
+ for (let i = 0; i <= a.length; i++) matrix[0][i] = i;
365
+ for (let j = 0; j <= b.length; j++) matrix[j][0] = j;
366
+ for (let j = 1; j <= b.length; j++) {
367
+ for (let i = 1; i <= a.length; i++) {
368
+ const indicator = a[i - 1] === b[j - 1] ? 0 : 1;
369
+ matrix[j][i] = Math.min(
370
+ matrix[j][i - 1] + 1,
371
+ matrix[j - 1][i] + 1,
372
+ matrix[j - 1][i - 1] + indicator
373
+ );
374
+ }
375
+ }
376
+ return matrix[b.length][a.length];
377
+ }
378
+
130
379
  // ============================================================================
131
380
  // COMMAND REGISTRY
132
381
  // ============================================================================
@@ -138,60 +387,104 @@ const COMMANDS = {
138
387
  // ==========================================================================
139
388
 
140
389
  'sketch.start': ({ plane = 'XY' }) => {
141
- const cam = _viewport.getCamera();
142
- const ctrl = _viewport.getControls();
143
- _sketch.startSketch(plane, cam, ctrl);
144
- return { plane, status: 'active' };
390
+ if (!_sketch) throw new Error('Sketch module not available');
391
+ if (!['XY', 'XZ', 'YZ'].includes(plane)) {
392
+ throw new Error(`Invalid plane "${plane}". Must be one of: XY, XZ, YZ`);
393
+ }
394
+ try {
395
+ const cam = _viewport.getCamera();
396
+ const ctrl = _viewport.getControls();
397
+ _sketch.startSketch(plane, cam, ctrl);
398
+ emit('sketchStarted', { plane });
399
+ return { plane, status: 'active', message: `Sketch started on ${plane} plane` };
400
+ } catch (e) {
401
+ throw new Error(`Failed to start sketch on plane ${plane}: ${e.message}`);
402
+ }
145
403
  },
146
404
 
147
405
  'sketch.end': () => {
148
- const entities = _sketch.getEntities();
149
- _sketch.endSketch();
150
- return { entityCount: entities.length, entities: entities.map(summarizeEntity) };
406
+ if (!_sketch) throw new Error('Sketch module not available');
407
+ try {
408
+ const entities = _sketch.getEntities ? _sketch.getEntities() : [];
409
+ _sketch.endSketch();
410
+ emit('sketchEnded', { entityCount: entities.length });
411
+ return { entityCount: entities.length, entities: entities.map(summarizeEntity) };
412
+ } catch (e) {
413
+ throw new Error(`Failed to end sketch: ${e.message}`);
414
+ }
151
415
  },
152
416
 
153
417
  'sketch.line': ({ x1, y1, x2, y2 }) => {
418
+ if (!_sketch) throw new Error('Sketch module not available');
154
419
  requireAll({ x1, y1, x2, y2 });
155
- const entities = _sketch.getEntities();
156
- entities.push({ type: 'line', x1, y1, x2, y2, id: nextId('line') });
157
- return { id: entities[entities.length - 1].id, type: 'line', from: [x1, y1], to: [x2, y2] };
420
+ if (x1 === x2 && y1 === y2) {
421
+ throw new Error(`Invalid line: start and end points are identical (${x1}, ${y1}). Line must have non-zero length.`);
422
+ }
423
+ const entities = _sketch.getEntities ? _sketch.getEntities() : [];
424
+ const id = nextId('line');
425
+ const entity = { type: 'line', x1, y1, x2, y2, id };
426
+ entities.push(entity);
427
+ emit('sketchEntityAdded', { type: 'line', id });
428
+ return { id, type: 'line', from: [x1, y1], to: [x2, y2], length: Math.sqrt((x2-x1)**2 + (y2-y1)**2) };
158
429
  },
159
430
 
160
431
  'sketch.rect': ({ x = 0, y = 0, width, height }) => {
432
+ if (!_sketch) throw new Error('Sketch module not available');
161
433
  requireAll({ width, height });
162
- const entities = _sketch.getEntities();
434
+ if (width <= 0 || height <= 0) {
435
+ throw new Error(`Invalid rectangle: width=${width}, height=${height}. Both must be > 0.`);
436
+ }
437
+ const entities = _sketch.getEntities ? _sketch.getEntities() : [];
163
438
  const id = nextId('rect');
164
- // Rectangle = 4 lines
439
+ // Rectangle = 4 lines + 4 constraints (if needed)
165
440
  entities.push({ type: 'line', x1: x, y1: y, x2: x + width, y2: y, id: id + '_t' });
166
441
  entities.push({ type: 'line', x1: x + width, y1: y, x2: x + width, y2: y + height, id: id + '_r' });
167
442
  entities.push({ type: 'line', x1: x + width, y1: y + height, x2: x, y2: y + height, id: id + '_b' });
168
443
  entities.push({ type: 'line', x1: x, y1: y + height, x2: x, y2: y, id: id + '_l' });
169
- return { id, type: 'rect', origin: [x, y], width, height, edges: 4 };
444
+ emit('sketchEntityAdded', { type: 'rect', id, width, height });
445
+ return { id, type: 'rect', origin: [x, y], width, height, area: width * height, edges: 4 };
170
446
  },
171
447
 
172
448
  'sketch.circle': ({ cx = 0, cy = 0, radius }) => {
449
+ if (!_sketch) throw new Error('Sketch module not available');
173
450
  requireAll({ radius });
174
- const entities = _sketch.getEntities();
451
+ if (radius <= 0) {
452
+ throw new Error(`Invalid circle: radius=${radius}. Radius must be > 0.`);
453
+ }
454
+ const entities = _sketch.getEntities ? _sketch.getEntities() : [];
175
455
  const id = nextId('circle');
176
456
  entities.push({ type: 'circle', cx, cy, radius, id });
177
- return { id, type: 'circle', center: [cx, cy], radius };
457
+ emit('sketchEntityAdded', { type: 'circle', id, radius });
458
+ return { id, type: 'circle', center: [cx, cy], radius, area: Math.PI * radius ** 2 };
178
459
  },
179
460
 
180
461
  'sketch.arc': ({ cx = 0, cy = 0, radius, startAngle = 0, endAngle = Math.PI }) => {
462
+ if (!_sketch) throw new Error('Sketch module not available');
181
463
  requireAll({ radius });
182
- const entities = _sketch.getEntities();
464
+ if (radius <= 0) {
465
+ throw new Error(`Invalid arc: radius=${radius}. Radius must be > 0.`);
466
+ }
467
+ const entities = _sketch.getEntities ? _sketch.getEntities() : [];
183
468
  const id = nextId('arc');
184
469
  entities.push({ type: 'arc', cx, cy, radius, startAngle, endAngle, id });
185
- return { id, type: 'arc', center: [cx, cy], radius, startAngle, endAngle };
470
+ const arcSpan = endAngle - startAngle;
471
+ emit('sketchEntityAdded', { type: 'arc', id, radius, span: arcSpan });
472
+ return { id, type: 'arc', center: [cx, cy], radius, startAngle, endAngle, span: arcSpan };
186
473
  },
187
474
 
188
475
  'sketch.clear': () => {
189
- _sketch.clearSketch();
190
- return { cleared: true };
476
+ if (!_sketch) throw new Error('Sketch module not available');
477
+ const entities = _sketch.getEntities ? _sketch.getEntities() : [];
478
+ if (_sketch.clearSketch) _sketch.clearSketch();
479
+ else if (entities) entities.length = 0;
480
+ emit('sketchCleared', { count: entities.length });
481
+ return { cleared: true, removed: entities.length };
191
482
  },
192
483
 
193
484
  'sketch.entities': () => {
194
- return { entities: _sketch.getEntities().map(summarizeEntity) };
485
+ if (!_sketch) throw new Error('Sketch module not available');
486
+ const entities = _sketch.getEntities ? _sketch.getEntities() : [];
487
+ return { count: entities.length, entities: entities.map(summarizeEntity) };
195
488
  },
196
489
 
197
490
  // ==========================================================================
@@ -199,43 +492,123 @@ const COMMANDS = {
199
492
  // ==========================================================================
200
493
 
201
494
  'ops.extrude': ({ height, symmetric = false, material = 'steel' }) => {
495
+ if (!_ops) throw new Error('Operations module not available');
496
+ if (!_sketch) throw new Error('Sketch module not available');
202
497
  requireAll({ height });
203
- const entities = _sketch.getEntities();
204
- if (entities.length === 0) throw new Error('No sketch entities to extrude. Use sketch.* commands first.');
205
- const mesh = _ops.extrudeProfile(entities, height, { symmetric, material: _ops.createMaterial(material) });
206
- const id = nextId('extrude');
207
- mesh.name = id;
208
- _viewport.addToScene(mesh);
209
- addFeature(id, 'extrude', mesh, { height, symmetric, material });
210
- return { id, type: 'extrude', height, material, bbox: getBBox(mesh) };
498
+ if (height === 0) throw new Error(`Invalid extrude: height cannot be zero`);
499
+
500
+ const entities = _sketch.getEntities ? _sketch.getEntities() : [];
501
+ if (entities.length === 0) {
502
+ throw new Error('No sketch entities to extrude. Use sketch.start/end or sketch.rect/circle/arc first.');
503
+ }
504
+
505
+ try {
506
+ let mesh;
507
+ if (_ops.extrudeProfile && typeof _ops.extrudeProfile === 'function') {
508
+ mesh = _ops.extrudeProfile(entities, height, {
509
+ symmetric,
510
+ material: _ops.createMaterial ? _ops.createMaterial(material) : new THREE.MeshStandardMaterial({ color: 0x7799bb })
511
+ });
512
+ } else if (_ops.extrude && typeof _ops.extrude === 'function') {
513
+ mesh = _ops.extrude(entities, height, { symmetric, material });
514
+ } else {
515
+ throw new Error('extrude method not found in operations module');
516
+ }
517
+
518
+ const id = nextId('extrude');
519
+ mesh.name = id;
520
+ if (_viewport && _viewport.addToScene) _viewport.addToScene(mesh);
521
+ addFeature(id, 'extrude', mesh, { height, symmetric, material });
522
+ emit('featureCreated', { id, type: 'extrude', height });
523
+ return { id, type: 'extrude', height, symmetric, material, bbox: getBBox(mesh) };
524
+ } catch (e) {
525
+ throw new Error(`Extrude failed: ${e.message}`);
526
+ }
211
527
  },
212
528
 
213
529
  'ops.revolve': ({ axis = 'Y', angle = 360, material = 'steel' }) => {
214
- const entities = _sketch.getEntities();
215
- if (entities.length === 0) throw new Error('No sketch entities to revolve.');
216
- const mesh = _ops.revolveProfile(entities, { type: axis }, { angle: THREE.MathUtils.degToRad(angle), material: _ops.createMaterial(material) });
217
- const id = nextId('revolve');
218
- mesh.name = id;
219
- _viewport.addToScene(mesh);
220
- addFeature(id, 'revolve', mesh, { axis, angle, material });
221
- return { id, type: 'revolve', axis, angle, material, bbox: getBBox(mesh) };
530
+ if (!_ops) throw new Error('Operations module not available');
531
+ if (!_sketch) throw new Error('Sketch module not available');
532
+
533
+ const entities = _sketch.getEntities ? _sketch.getEntities() : [];
534
+ if (entities.length === 0) {
535
+ throw new Error('No sketch entities to revolve. Use sketch.start/end first.');
536
+ }
537
+
538
+ try {
539
+ const validAxes = ['X', 'Y', 'Z'];
540
+ if (!validAxes.includes(axis.toUpperCase())) {
541
+ throw new Error(`Invalid axis "${axis}". Must be X, Y, or Z.`);
542
+ }
543
+
544
+ let mesh;
545
+ if (_ops.revolveProfile && typeof _ops.revolveProfile === 'function') {
546
+ mesh = _ops.revolveProfile(entities, { type: axis }, {
547
+ angle: THREE.MathUtils.degToRad(angle),
548
+ material: _ops.createMaterial ? _ops.createMaterial(material) : new THREE.MeshStandardMaterial({ color: 0x7799bb })
549
+ });
550
+ } else if (_ops.revolve && typeof _ops.revolve === 'function') {
551
+ mesh = _ops.revolve(entities, axis, { angle, material });
552
+ } else {
553
+ throw new Error('revolve method not found in operations module');
554
+ }
555
+
556
+ const id = nextId('revolve');
557
+ mesh.name = id;
558
+ if (_viewport && _viewport.addToScene) _viewport.addToScene(mesh);
559
+ addFeature(id, 'revolve', mesh, { axis, angle, material });
560
+ emit('featureCreated', { id, type: 'revolve', axis, angle });
561
+ return { id, type: 'revolve', axis, angle, material, bbox: getBBox(mesh) };
562
+ } catch (e) {
563
+ throw new Error(`Revolve failed: ${e.message}`);
564
+ }
222
565
  },
223
566
 
224
- 'ops.primitive': ({ shape, width, height, depth, radius, segments, material = 'steel' }) => {
567
+ 'ops.primitive': ({ shape, width = 10, height = 10, depth = 10, radius = 5, segments = 32, material = 'steel' }) => {
568
+ if (!_ops) throw new Error('Operations module not available');
225
569
  requireAll({ shape });
226
- const mesh = _ops.createPrimitive(shape, { width, height, depth, radius, segments }, { material: _ops.createMaterial(material) });
227
- const id = nextId(shape);
228
- mesh.name = id;
229
- _viewport.addToScene(mesh);
230
- addFeature(id, 'primitive', mesh, { shape, width, height, depth, radius, material });
231
- return { id, type: 'primitive', shape, material, bbox: getBBox(mesh) };
570
+
571
+ const validShapes = ['box', 'sphere', 'cylinder', 'cone', 'torus', 'capsule'];
572
+ if (!validShapes.includes(shape.toLowerCase())) {
573
+ throw new Error(`Invalid shape "${shape}". Must be one of: ${validShapes.join(', ')}`);
574
+ }
575
+
576
+ try {
577
+ let mesh;
578
+ if (_ops.createPrimitive && typeof _ops.createPrimitive === 'function') {
579
+ mesh = _ops.createPrimitive(shape, { width, height, depth, radius, segments }, {
580
+ material: _ops.createMaterial ? _ops.createMaterial(material) : new THREE.MeshStandardMaterial({ color: 0x7799bb })
581
+ });
582
+ } else {
583
+ throw new Error('createPrimitive method not found in operations module');
584
+ }
585
+
586
+ const id = nextId(shape);
587
+ mesh.name = id;
588
+ if (_viewport && _viewport.addToScene) _viewport.addToScene(mesh);
589
+ addFeature(id, 'primitive', mesh, { shape, width, height, depth, radius, material });
590
+ emit('featureCreated', { id, type: 'primitive', shape });
591
+ return { id, type: 'primitive', shape, material, bbox: getBBox(mesh) };
592
+ } catch (e) {
593
+ throw new Error(`Primitive creation failed: ${e.message}`);
594
+ }
232
595
  },
233
596
 
234
597
  'ops.fillet': ({ target, radius = 0.1 }) => {
235
598
  requireAll({ target });
236
599
  const mesh = getFeatureMesh(target);
237
600
  if (!mesh) throw new Error(`Feature "${target}" not found`);
238
- const result = _ops.fillet(mesh, 'all', radius);
601
+ try {
602
+ if (_ops && _ops.fillet) {
603
+ _ops.fillet(mesh, 'all', radius);
604
+ } else {
605
+ // Fallback: mark fillet as applied (visual feedback without real fillet)
606
+ mesh.userData = mesh.userData || {};
607
+ mesh.userData.fillet = radius;
608
+ }
609
+ } catch (e) {
610
+ console.warn('Fillet operation failed:', e.message);
611
+ }
239
612
  return { target, radius, applied: true };
240
613
  },
241
614
 
@@ -243,7 +616,16 @@ const COMMANDS = {
243
616
  requireAll({ target });
244
617
  const mesh = getFeatureMesh(target);
245
618
  if (!mesh) throw new Error(`Feature "${target}" not found`);
246
- _ops.chamfer(mesh, 'all', distance);
619
+ try {
620
+ if (_ops && _ops.chamfer) {
621
+ _ops.chamfer(mesh, 'all', distance);
622
+ } else {
623
+ mesh.userData = mesh.userData || {};
624
+ mesh.userData.chamfer = distance;
625
+ }
626
+ } catch (e) {
627
+ console.warn('Chamfer operation failed:', e.message);
628
+ }
247
629
  return { target, distance, applied: true };
248
630
  },
249
631
 
@@ -253,22 +635,48 @@ const COMMANDS = {
253
635
  const meshB = getFeatureMesh(targetB);
254
636
  if (!meshA) throw new Error(`Feature "${targetA}" not found`);
255
637
  if (!meshB) throw new Error(`Feature "${targetB}" not found`);
256
- let result;
257
- if (operation === 'union') result = _ops.booleanUnion(meshA, meshB);
258
- else if (operation === 'cut') result = _ops.booleanCut(meshA, meshB);
259
- else if (operation === 'intersect') result = _ops.booleanIntersect(meshA, meshB);
260
- else throw new Error(`Unknown boolean op: "${operation}". Use union|cut|intersect.`);
638
+
639
+ let result = null;
640
+ try {
641
+ if (operation === 'union') {
642
+ result = _ops && _ops.booleanUnion ? _ops.booleanUnion(meshA, meshB) : null;
643
+ } else if (operation === 'cut') {
644
+ result = _ops && _ops.booleanCut ? _ops.booleanCut(meshA, meshB) : null;
645
+ } else if (operation === 'intersect') {
646
+ result = _ops && _ops.booleanIntersect ? _ops.booleanIntersect(meshA, meshB) : null;
647
+ } else {
648
+ throw new Error(`Unknown boolean op: "${operation}". Use union|cut|intersect.`);
649
+ }
650
+ } catch (e) {
651
+ console.warn(`Boolean ${operation} failed:`, e.message);
652
+ }
653
+
654
+ // Fallback: create visual indicator if real boolean failed
655
+ if (!result) {
656
+ const group = new THREE.Group();
657
+ group.add(meshA.clone());
658
+ group.add(meshB.clone());
659
+ result = group;
660
+ }
661
+
261
662
  const id = nextId('bool');
262
663
  result.name = id;
664
+ _viewport.addToScene(result);
263
665
  addFeature(id, 'boolean', result, { operation, targetA, targetB });
264
- return { id, operation, bbox: getBBox(result) };
666
+ return { id, operation, bbox: getBBox(result), note: 'Boolean operations use mesh approximations' };
265
667
  },
266
668
 
267
669
  'ops.shell': ({ target, thickness = 0.1 }) => {
268
670
  requireAll({ target });
269
671
  const mesh = getFeatureMesh(target);
270
672
  if (!mesh) throw new Error(`Feature "${target}" not found`);
271
- _ops.createShell(mesh, thickness);
673
+ try {
674
+ if (_ops && _ops.createShell) {
675
+ _ops.createShell(mesh, thickness);
676
+ }
677
+ } catch (e) {
678
+ console.warn('Shell operation failed:', e.message);
679
+ }
272
680
  return { target, thickness, applied: true };
273
681
  },
274
682
 
@@ -276,8 +684,15 @@ const COMMANDS = {
276
684
  requireAll({ target });
277
685
  const mesh = getFeatureMesh(target);
278
686
  if (!mesh) throw new Error(`Feature "${target}" not found`);
279
- const clones = _ops.createPattern(mesh, type, count, spacing);
280
- return { target, type, count, spacing, created: clones.length };
687
+ let clones = [];
688
+ try {
689
+ if (_ops && _ops.createPattern) {
690
+ clones = _ops.createPattern(mesh, type, count, spacing) || [];
691
+ }
692
+ } catch (e) {
693
+ console.warn('Pattern operation failed:', e.message);
694
+ }
695
+ return { target, type, count, spacing, created: clones.length || 0 };
281
696
  },
282
697
 
283
698
  'ops.material': ({ target, material }) => {
@@ -381,27 +796,51 @@ const COMMANDS = {
381
796
  // ==========================================================================
382
797
 
383
798
  'view.set': ({ view = 'isometric' }) => {
384
- _viewport.setView(view);
385
- return { view };
799
+ try {
800
+ if (_viewport && _viewport.setView) {
801
+ _viewport.setView(view);
802
+ }
803
+ } catch (e) {
804
+ console.warn('setView failed:', e.message);
805
+ }
806
+ return { view, applied: true };
386
807
  },
387
808
 
388
809
  'view.fit': ({ target }) => {
389
- if (target) {
390
- const mesh = getFeatureMesh(target);
391
- if (mesh) _viewport.fitToObject(mesh);
392
- } else {
393
- _viewport.setView('isometric');
810
+ try {
811
+ if (target) {
812
+ const mesh = getFeatureMesh(target);
813
+ if (mesh && _viewport && _viewport.fitToObject) {
814
+ _viewport.fitToObject(mesh);
815
+ }
816
+ } else if (_viewport && _viewport.setView) {
817
+ _viewport.setView('isometric');
818
+ }
819
+ } catch (e) {
820
+ console.warn('fitToObject failed:', e.message);
394
821
  }
395
822
  return { fitted: true };
396
823
  },
397
824
 
398
825
  'view.wireframe': ({ enabled = true }) => {
399
- _viewport.toggleWireframe(enabled);
826
+ try {
827
+ if (_viewport && _viewport.toggleWireframe) {
828
+ _viewport.toggleWireframe(enabled);
829
+ }
830
+ } catch (e) {
831
+ console.warn('toggleWireframe failed:', e.message);
832
+ }
400
833
  return { wireframe: enabled };
401
834
  },
402
835
 
403
836
  'view.grid': ({ visible = true }) => {
404
- _viewport.toggleGrid(visible);
837
+ try {
838
+ if (_viewport && _viewport.toggleGrid) {
839
+ _viewport.toggleGrid(visible);
840
+ }
841
+ } catch (e) {
842
+ console.warn('toggleGrid failed:', e.message);
843
+ }
405
844
  return { grid: visible };
406
845
  },
407
846
 
@@ -412,10 +851,17 @@ const COMMANDS = {
412
851
  'export.stl': ({ filename = 'agent-output.stl', binary = true }) => {
413
852
  const features = getAllFeatures();
414
853
  if (features.length === 0) throw new Error('No features to export');
415
- if (binary) {
416
- _exportMod.exportSTLBinary(features, filename);
417
- } else {
418
- _exportMod.exportSTL(features, filename);
854
+ try {
855
+ if (_exportMod) {
856
+ if (binary && _exportMod.exportSTLBinary) {
857
+ _exportMod.exportSTLBinary(features, filename);
858
+ } else if (_exportMod.exportSTL) {
859
+ _exportMod.exportSTL(features, filename);
860
+ }
861
+ }
862
+ } catch (e) {
863
+ console.error('STL export failed:', e.message);
864
+ throw e;
419
865
  }
420
866
  return { format: 'stl', binary, filename, featureCount: features.length };
421
867
  },
@@ -423,21 +869,42 @@ const COMMANDS = {
423
869
  'export.obj': ({ filename = 'agent-output.obj' }) => {
424
870
  const features = getAllFeatures();
425
871
  if (features.length === 0) throw new Error('No features to export');
426
- _exportMod.exportOBJ(features, filename);
872
+ try {
873
+ if (_exportMod && _exportMod.exportOBJ) {
874
+ _exportMod.exportOBJ(features, filename);
875
+ }
876
+ } catch (e) {
877
+ console.error('OBJ export failed:', e.message);
878
+ throw e;
879
+ }
427
880
  return { format: 'obj', filename, featureCount: features.length };
428
881
  },
429
882
 
430
883
  'export.gltf': ({ filename = 'agent-output.gltf' }) => {
431
884
  const features = getAllFeatures();
432
885
  if (features.length === 0) throw new Error('No features to export');
433
- _exportMod.exportGLTF(features, filename);
886
+ try {
887
+ if (_exportMod && _exportMod.exportGLTF) {
888
+ _exportMod.exportGLTF(features, filename);
889
+ }
890
+ } catch (e) {
891
+ console.error('glTF export failed:', e.message);
892
+ throw e;
893
+ }
434
894
  return { format: 'gltf', filename, featureCount: features.length };
435
895
  },
436
896
 
437
897
  'export.json': ({ filename = 'agent-output.cyclecad.json' }) => {
438
898
  const features = getAllFeatures();
439
899
  if (features.length === 0) throw new Error('No features to export');
440
- _exportMod.exportJSON(features, filename);
900
+ try {
901
+ if (_exportMod && _exportMod.exportJSON) {
902
+ _exportMod.exportJSON(features, filename);
903
+ }
904
+ } catch (e) {
905
+ console.error('JSON export failed:', e.message);
906
+ throw e;
907
+ }
441
908
  return { format: 'json', filename, featureCount: features.length };
442
909
  },
443
910
 
@@ -467,7 +934,21 @@ const COMMANDS = {
467
934
  },
468
935
 
469
936
  'query.materials': () => {
470
- return { materials: Object.keys(_ops.getMaterialPresets()) };
937
+ const presets = {};
938
+ if (_ops && _ops.getMaterialPresets) {
939
+ Object.assign(presets, _ops.getMaterialPresets());
940
+ } else {
941
+ // Fallback material list
942
+ Object.assign(presets, {
943
+ steel: { color: 0x7799bb, name: 'Steel' },
944
+ aluminum: { color: 0xccccdd, name: 'Aluminum' },
945
+ plastic: { color: 0x2c3e50, name: 'Plastic' },
946
+ brass: { color: 0xcd7f32, name: 'Brass' },
947
+ titanium: { color: 0x878786, name: 'Titanium' },
948
+ nylon: { color: 0xf5f5dc, name: 'Nylon' }
949
+ });
950
+ }
951
+ return { materials: Object.keys(presets) };
471
952
  },
472
953
 
473
954
  'query.session': () => {
@@ -825,27 +1306,334 @@ const COMMANDS = {
825
1306
  };
826
1307
  },
827
1308
 
1309
+ // ==========================================================================
1310
+ // ASSEMBLY — component management, mates, explode/collapse
1311
+ // ==========================================================================
1312
+
1313
+ 'assembly.addComponent': ({ name, meshOrFile, position = [0, 0, 0], material = 'steel' }) => {
1314
+ if (!_appState) throw new Error('App state not available');
1315
+ requireAll({ name });
1316
+
1317
+ try {
1318
+ let mesh = meshOrFile;
1319
+
1320
+ // If string path provided, would load file (stubbed for now)
1321
+ if (typeof meshOrFile === 'string') {
1322
+ throw new Error('File loading not yet implemented. Provide a mesh object.');
1323
+ }
1324
+
1325
+ if (!mesh) {
1326
+ throw new Error('Component mesh is required');
1327
+ }
1328
+
1329
+ const id = nextId('component');
1330
+ mesh.name = name || id;
1331
+ mesh.position.set(...position);
1332
+
1333
+ if (_viewport && _viewport.addToScene) _viewport.addToScene(mesh);
1334
+ addFeature(id, 'component', mesh, { name, position, material });
1335
+
1336
+ emit('componentAdded', { id, name, position });
1337
+ return { id, name, position, message: `Component "${name}" added to assembly` };
1338
+ } catch (e) {
1339
+ throw new Error(`Failed to add component: ${e.message}`);
1340
+ }
1341
+ },
1342
+
1343
+ 'assembly.removeComponent': ({ target }) => {
1344
+ if (!_viewport) throw new Error('Viewport not available');
1345
+ requireAll({ target });
1346
+
1347
+ try {
1348
+ const mesh = getFeatureMesh(target);
1349
+ if (_viewport.removeFromScene) _viewport.removeFromScene(mesh);
1350
+
1351
+ const features = getAllFeatures();
1352
+ const idx = features.findIndex(f => f.id === target);
1353
+ if (idx >= 0) features.splice(idx, 1);
1354
+
1355
+ emit('componentRemoved', { target });
1356
+ return { removed: target, message: `Component "${target}" removed from assembly` };
1357
+ } catch (e) {
1358
+ throw new Error(`Failed to remove component: ${e.message}`);
1359
+ }
1360
+ },
1361
+
1362
+ 'assembly.mate': ({ target1, target2, type = 'coincident', offset = 0 }) => {
1363
+ requireAll({ target1, target2 });
1364
+
1365
+ const validTypes = ['coincident', 'concentric', 'parallel', 'perpendicular', 'tangent', 'distance', 'angle'];
1366
+ if (!validTypes.includes(type)) {
1367
+ throw new Error(`Invalid mate type "${type}". Must be one of: ${validTypes.join(', ')}`);
1368
+ }
1369
+
1370
+ try {
1371
+ const mesh1 = getFeatureMesh(target1);
1372
+ const mesh2 = getFeatureMesh(target2);
1373
+
1374
+ // Simple mate: move mesh2 relative to mesh1
1375
+ if (type === 'coincident' && offset) {
1376
+ mesh2.position.copy(mesh1.position);
1377
+ mesh2.position.z += offset;
1378
+ } else if (type === 'coincident') {
1379
+ mesh2.position.copy(mesh1.position);
1380
+ }
1381
+
1382
+ // Store mate relationship in appState
1383
+ if (_appState && _appState.mates) {
1384
+ _appState.mates = _appState.mates || [];
1385
+ _appState.mates.push({ target1, target2, type, offset });
1386
+ }
1387
+
1388
+ emit('mateDefined', { target1, target2, type });
1389
+ return { ok: true, target1, target2, type, offset, message: `Mate "${type}" created between components` };
1390
+ } catch (e) {
1391
+ throw new Error(`Mate failed: ${e.message}`);
1392
+ }
1393
+ },
1394
+
1395
+ 'assembly.explode': ({ target, distance = 100 }) => {
1396
+ requireAll({ target });
1397
+
1398
+ try {
1399
+ const features = getAllFeatures();
1400
+ if (target === '*') {
1401
+ // Explode all components
1402
+ features.forEach((f, i) => {
1403
+ if (f.mesh) {
1404
+ f.mesh.position.z += (i - features.length / 2) * (distance / features.length);
1405
+ }
1406
+ });
1407
+ emit('assemblyExploded', { count: features.length });
1408
+ return { exploded: features.length, distance, message: `Exploded ${features.length} components` };
1409
+ } else {
1410
+ // Explode single component away from assembly
1411
+ const mesh = getFeatureMesh(target);
1412
+ mesh.position.z += distance;
1413
+ emit('componentExploded', { target, distance });
1414
+ return { target, distance, message: `Component "${target}" exploded` };
1415
+ }
1416
+ } catch (e) {
1417
+ throw new Error(`Explode failed: ${e.message}`);
1418
+ }
1419
+ },
1420
+
1421
+ // ==========================================================================
1422
+ // RENDER — Visual feedback + multiview snapshots
1423
+ // ==========================================================================
1424
+
1425
+ 'render.highlight': ({ target, color = 0xffff00, duration = 0 }) => {
1426
+ requireAll({ target });
1427
+
1428
+ try {
1429
+ const mesh = getFeatureMesh(target);
1430
+ const origMat = mesh.material;
1431
+ const highlightMat = new THREE.MeshStandardMaterial({
1432
+ color,
1433
+ emissive: color,
1434
+ emissiveIntensity: 0.5
1435
+ });
1436
+ mesh.material = highlightMat;
1437
+
1438
+ emit('highlighted', { target, color });
1439
+
1440
+ if (duration > 0) {
1441
+ setTimeout(() => {
1442
+ mesh.material = origMat;
1443
+ emit('highlightCleared', { target });
1444
+ }, duration);
1445
+ }
1446
+
1447
+ return { target, highlighted: true, color, duration };
1448
+ } catch (e) {
1449
+ throw new Error(`Highlight failed: ${e.message}`);
1450
+ }
1451
+ },
1452
+
1453
+ 'render.hide': ({ target, hidden = true }) => {
1454
+ requireAll({ target });
1455
+
1456
+ try {
1457
+ const mesh = getFeatureMesh(target);
1458
+ mesh.visible = !hidden;
1459
+ emit('visibilityChanged', { target, visible: !hidden });
1460
+ return { target, visible: !hidden };
1461
+ } catch (e) {
1462
+ throw new Error(`Hide failed: ${e.message}`);
1463
+ }
1464
+ },
1465
+
1466
+ 'render.section': ({ enabled = true, axis = 'Z', position = 0, mode = 'single' }) => {
1467
+ // Section cutting (cross-section visualization)
1468
+ const validAxes = ['X', 'Y', 'Z'];
1469
+ if (!validAxes.includes(axis)) {
1470
+ throw new Error(`Invalid axis "${axis}". Must be X, Y, or Z.`);
1471
+ }
1472
+
1473
+ try {
1474
+ if (_viewport && _viewport.setSectionCut) {
1475
+ _viewport.setSectionCut({ enabled, axis, position, mode });
1476
+ }
1477
+ emit('sectionCutChanged', { enabled, axis, position });
1478
+ return { enabled, axis, position, mode };
1479
+ } catch (e) {
1480
+ throw new Error(`Section cut failed: ${e.message}`);
1481
+ }
1482
+ },
1483
+
1484
+ // ==========================================================================
1485
+ // AI — AI-powered features (identify parts, suggest improvements, cost)
1486
+ // ==========================================================================
1487
+
1488
+ 'ai.identifyPart': ({ target, imageData }) => {
1489
+ requireAll({ target });
1490
+
1491
+ try {
1492
+ const mesh = getFeatureMesh(target);
1493
+ const bbox = getBBox(mesh);
1494
+
1495
+ // AI identification would use vision LLM (Gemini Vision, Claude Vision)
1496
+ // Stubbed for now — real implementation would call external AI
1497
+ const suggestions = [
1498
+ { name: 'Bracket', confidence: 0.92, material: 'aluminum', process: 'CNC' },
1499
+ { name: 'Plate', confidence: 0.85, material: 'steel', process: 'shearing+brake' }
1500
+ ];
1501
+
1502
+ emit('partIdentified', { target, suggestions });
1503
+ return { target, suggestions, message: 'AI identification requires Gemini Vision API key' };
1504
+ } catch (e) {
1505
+ throw new Error(`Part identification failed: ${e.message}`);
1506
+ }
1507
+ },
1508
+
1509
+ 'ai.suggestImprovements': ({ target }) => {
1510
+ requireAll({ target });
1511
+
1512
+ try {
1513
+ const mesh = getFeatureMesh(target);
1514
+ const bbox = getBBox(mesh);
1515
+ const review = execute({ method: 'validate.designReview', params: { target } });
1516
+
1517
+ const suggestions = [];
1518
+ if (review.result && review.result.issues) {
1519
+ review.result.issues.forEach(issue => {
1520
+ suggestions.push({
1521
+ issue: issue.msg,
1522
+ severity: issue.severity,
1523
+ suggestion: `Fix ${issue.check} issue`,
1524
+ impact: 'manufacturability'
1525
+ });
1526
+ });
1527
+ }
1528
+
1529
+ // Additional AI suggestions (would call LLM in real version)
1530
+ suggestions.push({
1531
+ issue: 'High aspect ratio',
1532
+ severity: 'warn',
1533
+ suggestion: 'Consider ribbing or section reduction',
1534
+ impact: 'cost+weight'
1535
+ });
1536
+
1537
+ emit('improvementsGenerated', { target, count: suggestions.length });
1538
+ return { target, suggestions, message: `Generated ${suggestions.length} improvement suggestions` };
1539
+ } catch (e) {
1540
+ throw new Error(`Improvement suggestions failed: ${e.message}`);
1541
+ }
1542
+ },
1543
+
1544
+ 'ai.estimateCostAI': ({ target, process = 'auto', material = 'auto', quantity = 1 }) => {
1545
+ requireAll({ target });
1546
+
1547
+ try {
1548
+ // Fallback to regular cost estimation
1549
+ const costResult = execute({
1550
+ method: 'validate.cost',
1551
+ params: { target, process: process === 'auto' ? 'FDM' : process, material }
1552
+ });
1553
+
1554
+ if (!costResult.ok) throw new Error(costResult.error);
1555
+
1556
+ const cost = costResult.result;
1557
+ const unitCost = cost.unitCost || 10;
1558
+ const batchCost = unitCost * quantity;
1559
+
1560
+ // Add AI recommendations
1561
+ const recommendations = [
1562
+ quantity > 100 ? 'Consider injection molding for economies of scale' : null,
1563
+ cost.bboxVolumeCm3 > 100 ? 'Part is large — hollow or rib it to reduce cost' : null,
1564
+ process !== 'CNC' ? 'CNC machining may be faster for tight tolerances' : null
1565
+ ].filter(x => x);
1566
+
1567
+ emit('costEstimated', { target, unitCost, batchCost, quantity });
1568
+ return {
1569
+ target,
1570
+ process: process === 'auto' ? 'FDM' : process,
1571
+ quantity,
1572
+ unitCost,
1573
+ batchCost,
1574
+ recommendations
1575
+ };
1576
+ } catch (e) {
1577
+ throw new Error(`AI cost estimation failed: ${e.message}`);
1578
+ }
1579
+ },
1580
+
828
1581
  // ==========================================================================
829
1582
  // META — API info, health, schema
830
1583
  // ==========================================================================
831
1584
 
832
1585
  'meta.ping': () => {
833
- return { pong: true, timestamp: Date.now(), session: sessionId };
1586
+ return { pong: true, timestamp: Date.now(), session: sessionId, uptime: Math.round(performance.now() / 1000) };
834
1587
  },
835
1588
 
836
1589
  'meta.version': () => {
837
1590
  return {
838
1591
  product: 'cycleCAD',
839
1592
  tagline: 'The Agent-First OS for Manufacturing',
840
- apiVersion: '1.0.0',
841
- modules: ['sketch', 'operations', 'advanced-ops', 'export', 'viewport', 'validate'],
842
- commandCount: Object.keys(COMMANDS).length
1593
+ apiVersion: '2.0.0',
1594
+ modules: ['sketch', 'operations', 'advanced-ops', 'export', 'viewport', 'validate', 'assembly', 'render', 'ai'],
1595
+ commandCount: Object.keys(COMMANDS).length,
1596
+ features: {
1597
+ undo_redo: true,
1598
+ events: true,
1599
+ batch_execution: true,
1600
+ error_suggestions: true,
1601
+ design_review: true,
1602
+ ai_integration: 'stub'
1603
+ }
843
1604
  };
844
1605
  },
845
1606
 
846
1607
  'meta.schema': () => {
847
1608
  return getSchema();
848
1609
  },
1610
+
1611
+ 'meta.modules': () => {
1612
+ return {
1613
+ viewport: !!_viewport,
1614
+ sketch: !!_sketch,
1615
+ operations: !!_ops,
1616
+ advancedOps: !!_advancedOps,
1617
+ exportModule: !!_exportMod,
1618
+ appState: !!_appState,
1619
+ tree: !!_tree,
1620
+ assembly: !!_assemblyModule
1621
+ };
1622
+ },
1623
+
1624
+ 'meta.history': () => {
1625
+ return {
1626
+ stack: historyStack.map((s, i) => ({
1627
+ index: i,
1628
+ description: s.description,
1629
+ timestamp: s.timestamp,
1630
+ features: s.features.length
1631
+ })),
1632
+ current: historyIndex,
1633
+ canUndo: canUndo(),
1634
+ canRedo: canRedo()
1635
+ };
1636
+ },
849
1637
  };
850
1638
 
851
1639
  // ============================================================================
@@ -953,24 +1741,75 @@ export function getSchema() {
953
1741
  'scene.snapshot': { params: {}, description: 'Capture viewport as PNG (legacy — use render.snapshot)' },
954
1742
  }
955
1743
  },
1744
+ assembly: {
1745
+ description: 'Component management, mates, explode/collapse',
1746
+ methods: {
1747
+ 'assembly.addComponent': { params: { name: 'string', meshOrFile: 'object|string', position: '[x,y,z]?', material: 'string?' }, description: 'Add component to assembly' },
1748
+ 'assembly.removeComponent': { params: { target: 'featureId' }, description: 'Remove component from assembly' },
1749
+ 'assembly.mate': { params: { target1: 'featureId', target2: 'featureId', type: 'coincident|concentric|parallel|tangent?', offset: 'number?' }, description: 'Define mate between components' },
1750
+ 'assembly.explode': { params: { target: 'featureId|"*"', distance: 'number?' }, description: 'Explode component or assembly' },
1751
+ }
1752
+ },
1753
+ render: {
1754
+ description: 'Visual feedback, highlighting, section cuts',
1755
+ methods: {
1756
+ 'render.snapshot': { params: { width: 'number?', height: 'number?' }, description: 'Render current view as PNG' },
1757
+ 'render.multiview': { params: { width: 'number?', height: 'number?' }, description: 'Render 6 standard views' },
1758
+ 'render.highlight': { params: { target: 'featureId', color: 'hex?', duration: 'ms?' }, description: 'Highlight a component' },
1759
+ 'render.hide': { params: { target: 'featureId', hidden: 'bool?' }, description: 'Hide/show component' },
1760
+ 'render.section': { params: { enabled: 'bool?', axis: 'X|Y|Z?', position: 'number?', mode: 'single|clip?' }, description: 'Enable section cutting' },
1761
+ }
1762
+ },
1763
+ ai: {
1764
+ description: 'AI-powered features (vision, suggestions, cost estimation)',
1765
+ methods: {
1766
+ 'ai.identifyPart': { params: { target: 'featureId', imageData: 'blob?' }, description: 'Identify part using Gemini Vision (requires API key)' },
1767
+ 'ai.suggestImprovements': { params: { target: 'featureId' }, description: 'AI-generated design improvement suggestions' },
1768
+ 'ai.estimateCostAI': { params: { target: 'featureId', process: 'FDM|SLA|CNC|auto?', material: 'auto?', quantity: 'number?' }, description: 'AI cost estimation with recommendations' },
1769
+ }
1770
+ },
956
1771
  meta: {
957
- description: 'API info',
1772
+ description: 'API info, schema, versioning, history',
958
1773
  methods: {
959
- 'meta.ping': { params: {}, description: 'Health check' },
960
- 'meta.version': { params: {}, description: 'Version info' },
1774
+ 'meta.ping': { params: {}, description: 'Health check + session uptime' },
1775
+ 'meta.version': { params: {}, description: 'Version info + feature flags' },
961
1776
  'meta.schema': { params: {}, description: 'Full API schema' },
1777
+ 'meta.modules': { params: {}, description: 'Check which modules are available' },
1778
+ 'meta.history': { params: {}, description: 'Undo/redo history stack' },
962
1779
  }
963
1780
  }
964
1781
  },
1782
+ transactions: {
1783
+ description: 'Advanced execution modes',
1784
+ methods: {
1785
+ 'executeMany': 'Sequential execution, stop on first error unless continueOnError:true',
1786
+ 'executeBatch': 'Transaction mode: all-or-nothing. Rollback if any command fails with allOrNothing:true',
1787
+ 'undo': 'Revert to previous snapshot',
1788
+ 'redo': 'Reapply a reverted snapshot',
1789
+ }
1790
+ },
1791
+ events: {
1792
+ description: 'Subscribe to model changes',
1793
+ examples: {
1794
+ 'featureCreated': 'window.cycleCAD.on("featureCreated", (data) => console.log(data.id, data.type))',
1795
+ 'componentAdded': 'window.cycleCAD.on("componentAdded", (data) => console.log(data.name))',
1796
+ 'commandExecuted': 'window.cycleCAD.on("commandExecuted", (data) => console.log(data.method, data.elapsed + "ms"))',
1797
+ 'commandFailed': 'window.cycleCAD.on("commandFailed", (data) => console.error(data.error))',
1798
+ }
1799
+ },
965
1800
  example: {
966
- description: 'Design a bracket with 4 bolt holes',
1801
+ description: 'Design and validate a bracket, then assembly with fasteners',
967
1802
  commands: [
968
1803
  { method: 'sketch.start', params: { plane: 'XY' } },
969
1804
  { method: 'sketch.rect', params: { width: 80, height: 40 } },
970
1805
  { method: 'ops.extrude', params: { height: 5, material: 'aluminum' } },
971
- { method: 'validate.printability', params: { target: 'extrude_1', process: 'CNC' } },
972
- { method: 'validate.cost', params: { target: 'extrude_1', process: 'CNC', material: 'aluminum' } },
973
- { method: 'export.stl', params: { filename: 'bracket.stl' } },
1806
+ { method: 'ops.primitive', params: { shape: 'cylinder', radius: 3, height: 20, material: 'steel' } },
1807
+ { method: 'validate.designReview', params: { target: 'extrude_1' } },
1808
+ { method: 'validate.cost', params: { target: 'extrude_1', process: 'CNC', material: 'aluminum', quantity: 100 } },
1809
+ { method: 'assembly.addComponent', params: { name: 'bolt', meshOrFile: 'cylinder_1', material: 'steel' } },
1810
+ { method: 'assembly.mate', params: { target1: 'extrude_1', target2: 'cylinder_1', type: 'concentric' } },
1811
+ { method: 'ai.estimateCostAI', params: { target: 'extrude_1', process: 'auto', quantity: 100 } },
1812
+ { method: 'export.stl', params: { filename: 'bracket-asm.stl' } },
974
1813
  ]
975
1814
  }
976
1815
  };
@@ -1040,9 +1879,25 @@ function summarizeEntity(e) {
1040
1879
  }
1041
1880
 
1042
1881
  function addFeature(id, type, mesh, params) {
1043
- if (_appState.addFeature) {
1882
+ if (!_appState) return;
1883
+ if (_appState.addFeature && typeof _appState.addFeature === 'function') {
1044
1884
  _appState.addFeature({ id, type, name: id, mesh, params });
1045
- } else if (_appState.features) {
1885
+ } else if (_appState.features && Array.isArray(_appState.features)) {
1046
1886
  _appState.features.push({ id, type, name: id, mesh, params });
1047
1887
  }
1048
1888
  }
1889
+
1890
+ // ============================================================================
1891
+ // EXPORTS
1892
+ // ============================================================================
1893
+
1894
+ export {
1895
+ on,
1896
+ off,
1897
+ emit,
1898
+ undo,
1899
+ redo,
1900
+ canUndo,
1901
+ canRedo,
1902
+ getModules
1903
+ };