cyclecad 0.2.2 → 0.2.3

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.
Files changed (69) hide show
  1. package/API-BUILD-MANIFEST.txt +339 -0
  2. package/API-SERVER.md +535 -0
  3. package/Architecture-Deck.pptx +0 -0
  4. package/CLAUDE.md +172 -11
  5. package/CLI-BUILD-SUMMARY.md +504 -0
  6. package/CLI-INDEX.md +356 -0
  7. package/CLI-README.md +466 -0
  8. package/COLLABORATION-INTEGRATION-GUIDE.md +325 -0
  9. package/CONNECTED_FABS_GUIDE.md +612 -0
  10. package/CONNECTED_FABS_README.md +310 -0
  11. package/DELIVERABLES.md +343 -0
  12. package/DFM-ANALYZER-INTEGRATION.md +368 -0
  13. package/DFM-QUICK-START.js +253 -0
  14. package/Dockerfile +69 -0
  15. package/IMPLEMENTATION.md +327 -0
  16. package/LICENSE +31 -0
  17. package/MARKETPLACE_QUICK_REFERENCE.txt +294 -0
  18. package/MCP-INDEX.md +264 -0
  19. package/QUICKSTART-API.md +388 -0
  20. package/QUICKSTART-CLI.md +211 -0
  21. package/QUICKSTART-MCP.md +196 -0
  22. package/README-MCP.md +208 -0
  23. package/TEST-TOKEN-ENGINE.md +319 -0
  24. package/TOKEN-ENGINE-SUMMARY.md +266 -0
  25. package/TOKENS-README.md +263 -0
  26. package/TOOLS-REFERENCE.md +254 -0
  27. package/app/index.html +168 -3
  28. package/app/js/TOKEN-INTEGRATION.md +391 -0
  29. package/app/js/agent-api.js +3 -3
  30. package/app/js/ai-copilot.js +1435 -0
  31. package/app/js/cam-pipeline.js +840 -0
  32. package/app/js/collaboration-ui.js +995 -0
  33. package/app/js/collaboration.js +1116 -0
  34. package/app/js/connected-fabs-example.js +404 -0
  35. package/app/js/connected-fabs.js +1449 -0
  36. package/app/js/dfm-analyzer.js +1760 -0
  37. package/app/js/marketplace.js +1994 -0
  38. package/app/js/material-library.js +2115 -0
  39. package/app/js/token-dashboard.js +563 -0
  40. package/app/js/token-engine.js +743 -0
  41. package/app/test-agent.html +1801 -0
  42. package/bin/cyclecad-cli.js +662 -0
  43. package/bin/cyclecad-mcp +2 -0
  44. package/bin/server.js +242 -0
  45. package/cycleCAD-Architecture.pptx +0 -0
  46. package/cycleCAD-Investor-Deck.pptx +0 -0
  47. package/demo-mcp.sh +60 -0
  48. package/docs/API-SERVER-SUMMARY.md +375 -0
  49. package/docs/API-SERVER.md +667 -0
  50. package/docs/CAM-EXAMPLES.md +344 -0
  51. package/docs/CAM-INTEGRATION.md +612 -0
  52. package/docs/CAM-QUICK-REFERENCE.md +199 -0
  53. package/docs/CLI-INTEGRATION.md +510 -0
  54. package/docs/CLI.md +872 -0
  55. package/docs/MARKETPLACE-API-SCHEMA.json +564 -0
  56. package/docs/MARKETPLACE-INTEGRATION.md +467 -0
  57. package/docs/MARKETPLACE-SETUP.html +439 -0
  58. package/docs/MCP-SERVER.md +403 -0
  59. package/examples/api-client-example.js +488 -0
  60. package/examples/api-client-example.py +359 -0
  61. package/examples/batch-manufacturing.txt +28 -0
  62. package/examples/batch-simple.txt +26 -0
  63. package/model-marketplace.html +1273 -0
  64. package/package.json +14 -3
  65. package/server/api-server.js +1120 -0
  66. package/server/mcp-server.js +1161 -0
  67. package/test-api-server.js +432 -0
  68. package/test-mcp.js +198 -0
  69. package/~$cycleCAD-Investor-Deck.pptx +0 -0
@@ -0,0 +1,1116 @@
1
+ /**
2
+ * collaboration.js — Real-time Multi-User Collaboration for cycleCAD
3
+ *
4
+ * Implements:
5
+ * - Session management (create, join, leave)
6
+ * - Presence system (cursor, selection, active tool tracking)
7
+ * - Multi-user 3D cursors with name labels
8
+ * - Operation broadcasting and conflict resolution
9
+ * - Chat system with message history
10
+ * - Git-style version control (snapshots & visual diff)
11
+ * - Permissions system (host, editor, viewer roles)
12
+ * - Shareable links and embed code generation
13
+ * - AI agent participants with simulated activity
14
+ * - Full localStorage persistence
15
+ *
16
+ * Exposes as: window.cycleCAD.collab
17
+ */
18
+
19
+ import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js';
20
+
21
+ // ============================================================================
22
+ // Module State
23
+ // ============================================================================
24
+
25
+ let _viewport = null;
26
+ let _scene = null;
27
+ let _camera = null;
28
+ let _renderer = null;
29
+
30
+ const STATE = {
31
+ session: null,
32
+ userId: null,
33
+ participants: {},
34
+ cursorObjects: {}, // Three.js objects for remote cursors
35
+ selectionHighlights: {}, // Three.js objects for selection highlights
36
+ messages: [],
37
+ snapshots: {}, // { snapshotId: { name, timestamp, features, state } }
38
+ currentSnapshot: null,
39
+ eventListeners: {},
40
+ agentDemo: {
41
+ running: false,
42
+ agents: null,
43
+ updateInterval: null
44
+ }
45
+ };
46
+
47
+ // ============================================================================
48
+ // Initialization
49
+ // ============================================================================
50
+
51
+ /**
52
+ * Initialize collaboration module with viewport reference
53
+ */
54
+ export function initCollaboration(viewport) {
55
+ _viewport = viewport;
56
+ _scene = viewport?.scene;
57
+ _camera = viewport?.camera;
58
+ _renderer = viewport?.renderer;
59
+
60
+ // Load persisted state from localStorage
61
+ loadPersistedState();
62
+
63
+ // Initialize local user
64
+ const userId = localStorage.getItem('ev_userId') || generateUserId();
65
+ localStorage.setItem('ev_userId', userId);
66
+ STATE.userId = userId;
67
+
68
+ // Expose API globally
69
+ window.cycleCAD = window.cycleCAD || {};
70
+ window.cycleCAD.collab = {
71
+ createSession,
72
+ joinSession,
73
+ leaveSession,
74
+ getSession,
75
+ listParticipants,
76
+ updatePresence,
77
+ onPresenceUpdate,
78
+ broadcastOperation,
79
+ onRemoteOperation,
80
+ sendMessage,
81
+ onMessage,
82
+ getMessageHistory,
83
+ saveSnapshot,
84
+ listSnapshots,
85
+ loadSnapshot,
86
+ diffSnapshots,
87
+ visualDiff,
88
+ generateShareLink,
89
+ generateEmbedCode,
90
+ startAgentDemo,
91
+ stopAgentDemo,
92
+ setRole,
93
+ canPerform,
94
+ on: addEventListener,
95
+ off: removeEventListener,
96
+ _debug: { STATE, _scene, _camera, _renderer }
97
+ };
98
+
99
+ // Start rendering loop for presence updates and cursor animation
100
+ startPresenceUpdateLoop();
101
+
102
+ console.log('[Collab] Initialized. UserId:', STATE.userId);
103
+ return { sessionId: STATE.session?.sessionId, userId: STATE.userId };
104
+ }
105
+
106
+ // ============================================================================
107
+ // Session Management
108
+ // ============================================================================
109
+
110
+ /**
111
+ * Create a new collaboration session
112
+ * @param {Object} options - { maxUsers, readOnly, password }
113
+ * @returns {Object} - { sessionId, shareUrl, hostId, created }
114
+ */
115
+ function createSession(options = {}) {
116
+ const sessionId = generateSessionId();
117
+ const created = Date.now();
118
+
119
+ STATE.session = {
120
+ sessionId,
121
+ hostId: STATE.userId,
122
+ created,
123
+ maxUsers: options.maxUsers || 10,
124
+ readOnly: options.readOnly || false,
125
+ password: options.password || null,
126
+ participants: [STATE.userId],
127
+ locked: false
128
+ };
129
+
130
+ STATE.participants[STATE.userId] = {
131
+ userId: STATE.userId,
132
+ name: 'You',
133
+ avatar: generateUserColor(STATE.userId),
134
+ cursor3D: { x: 0, y: 0, z: 0 },
135
+ selectedPart: null,
136
+ activeTool: null,
137
+ camera: { position: { x: 0, y: 0, z: 100 }, target: { x: 0, y: 0, z: 0 } },
138
+ lastSeen: created,
139
+ role: 'host',
140
+ status: 'active'
141
+ };
142
+
143
+ persistState();
144
+ emitEvent('session-created', STATE.session);
145
+
146
+ const shareUrl = `${window.location.origin}${window.location.pathname}?session=${sessionId}`;
147
+ console.log('[Collab] Session created:', sessionId);
148
+
149
+ return {
150
+ sessionId,
151
+ shareUrl,
152
+ hostId: STATE.session.hostId,
153
+ created
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Join an existing session
159
+ * @param {string} sessionId - Session to join
160
+ * @param {Object} options - { name, password, role }
161
+ */
162
+ function joinSession(sessionId, options = {}) {
163
+ // Simulate joining (in production, would call API)
164
+ if (!STATE.session) {
165
+ STATE.session = {
166
+ sessionId,
167
+ hostId: sessionId.substring(0, 8), // mock
168
+ created: Date.now(),
169
+ maxUsers: 10,
170
+ readOnly: false,
171
+ password: null,
172
+ participants: [STATE.userId],
173
+ locked: false
174
+ };
175
+ }
176
+
177
+ const role = options.role || 'editor';
178
+ STATE.participants[STATE.userId] = {
179
+ userId: STATE.userId,
180
+ name: options.name || `User${STATE.userId.substring(0, 4)}`,
181
+ avatar: generateUserColor(STATE.userId),
182
+ cursor3D: { x: 0, y: 0, z: 0 },
183
+ selectedPart: null,
184
+ activeTool: null,
185
+ camera: { position: { x: 0, y: 0, z: 100 }, target: { x: 0, y: 0, z: 0 } },
186
+ lastSeen: Date.now(),
187
+ role,
188
+ status: 'active'
189
+ };
190
+
191
+ persistState();
192
+ emitEvent('session-joined', STATE.session);
193
+ console.log('[Collab] Joined session:', sessionId, 'as', options.name);
194
+
195
+ return STATE.session;
196
+ }
197
+
198
+ /**
199
+ * Leave current session
200
+ */
201
+ function leaveSession() {
202
+ if (!STATE.session) return;
203
+
204
+ broadcastOperation({
205
+ type: 'user-left',
206
+ userId: STATE.userId,
207
+ timestamp: Date.now()
208
+ });
209
+
210
+ stopAgentDemo();
211
+ clearCursors();
212
+ STATE.session = null;
213
+ STATE.participants = {};
214
+
215
+ persistState();
216
+ emitEvent('session-left');
217
+ console.log('[Collab] Left session');
218
+ }
219
+
220
+ /**
221
+ * Get current session info
222
+ */
223
+ function getSession() {
224
+ return STATE.session;
225
+ }
226
+
227
+ /**
228
+ * List all participants in current session
229
+ */
230
+ function listParticipants() {
231
+ return Object.values(STATE.participants).map(p => ({
232
+ ...p,
233
+ isLocalUser: p.userId === STATE.userId
234
+ }));
235
+ }
236
+
237
+ // ============================================================================
238
+ // Presence System
239
+ // ============================================================================
240
+
241
+ /**
242
+ * Update local user's presence (cursor, selection, tool, camera)
243
+ * @param {Object} state - { cursor3D, selectedPart, activeTool, camera }
244
+ */
245
+ function updatePresence(state) {
246
+ if (!STATE.session) return;
247
+
248
+ const presence = STATE.participants[STATE.userId];
249
+ if (!presence) return;
250
+
251
+ Object.assign(presence, {
252
+ ...state,
253
+ lastSeen: Date.now(),
254
+ status: 'active'
255
+ });
256
+
257
+ persistState();
258
+
259
+ // Broadcast to other participants (in real app, via WebSocket)
260
+ broadcastPresence(presence);
261
+ }
262
+
263
+ /**
264
+ * Broadcast presence to simulated network
265
+ */
266
+ function broadcastPresence(presence) {
267
+ // In production, this would send via WebSocket
268
+ // For demo, simulate delayed delivery to participants
269
+ setTimeout(() => {
270
+ for (const userId in STATE.participants) {
271
+ if (userId !== STATE.userId) {
272
+ const otherUser = STATE.participants[userId];
273
+ emitEvent('presence-update', presence);
274
+ }
275
+ }
276
+ }, 50);
277
+ }
278
+
279
+ /**
280
+ * Register callback for presence updates from other users
281
+ */
282
+ function onPresenceUpdate(callback) {
283
+ addEventListener('presence-update', callback);
284
+ }
285
+
286
+ /**
287
+ * Update cursor visibility and position for a participant
288
+ */
289
+ function updateRemoteCursor(presence) {
290
+ if (!_scene || !presence) return;
291
+
292
+ const cursorId = `cursor_${presence.userId}`;
293
+ let cursorGroup = STATE.cursorObjects[cursorId];
294
+
295
+ if (!cursorGroup) {
296
+ // Create new cursor group
297
+ cursorGroup = new THREE.Group();
298
+ cursorGroup.name = cursorId;
299
+
300
+ // Cursor sphere
301
+ const sphereGeom = new THREE.SphereGeometry(2, 8, 8);
302
+ const sphereMat = new THREE.MeshPhongMaterial({
303
+ color: presence.avatar,
304
+ emissive: presence.avatar,
305
+ emissiveIntensity: 0.4,
306
+ wireframe: false
307
+ });
308
+ const sphere = new THREE.Mesh(sphereGeom, sphereMat);
309
+ sphere.name = 'cursor_sphere';
310
+ cursorGroup.add(sphere);
311
+
312
+ // Name label as sprite (canvas texture)
313
+ const labelTexture = createNameLabel(presence.name, presence.avatar);
314
+ const labelGeom = new THREE.PlaneGeometry(8, 2);
315
+ const labelMat = new THREE.MeshBasicMaterial({
316
+ map: labelTexture,
317
+ transparent: true
318
+ });
319
+ const label = new THREE.Mesh(labelGeom, labelMat);
320
+ label.position.z = 3;
321
+ label.name = 'cursor_label';
322
+ cursorGroup.add(label);
323
+
324
+ _scene.add(cursorGroup);
325
+ STATE.cursorObjects[cursorId] = cursorGroup;
326
+ }
327
+
328
+ // Animate cursor to new position
329
+ const targetPos = presence.cursor3D || { x: 0, y: 0, z: 0 };
330
+ animateCursorPosition(cursorGroup, targetPos, 150); // 150ms interpolation
331
+
332
+ // Update opacity based on lastSeen
333
+ const timeSinceLastSeen = Date.now() - presence.lastSeen;
334
+ const opacity = timeSinceLastSeen > 5000 ? 0 : Math.max(0.3, 1 - timeSinceLastSeen / 5000);
335
+ cursorGroup.traverse(child => {
336
+ if (child.material && child.material.opacity !== undefined) {
337
+ child.material.opacity = opacity;
338
+ }
339
+ });
340
+ }
341
+
342
+ /**
343
+ * Animate cursor smoothly between positions
344
+ */
345
+ function animateCursorPosition(cursorGroup, targetPos, duration = 150) {
346
+ const startPos = cursorGroup.position.clone();
347
+ const startTime = Date.now();
348
+
349
+ const animate = () => {
350
+ const elapsed = Date.now() - startTime;
351
+ const progress = Math.min(elapsed / duration, 1);
352
+ const easeProgress = easeOutQuad(progress);
353
+
354
+ cursorGroup.position.x = startPos.x + (targetPos.x - startPos.x) * easeProgress;
355
+ cursorGroup.position.y = startPos.y + (targetPos.y - startPos.y) * easeProgress;
356
+ cursorGroup.position.z = startPos.z + (targetPos.z - startPos.z) * easeProgress;
357
+
358
+ if (progress < 1) {
359
+ requestAnimationFrame(animate);
360
+ }
361
+ };
362
+
363
+ animate();
364
+ }
365
+
366
+ /**
367
+ * Render selection highlights for other users' selected parts
368
+ */
369
+ function updateSelectionHighlights(userId, partIndex) {
370
+ if (!_scene) return;
371
+
372
+ // Remove existing highlight for this user
373
+ const highlightId = `select_${userId}`;
374
+ if (STATE.selectionHighlights[highlightId]) {
375
+ _scene.remove(STATE.selectionHighlights[highlightId]);
376
+ delete STATE.selectionHighlights[highlightId];
377
+ }
378
+
379
+ if (partIndex === null) return;
380
+
381
+ // Create outlines around part (simplified: create wireframe box)
382
+ const color = STATE.participants[userId]?.avatar || 0x0284c7;
383
+ const edgeGeom = new THREE.EdgesGeometry(new THREE.BoxGeometry(10, 10, 10));
384
+ const edgeMat = new THREE.LineBasicMaterial({ color });
385
+ const wireframe = new THREE.LineSegments(edgeGeom, edgeMat);
386
+ wireframe.name = highlightId;
387
+
388
+ _scene.add(wireframe);
389
+ STATE.selectionHighlights[highlightId] = wireframe;
390
+ }
391
+
392
+ /**
393
+ * Clear all remote cursors
394
+ */
395
+ function clearCursors() {
396
+ for (const cursorId in STATE.cursorObjects) {
397
+ const cursor = STATE.cursorObjects[cursorId];
398
+ if (_scene && cursor.parent === _scene) {
399
+ _scene.remove(cursor);
400
+ }
401
+ }
402
+ STATE.cursorObjects = {};
403
+
404
+ for (const highlightId in STATE.selectionHighlights) {
405
+ const highlight = STATE.selectionHighlights[highlightId];
406
+ if (_scene && highlight.parent === _scene) {
407
+ _scene.remove(highlight);
408
+ }
409
+ }
410
+ STATE.selectionHighlights = {};
411
+ }
412
+
413
+ // ============================================================================
414
+ // Operation Broadcasting
415
+ // ============================================================================
416
+
417
+ /**
418
+ * Broadcast an operation to all participants
419
+ * @param {Object} op - { type, method, params, userId, timestamp }
420
+ */
421
+ function broadcastOperation(op) {
422
+ if (!STATE.session) return;
423
+
424
+ const operation = {
425
+ type: op.type,
426
+ method: op.method,
427
+ params: op.params,
428
+ userId: op.userId || STATE.userId,
429
+ timestamp: op.timestamp || Date.now(),
430
+ sessionId: STATE.session.sessionId,
431
+ opId: generateOpId()
432
+ };
433
+
434
+ // Add to local operation log
435
+ if (!window.cycleCAD._opLog) {
436
+ window.cycleCAD._opLog = [];
437
+ }
438
+ window.cycleCAD._opLog.push(operation);
439
+
440
+ // Broadcast to other participants
441
+ setTimeout(() => {
442
+ emitEvent('operation-received', operation);
443
+ }, 50);
444
+ }
445
+
446
+ /**
447
+ * Register callback for remote operations
448
+ */
449
+ function onRemoteOperation(callback) {
450
+ addEventListener('operation-received', callback);
451
+ }
452
+
453
+ // ============================================================================
454
+ // Chat System
455
+ // ============================================================================
456
+
457
+ /**
458
+ * Send a message to the session
459
+ */
460
+ function sendMessage(text, type = 'text') {
461
+ if (!STATE.session) return null;
462
+
463
+ const message = {
464
+ messageId: generateMessageId(),
465
+ userId: STATE.userId,
466
+ userName: STATE.participants[STATE.userId]?.name || 'Anonymous',
467
+ userColor: STATE.participants[STATE.userId]?.avatar || '#0284c7',
468
+ text,
469
+ type,
470
+ timestamp: Date.now()
471
+ };
472
+
473
+ STATE.messages.push(message);
474
+ persistState();
475
+
476
+ emitEvent('message-sent', message);
477
+ console.log('[Chat]', message.userName, ':', text);
478
+
479
+ return message;
480
+ }
481
+
482
+ /**
483
+ * Register callback for messages
484
+ */
485
+ function onMessage(callback) {
486
+ addEventListener('message-sent', callback);
487
+ }
488
+
489
+ /**
490
+ * Get message history
491
+ */
492
+ function getMessageHistory() {
493
+ return STATE.messages.slice();
494
+ }
495
+
496
+ // ============================================================================
497
+ // Version Control (Git-style)
498
+ // ============================================================================
499
+
500
+ /**
501
+ * Save a snapshot of current state with a name
502
+ */
503
+ function saveSnapshot(name) {
504
+ const snapshotId = generateSnapshotId();
505
+ const timestamp = Date.now();
506
+
507
+ // Capture current features from feature tree or app state
508
+ const features = captureCurrentFeatures();
509
+
510
+ STATE.snapshots[snapshotId] = {
511
+ snapshotId,
512
+ name,
513
+ timestamp,
514
+ features,
515
+ userId: STATE.userId,
516
+ userName: STATE.participants[STATE.userId]?.name || 'Anonymous',
517
+ featureCount: features.length
518
+ };
519
+
520
+ STATE.currentSnapshot = snapshotId;
521
+ persistState();
522
+
523
+ emitEvent('snapshot-saved', STATE.snapshots[snapshotId]);
524
+ console.log('[Collab] Snapshot saved:', name, `(${features.length} features)`);
525
+
526
+ return STATE.snapshots[snapshotId];
527
+ }
528
+
529
+ /**
530
+ * List all snapshots
531
+ */
532
+ function listSnapshots() {
533
+ return Object.values(STATE.snapshots)
534
+ .sort((a, b) => b.timestamp - a.timestamp)
535
+ .map(s => ({
536
+ ...s,
537
+ formattedTime: new Date(s.timestamp).toLocaleString()
538
+ }));
539
+ }
540
+
541
+ /**
542
+ * Load a snapshot and restore state
543
+ */
544
+ function loadSnapshot(snapshotId) {
545
+ const snapshot = STATE.snapshots[snapshotId];
546
+ if (!snapshot) {
547
+ console.error('[Collab] Snapshot not found:', snapshotId);
548
+ return null;
549
+ }
550
+
551
+ // In production, would restore features through the operation API
552
+ STATE.currentSnapshot = snapshotId;
553
+ persistState();
554
+
555
+ emitEvent('snapshot-loaded', snapshot);
556
+ console.log('[Collab] Snapshot loaded:', snapshot.name);
557
+
558
+ return snapshot;
559
+ }
560
+
561
+ /**
562
+ * Compare two snapshots and return differences
563
+ */
564
+ function diffSnapshots(id1, id2) {
565
+ const snap1 = STATE.snapshots[id1];
566
+ const snap2 = STATE.snapshots[id2];
567
+
568
+ if (!snap1 || !snap2) return null;
569
+
570
+ const features1 = snap1.features || [];
571
+ const features2 = snap2.features || [];
572
+
573
+ const featureMap1 = new Map(features1.map(f => [f.featureId, f]));
574
+ const featureMap2 = new Map(features2.map(f => [f.featureId, f]));
575
+
576
+ const added = features2.filter(f => !featureMap1.has(f.featureId));
577
+ const removed = features1.filter(f => !featureMap2.has(f.featureId));
578
+ const modified = features2.filter(f => {
579
+ const orig = featureMap1.get(f.featureId);
580
+ return orig && JSON.stringify(orig) !== JSON.stringify(f);
581
+ });
582
+ const unchanged = features1.filter(f => {
583
+ const curr = featureMap2.get(f.featureId);
584
+ return curr && JSON.stringify(curr) === JSON.stringify(f);
585
+ });
586
+
587
+ return {
588
+ snap1Id: id1,
589
+ snap2Id: id2,
590
+ snap1Name: snap1.name,
591
+ snap2Name: snap2.name,
592
+ added: added.map(f => f.name || f.type),
593
+ removed: removed.map(f => f.name || f.type),
594
+ modified: modified.map(f => f.name || f.type),
595
+ unchanged: unchanged.map(f => f.name || f.type),
596
+ summary: `+${added.length} -${removed.length} ~${modified.length}`
597
+ };
598
+ }
599
+
600
+ /**
601
+ * Apply visual diff to the scene (color-code parts)
602
+ */
603
+ function visualDiff(id1, id2) {
604
+ const diff = diffSnapshots(id1, id2);
605
+ if (!diff) return null;
606
+
607
+ console.log('[Collab] Visual diff:', diff.summary);
608
+
609
+ // In production, would highlight parts in the 3D view
610
+ // For now, just return the diff data
611
+ emitEvent('visual-diff-applied', diff);
612
+
613
+ return diff;
614
+ }
615
+
616
+ // ============================================================================
617
+ // Permissions & Roles
618
+ // ============================================================================
619
+
620
+ /**
621
+ * Set role for a participant
622
+ */
623
+ function setRole(userId, role) {
624
+ if (STATE.session?.hostId !== STATE.userId) {
625
+ console.error('[Collab] Only host can change roles');
626
+ return false;
627
+ }
628
+
629
+ const participant = STATE.participants[userId];
630
+ if (!participant) return false;
631
+
632
+ const validRoles = ['host', 'editor', 'viewer'];
633
+ if (!validRoles.includes(role)) return false;
634
+
635
+ participant.role = role;
636
+ persistState();
637
+
638
+ emitEvent('role-changed', { userId, role });
639
+ console.log('[Collab] Role changed:', userId, '->', role);
640
+
641
+ return true;
642
+ }
643
+
644
+ /**
645
+ * Check if a user can perform an action
646
+ */
647
+ function canPerform(userId, action) {
648
+ const participant = STATE.participants[userId];
649
+ if (!participant) return false;
650
+
651
+ const permissions = {
652
+ host: ['create', 'edit', 'delete', 'export', 'save', 'invite'],
653
+ editor: ['create', 'edit', 'delete', 'export', 'save'],
654
+ viewer: []
655
+ };
656
+
657
+ const allowedActions = permissions[participant.role] || [];
658
+ return allowedActions.includes(action);
659
+ }
660
+
661
+ // ============================================================================
662
+ // Share Links & Embed Code
663
+ // ============================================================================
664
+
665
+ /**
666
+ * Generate a shareable link
667
+ */
668
+ function generateShareLink(options = {}) {
669
+ if (!STATE.session) return null;
670
+
671
+ const { readOnly = false, password = null, expiry = '24h' } = options;
672
+
673
+ const shareToken = generateShareToken();
674
+ const expiryMs = parseExpiryToMs(expiry);
675
+ const expiresAt = expiryMs ? Date.now() + expiryMs : null;
676
+
677
+ const shareLink = {
678
+ token: shareToken,
679
+ sessionId: STATE.session.sessionId,
680
+ url: `${window.location.origin}${window.location.pathname}?session=${STATE.session.sessionId}&token=${shareToken}`,
681
+ readOnly,
682
+ password,
683
+ expiresAt,
684
+ createdBy: STATE.userId,
685
+ createdAt: Date.now()
686
+ };
687
+
688
+ // Store share links in localStorage
689
+ const shareLinks = JSON.parse(localStorage.getItem('ev_shareLinks') || '{}');
690
+ shareLinks[shareToken] = shareLink;
691
+ localStorage.setItem('ev_shareLinks', JSON.stringify(shareLinks));
692
+
693
+ return shareLink;
694
+ }
695
+
696
+ /**
697
+ * Generate embed code for iframe embedding
698
+ */
699
+ function generateEmbedCode(options = {}) {
700
+ if (!STATE.session) return null;
701
+
702
+ const { width = 800, height = 600, showToolbar = true, showTree = true } = options;
703
+
704
+ const params = new URLSearchParams({
705
+ session: STATE.session.sessionId,
706
+ embed: 'true',
707
+ toolbar: showToolbar,
708
+ tree: showTree
709
+ });
710
+
711
+ const url = `${window.location.origin}${window.location.pathname}?${params}`;
712
+
713
+ return {
714
+ html: `<iframe src="${url}" width="${width}" height="${height}" style="border: none; border-radius: 8px;"></iframe>`,
715
+ url,
716
+ width,
717
+ height
718
+ };
719
+ }
720
+
721
+ // ============================================================================
722
+ // AI Agent Participants (Demo)
723
+ // ============================================================================
724
+
725
+ /**
726
+ * Start simulated AI agent participants
727
+ */
728
+ function startAgentDemo() {
729
+ if (STATE.agentDemo.running) return;
730
+
731
+ // Create 3 AI agents
732
+ const agents = {
733
+ geometry: {
734
+ userId: generateUserId(),
735
+ name: 'GeometryBot',
736
+ avatar: '#3b82f6',
737
+ role: 'editor',
738
+ personality: 'Creates shapes, suggests improvements'
739
+ },
740
+ quality: {
741
+ userId: generateUserId(),
742
+ name: 'QualityBot',
743
+ avatar: '#ef4444',
744
+ role: 'viewer',
745
+ personality: 'Runs DFM checks, flags issues'
746
+ },
747
+ cost: {
748
+ userId: generateUserId(),
749
+ name: 'CostBot',
750
+ avatar: '#8b5cf6',
751
+ role: 'viewer',
752
+ personality: 'Estimates costs, compares processes'
753
+ }
754
+ };
755
+
756
+ // Add agents to participants
757
+ for (const agentKey in agents) {
758
+ const agent = agents[agentKey];
759
+ STATE.participants[agent.userId] = {
760
+ userId: agent.userId,
761
+ name: agent.name,
762
+ avatar: agent.avatar,
763
+ cursor3D: {
764
+ x: (Math.random() - 0.5) * 50,
765
+ y: (Math.random() - 0.5) * 50,
766
+ z: (Math.random() - 0.5) * 50
767
+ },
768
+ selectedPart: null,
769
+ activeTool: null,
770
+ camera: { position: { x: 0, y: 0, z: 100 }, target: { x: 0, y: 0, z: 0 } },
771
+ lastSeen: Date.now(),
772
+ role: agent.role,
773
+ status: 'active',
774
+ isAgent: true
775
+ };
776
+
777
+ emitEvent('user-joined', STATE.participants[agent.userId]);
778
+ }
779
+
780
+ STATE.agentDemo.agents = agents;
781
+ STATE.agentDemo.running = true;
782
+
783
+ // Simulate agent activity
784
+ STATE.agentDemo.updateInterval = setInterval(() => {
785
+ simulateAgentActivity(agents);
786
+ }, 3000);
787
+
788
+ console.log('[Collab] Agent demo started with 3 agents');
789
+ emitEvent('agent-demo-started');
790
+ }
791
+
792
+ /**
793
+ * Stop agent demo
794
+ */
795
+ function stopAgentDemo() {
796
+ if (!STATE.agentDemo.running) return;
797
+
798
+ if (STATE.agentDemo.updateInterval) {
799
+ clearInterval(STATE.agentDemo.updateInterval);
800
+ }
801
+
802
+ // Remove agents from participants
803
+ for (const agentKey in STATE.agentDemo.agents) {
804
+ const agent = STATE.agentDemo.agents[agentKey];
805
+ delete STATE.participants[agent.userId];
806
+ }
807
+
808
+ STATE.agentDemo.agents = null;
809
+ STATE.agentDemo.running = false;
810
+
811
+ clearCursors();
812
+ console.log('[Collab] Agent demo stopped');
813
+ emitEvent('agent-demo-stopped');
814
+ }
815
+
816
+ /**
817
+ * Simulate activity from agents
818
+ */
819
+ function simulateAgentActivity(agents) {
820
+ const agentKeys = Object.keys(agents);
821
+ const randomAgent = agentKeys[Math.floor(Math.random() * agentKeys.length)];
822
+ const agent = agents[randomAgent];
823
+
824
+ // Random movement
825
+ const participant = STATE.participants[agent.userId];
826
+ if (participant) {
827
+ participant.cursor3D = {
828
+ x: participant.cursor3D.x + (Math.random() - 0.5) * 10,
829
+ y: participant.cursor3D.y + (Math.random() - 0.5) * 10,
830
+ z: participant.cursor3D.z + (Math.random() - 0.5) * 10
831
+ };
832
+ participant.lastSeen = Date.now();
833
+
834
+ updateRemoteCursor(participant);
835
+
836
+ // Occasional messages
837
+ if (Math.random() < 0.2) {
838
+ const messages = {
839
+ geometry: [
840
+ 'Fillet edge R2.5 for better aesthetics',
841
+ 'Consider adding a chamfer here',
842
+ 'Geometry looks good!'
843
+ ],
844
+ quality: [
845
+ 'DFM check: Undercut detected',
846
+ 'Wall thickness acceptable',
847
+ 'Recommend parting line adjustment'
848
+ ],
849
+ cost: [
850
+ 'Estimated cost: $12.50 (CNC)',
851
+ 'Cheaper as molded part: $2.00',
852
+ 'Lead time: 5 days injection molding'
853
+ ]
854
+ };
855
+
856
+ const msgList = messages[randomAgent] || [];
857
+ const msg = msgList[Math.floor(Math.random() * msgList.length)];
858
+
859
+ STATE.messages.push({
860
+ messageId: generateMessageId(),
861
+ userId: agent.userId,
862
+ userName: agent.name,
863
+ userColor: agent.avatar,
864
+ text: msg,
865
+ type: 'system',
866
+ timestamp: Date.now()
867
+ });
868
+
869
+ emitEvent('message-sent', STATE.messages[STATE.messages.length - 1]);
870
+ }
871
+ }
872
+ }
873
+
874
+ // ============================================================================
875
+ // Presence Update Loop
876
+ // ============================================================================
877
+
878
+ /**
879
+ * Start the presence update loop (animates cursors, syncs state)
880
+ */
881
+ function startPresenceUpdateLoop() {
882
+ setInterval(() => {
883
+ if (!STATE.session) return;
884
+
885
+ // Update cursor visibility for all participants
886
+ for (const userId in STATE.participants) {
887
+ if (userId !== STATE.userId) {
888
+ updateRemoteCursor(STATE.participants[userId]);
889
+ }
890
+ }
891
+ }, 100);
892
+ }
893
+
894
+ // ============================================================================
895
+ // Utilities
896
+ // ============================================================================
897
+
898
+ /**
899
+ * Generate a unique user ID
900
+ */
901
+ function generateUserId() {
902
+ return `user_${Math.random().toString(36).substring(2, 11)}`;
903
+ }
904
+
905
+ /**
906
+ * Generate a unique session ID
907
+ */
908
+ function generateSessionId() {
909
+ return `session_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`;
910
+ }
911
+
912
+ /**
913
+ * Generate a unique operation ID
914
+ */
915
+ function generateOpId() {
916
+ return `op_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
917
+ }
918
+
919
+ /**
920
+ * Generate a unique message ID
921
+ */
922
+ function generateMessageId() {
923
+ return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
924
+ }
925
+
926
+ /**
927
+ * Generate a unique snapshot ID
928
+ */
929
+ function generateSnapshotId() {
930
+ return `snap_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
931
+ }
932
+
933
+ /**
934
+ * Generate a unique share token
935
+ */
936
+ function generateShareToken() {
937
+ return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
938
+ }
939
+
940
+ /**
941
+ * Generate a user color based on user ID (deterministic)
942
+ */
943
+ function generateUserColor(userId) {
944
+ const hash = userId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
945
+ const hue = (hash % 360);
946
+ const sat = 70;
947
+ const light = 50;
948
+ return `hsl(${hue}, ${sat}%, ${light}%)`;
949
+ }
950
+
951
+ /**
952
+ * Create a canvas texture for name labels
953
+ */
954
+ function createNameLabel(name, color) {
955
+ const canvas = document.createElement('canvas');
956
+ canvas.width = 256;
957
+ canvas.height = 64;
958
+
959
+ const ctx = canvas.getContext('2d');
960
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
961
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
962
+
963
+ ctx.fillStyle = typeof color === 'string' ? color : `#${color.toString(16)}`;
964
+ ctx.font = 'bold 24px Arial';
965
+ ctx.textAlign = 'center';
966
+ ctx.textBaseline = 'middle';
967
+ ctx.fillText(name.substring(0, 15), canvas.width / 2, canvas.height / 2);
968
+
969
+ const texture = new THREE.CanvasTexture(canvas);
970
+ return texture;
971
+ }
972
+
973
+ /**
974
+ * Ease out quad easing function
975
+ */
976
+ function easeOutQuad(t) {
977
+ return 1 - (1 - t) * (1 - t);
978
+ }
979
+
980
+ /**
981
+ * Parse expiry string to milliseconds
982
+ */
983
+ function parseExpiryToMs(expiry) {
984
+ const expiryMap = {
985
+ '1h': 3600 * 1000,
986
+ '24h': 24 * 3600 * 1000,
987
+ '7d': 7 * 24 * 3600 * 1000,
988
+ 'never': null
989
+ };
990
+ return expiryMap[expiry] || null;
991
+ }
992
+
993
+ /**
994
+ * Capture current features from app state
995
+ */
996
+ function captureCurrentFeatures() {
997
+ // In production, would extract from window.cycleCAD feature tree
998
+ // For now, mock some features
999
+ if (window.cycleCAD && window.cycleCAD._opLog) {
1000
+ return window.cycleCAD._opLog.map((op, idx) => ({
1001
+ featureId: `feat_${idx}`,
1002
+ name: op.method || op.type,
1003
+ type: op.type,
1004
+ params: op.params,
1005
+ timestamp: op.timestamp
1006
+ }));
1007
+ }
1008
+ return [];
1009
+ }
1010
+
1011
+ // ============================================================================
1012
+ // Event System
1013
+ // ============================================================================
1014
+
1015
+ /**
1016
+ * Register event listener
1017
+ */
1018
+ function addEventListener(eventName, callback) {
1019
+ if (!STATE.eventListeners[eventName]) {
1020
+ STATE.eventListeners[eventName] = [];
1021
+ }
1022
+ STATE.eventListeners[eventName].push(callback);
1023
+ }
1024
+
1025
+ /**
1026
+ * Unregister event listener
1027
+ */
1028
+ function removeEventListener(eventName, callback) {
1029
+ if (!STATE.eventListeners[eventName]) return;
1030
+ STATE.eventListeners[eventName] = STATE.eventListeners[eventName].filter(cb => cb !== callback);
1031
+ }
1032
+
1033
+ /**
1034
+ * Emit event to all listeners
1035
+ */
1036
+ function emitEvent(eventName, data) {
1037
+ const listeners = STATE.eventListeners[eventName] || [];
1038
+ listeners.forEach(cb => {
1039
+ try {
1040
+ cb(data);
1041
+ } catch (err) {
1042
+ console.error(`[Collab] Event listener error for ${eventName}:`, err);
1043
+ }
1044
+ });
1045
+ }
1046
+
1047
+ // ============================================================================
1048
+ // Persistence
1049
+ // ============================================================================
1050
+
1051
+ /**
1052
+ * Persist state to localStorage
1053
+ */
1054
+ function persistState() {
1055
+ try {
1056
+ localStorage.setItem('ev_collabState', JSON.stringify({
1057
+ session: STATE.session,
1058
+ messages: STATE.messages,
1059
+ snapshots: STATE.snapshots,
1060
+ currentSnapshot: STATE.currentSnapshot
1061
+ }));
1062
+ } catch (err) {
1063
+ console.error('[Collab] Persistence error:', err);
1064
+ }
1065
+ }
1066
+
1067
+ /**
1068
+ * Load persisted state from localStorage
1069
+ */
1070
+ function loadPersistedState() {
1071
+ try {
1072
+ const stored = localStorage.getItem('ev_collabState');
1073
+ if (stored) {
1074
+ const data = JSON.parse(stored);
1075
+ STATE.session = data.session;
1076
+ STATE.messages = data.messages || [];
1077
+ STATE.snapshots = data.snapshots || {};
1078
+ STATE.currentSnapshot = data.currentSnapshot;
1079
+ }
1080
+ } catch (err) {
1081
+ console.error('[Collab] Load persistence error:', err);
1082
+ }
1083
+ }
1084
+
1085
+ // ============================================================================
1086
+ // Export
1087
+ // ============================================================================
1088
+
1089
+ export {
1090
+ initCollaboration,
1091
+ createSession,
1092
+ joinSession,
1093
+ leaveSession,
1094
+ getSession,
1095
+ listParticipants,
1096
+ updatePresence,
1097
+ onPresenceUpdate,
1098
+ broadcastOperation,
1099
+ onRemoteOperation,
1100
+ sendMessage,
1101
+ onMessage,
1102
+ getMessageHistory,
1103
+ saveSnapshot,
1104
+ listSnapshots,
1105
+ loadSnapshot,
1106
+ diffSnapshots,
1107
+ visualDiff,
1108
+ generateShareLink,
1109
+ generateEmbedCode,
1110
+ startAgentDemo,
1111
+ stopAgentDemo,
1112
+ setRole,
1113
+ canPerform,
1114
+ clearCursors,
1115
+ updateRemoteCursor
1116
+ };