cyclecad 2.0.1 → 3.0.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.
Files changed (48) hide show
  1. package/DELIVERABLES.txt +296 -445
  2. package/ENHANCEMENT_COMPLETION_REPORT.md +383 -0
  3. package/ENHANCEMENT_SUMMARY.txt +308 -0
  4. package/FEATURE_INVENTORY.md +235 -0
  5. package/FUSION360_FEATURES_SUMMARY.md +452 -0
  6. package/FUSION360_PARITY_ENHANCEMENTS.md +461 -0
  7. package/FUSION360_PARITY_SUMMARY.md +520 -0
  8. package/FUSION360_QUICK_REFERENCE.md +351 -0
  9. package/IMPLEMENTATION_GUIDE.md +502 -0
  10. package/INTEGRATION-GUIDE.md +377 -0
  11. package/MODULES_PHASES_6_7.md +780 -0
  12. package/MODULE_API_REFERENCE.md +712 -0
  13. package/MODULE_INVENTORY.txt +264 -0
  14. package/app/index.html +1345 -4930
  15. package/app/js/app.js +1312 -514
  16. package/app/js/brep-kernel.js +1353 -455
  17. package/app/js/help-module.js +1437 -0
  18. package/app/js/kernel.js +364 -40
  19. package/app/js/modules/animation-module.js +1461 -0
  20. package/app/js/modules/assembly-module.js +47 -3
  21. package/app/js/modules/cam-module.js +1572 -0
  22. package/app/js/modules/collaboration-module.js +1615 -0
  23. package/app/js/modules/constraint-module.js +1266 -0
  24. package/app/js/modules/data-module.js +1054 -0
  25. package/app/js/modules/drawing-module.js +54 -8
  26. package/app/js/modules/formats-module.js +873 -0
  27. package/app/js/modules/inspection-module.js +1330 -0
  28. package/app/js/modules/mesh-module-enhanced.js +880 -0
  29. package/app/js/modules/mesh-module.js +968 -0
  30. package/app/js/modules/operations-module.js +40 -7
  31. package/app/js/modules/plugin-module.js +1554 -0
  32. package/app/js/modules/rendering-module.js +1766 -0
  33. package/app/js/modules/scripting-module.js +1073 -0
  34. package/app/js/modules/simulation-module.js +60 -3
  35. package/app/js/modules/sketch-module.js +2029 -91
  36. package/app/js/modules/step-module.js +47 -6
  37. package/app/js/modules/surface-module.js +1040 -0
  38. package/app/js/modules/version-module.js +1830 -0
  39. package/app/js/modules/viewport-module.js +95 -8
  40. package/app/test-agent-v2.html +881 -1316
  41. package/cycleCAD-Architecture-v2.pptx +0 -0
  42. package/docs/ARCHITECTURE.html +838 -1408
  43. package/docs/DEVELOPER-GUIDE.md +1504 -0
  44. package/docs/TUTORIAL.md +740 -0
  45. package/package.json +1 -1
  46. package/~$cycleCAD-Architecture-v2.pptx +0 -0
  47. package/.github/scripts/cad-diff.js +0 -590
  48. package/.github/workflows/cad-diff.yml +0 -117
@@ -0,0 +1,1615 @@
1
+ /**
2
+ * @file collaboration-module.js
3
+ * @description Real-time multi-user collaboration via WebRTC peer-to-peer.
4
+ * Users see each other's 3D cursors, watch live edits synchronize across the design,
5
+ * and communicate via floating in-viewport chat messages. Built on CRDT (Conflict-free
6
+ * Replicated Data Types) for automatic conflict resolution when concurrent edits occur.
7
+ *
8
+ * @tutorial Creating a Collaboration Room
9
+ * Step 1: Initialize collaboration
10
+ * const collab = await kernel.exec('collab.createRoom', {
11
+ * capacity: 10,
12
+ * access: 'owner'
13
+ * });
14
+ * // Returns { code: 'ABC123', hostName: 'You' }
15
+ *
16
+ * Step 2: Share the code (ABC123) with teammates via email, Slack, or link
17
+ *
18
+ * Step 3: They join with
19
+ * const joined = await kernel.exec('collab.joinRoom', {
20
+ * code: 'ABC123',
21
+ * userName: 'Alice'
22
+ * });
23
+ * // They instantly see your cursor, geometry, and chat
24
+ *
25
+ * Step 4: Make changes — they're broadcast to all peers automatically
26
+ * kernel.exec('shape.cylinder', { radius: 25, height: 80 });
27
+ * // All peers see the cylinder appear in real-time
28
+ *
29
+ * Step 5: Leave the room (browser close or manual)
30
+ * kernel.exec('collab.leaveRoom');
31
+ *
32
+ * @tutorial Changing User Roles
33
+ * Room owners can control permissions:
34
+ * kernel.exec('collab.setRole', {
35
+ * userId: 'alice-123',
36
+ * role: 'editor' // 'owner' | 'editor' | 'viewer'
37
+ * });
38
+ *
39
+ * Roles:
40
+ * - owner: Full control (create/edit/delete/invite)
41
+ * - editor: Can model and view, cannot delete others' work or invite
42
+ * - viewer: Read-only, can only see and comment
43
+ *
44
+ * @tutorial In-Viewport Chat
45
+ * Press Enter to open chat box, type, press Enter to send.
46
+ * Messages appear as floating bubbles near your cursor for 10 seconds.
47
+ * Includes sender name, timestamp, and avatar color (per user).
48
+ *
49
+ * @version 1.0.0
50
+ * @author Sachin Kumar <vvlars@googlemail.com>
51
+ * @license MIT
52
+ */
53
+
54
+ // ============================================================================
55
+ // COLLABORATION MODULE — Main Export
56
+ // ============================================================================
57
+
58
+ export default {
59
+ name: 'collaboration',
60
+ version: '1.0.0',
61
+
62
+ // ========================================================================
63
+ // MODULE STATE
64
+ // ========================================================================
65
+
66
+ state: {
67
+ /** @type {string|null} Current room code (null if not connected) */
68
+ roomCode: null,
69
+
70
+ /** @type {string} Local user ID (UUID) */
71
+ userId: null,
72
+
73
+ /** @type {string} Local user name */
74
+ userName: 'User',
75
+
76
+ /** @type {'owner'|'editor'|'viewer'} Local user role */
77
+ role: 'viewer',
78
+
79
+ /** @type {Map<string, Object>} Connected peers: userId → {name, role, cursorPos, color, ping} */
80
+ peers: new Map(),
81
+
82
+ /** @type {Array<Object>} CRDT operation log for sync */
83
+ operationLog: [],
84
+
85
+ /** @type {number} Clock vector for causality tracking */
86
+ lamportClock: 0,
87
+
88
+ /** @type {boolean} Room active */
89
+ connected: false,
90
+
91
+ /** @type {number} Peer discovery timeout (ms) */
92
+ peerDiscoveryInterval: null,
93
+
94
+ /** @type {Object} WebSocket for signaling (if signaling server available) */
95
+ signalingSocket: null,
96
+
97
+ /** @type {Map<string, RTCPeerConnection>} WebRTC peer connections */
98
+ peerConnections: new Map(),
99
+
100
+ /** @type {Map<string, RTCDataChannel>} Data channels per peer */
101
+ dataChannels: new Map(),
102
+ },
103
+
104
+ // ========================================================================
105
+ // INIT — Setup and teardown
106
+ // ========================================================================
107
+
108
+ /**
109
+ * Initialize collaboration module.
110
+ * Sets up event listeners, generates user ID, loads preferences.
111
+ * Called automatically on app startup.
112
+ *
113
+ * @async
114
+ * @returns {Promise<void>}
115
+ */
116
+ async init() {
117
+ this.state.userId = this._generateUUID();
118
+ this.state.userName = localStorage.getItem('collab_userName') || 'User';
119
+
120
+ // Listen for 3D cursor movement
121
+ window.addEventListener('mousemove', (e) => this._onCursorMove(e));
122
+
123
+ // Listen for chat keypress
124
+ document.addEventListener('keypress', (e) => this._onChatKeyPress(e));
125
+
126
+ // Detect page unload and gracefully leave room
127
+ window.addEventListener('beforeunload', () => {
128
+ if (this.state.connected) {
129
+ this._disconnect();
130
+ }
131
+ });
132
+
133
+ console.log('[Collaboration] Initialized. User ID:', this.state.userId);
134
+ },
135
+
136
+ // ========================================================================
137
+ // PUBLIC API — Room Management
138
+ // ========================================================================
139
+
140
+ /**
141
+ * Create a new collaboration room.
142
+ * Generates a 6-character alphanumeric room code.
143
+ * Caller becomes the owner with full permissions.
144
+ *
145
+ * @param {Object} options
146
+ * @param {number} [options.capacity=10] Max peers allowed (host is separate)
147
+ * @param {string} [options.hostName] Display name for host (default: 'You')
148
+ * @returns {Promise<Object>} { code, hostName, shareLink }
149
+ *
150
+ * @example
151
+ * const room = await kernel.exec('collab.createRoom', { capacity: 5 });
152
+ * console.log('Room code:', room.code); // 'ABC123'
153
+ */
154
+ async createRoom(options = {}) {
155
+ const { capacity = 10, hostName = 'You' } = options;
156
+
157
+ // Generate room code
158
+ const code = this._generateRoomCode();
159
+
160
+ this.state.roomCode = code;
161
+ this.state.role = 'owner';
162
+ this.state.userName = hostName;
163
+ this.state.connected = true;
164
+
165
+ // Save preference
166
+ localStorage.setItem('collab_userName', hostName);
167
+
168
+ // Initialize empty peer map
169
+ this.state.peers.clear();
170
+ this.state.operationLog = [];
171
+ this.state.lamportClock = 0;
172
+
173
+ this._showNotification(`Room created: ${code}`, 'success');
174
+ this._broadcastEvent('collab:roomCreated', { code, hostName });
175
+
176
+ return {
177
+ code,
178
+ hostName,
179
+ shareLink: `${window.location.origin}?join=${code}`,
180
+ };
181
+ },
182
+
183
+ /**
184
+ * Join an existing collaboration room.
185
+ * Requests peer list from host and establishes WebRTC connections.
186
+ *
187
+ * @param {Object} options
188
+ * @param {string} options.code Room code (6 chars, case-insensitive)
189
+ * @param {string} [options.userName] Display name for this user
190
+ * @returns {Promise<Object>} { joined, peerCount, hostName }
191
+ *
192
+ * @example
193
+ * const result = await kernel.exec('collab.joinRoom', {
194
+ * code: 'ABC123',
195
+ * userName: 'Alice'
196
+ * });
197
+ * if (result.joined) console.log('Connected to', result.peerCount, 'peers');
198
+ */
199
+ async joinRoom(options = {}) {
200
+ const { code, userName = 'User' } = options;
201
+
202
+ if (!code || code.length !== 6) {
203
+ throw new Error('Invalid room code format');
204
+ }
205
+
206
+ this.state.roomCode = code.toUpperCase();
207
+ this.state.userName = userName;
208
+ this.state.role = 'editor'; // Joining user starts as editor, not owner
209
+ this.state.connected = true;
210
+
211
+ localStorage.setItem('collab_userName', userName);
212
+
213
+ // Simulate peer discovery (in production: contact signaling server)
214
+ await this._discoverPeers();
215
+
216
+ this._showNotification(`Joined room ${code}`, 'success');
217
+ this._broadcastEvent('collab:userJoined', {
218
+ userId: this.state.userId,
219
+ userName,
220
+ role: this.state.role,
221
+ });
222
+
223
+ return {
224
+ joined: true,
225
+ peerCount: this.state.peers.size,
226
+ hostName: 'Host', // Would come from signaling server
227
+ };
228
+ },
229
+
230
+ /**
231
+ * Leave the current collaboration room.
232
+ * Closes all peer connections and notifies other users.
233
+ *
234
+ * @returns {Promise<void>}
235
+ *
236
+ * @example
237
+ * await kernel.exec('collab.leaveRoom');
238
+ */
239
+ async leaveRoom() {
240
+ if (!this.state.connected) {
241
+ return;
242
+ }
243
+
244
+ await this._disconnect();
245
+
246
+ this.state.roomCode = null;
247
+ this.state.connected = false;
248
+ this.state.peers.clear();
249
+
250
+ this._showNotification('Left collaboration room', 'info');
251
+ this._broadcastEvent('collab:roomClosed', {});
252
+ },
253
+
254
+ // ========================================================================
255
+ // PUBLIC API — User Management
256
+ // ========================================================================
257
+
258
+ /**
259
+ * Get list of all connected users (peers + self).
260
+ *
261
+ * @returns {Promise<Array<Object>>} List of { userId, name, role, ping, color }
262
+ *
263
+ * @example
264
+ * const users = await kernel.exec('collab.getUsers');
265
+ * console.log(`${users.length} users in room`);
266
+ */
267
+ async getUsers() {
268
+ const users = Array.from(this.state.peers.values()).map((peer) => ({
269
+ userId: peer.userId,
270
+ name: peer.name,
271
+ role: peer.role,
272
+ ping: peer.ping || 0,
273
+ color: peer.color,
274
+ }));
275
+
276
+ // Add self
277
+ users.unshift({
278
+ userId: this.state.userId,
279
+ name: this.state.userName + ' (You)',
280
+ role: this.state.role,
281
+ ping: 0,
282
+ color: '#4CAF50', // Green for self
283
+ });
284
+
285
+ return users;
286
+ },
287
+
288
+ /**
289
+ * Change a user's role (owner only).
290
+ * Validates that caller is owner before allowing role changes.
291
+ *
292
+ * @param {Object} options
293
+ * @param {string} options.userId Target user ID
294
+ * @param {string} options.role New role ('owner' | 'editor' | 'viewer')
295
+ * @returns {Promise<void>}
296
+ *
297
+ * @example
298
+ * await kernel.exec('collab.setRole', {
299
+ * userId: 'alice-uuid',
300
+ * role: 'viewer'
301
+ * });
302
+ */
303
+ async setRole(options = {}) {
304
+ const { userId, role } = options;
305
+
306
+ if (this.state.role !== 'owner') {
307
+ throw new Error('Only room owner can change roles');
308
+ }
309
+
310
+ if (!['owner', 'editor', 'viewer'].includes(role)) {
311
+ throw new Error('Invalid role: ' + role);
312
+ }
313
+
314
+ const peer = this.state.peers.get(userId);
315
+ if (peer) {
316
+ peer.role = role;
317
+ this._broadcastEvent('collab:roleChanged', { userId, role });
318
+ this._broadcastToPeers('roleChange', { userId, role });
319
+ }
320
+ },
321
+
322
+ // ========================================================================
323
+ // PUBLIC API — Operations and Chat
324
+ // ========================================================================
325
+
326
+ /**
327
+ * Broadcast a geometry operation to all peers.
328
+ * Operation is logged in CRDT log with causality tracking.
329
+ *
330
+ * @param {Object} operation Geometry operation object
331
+ * { type, params, featureId, userId, timestamp, lamportClock }
332
+ * @returns {Promise<void>}
333
+ *
334
+ * @example
335
+ * await kernel.exec('collab.broadcastOperation', {
336
+ * type: 'extrude',
337
+ * params: { distance: 50 },
338
+ * featureId: 'sketch_1'
339
+ * });
340
+ */
341
+ async broadcastOperation(operation = {}) {
342
+ if (!this.state.connected) {
343
+ throw new Error('Not connected to collaboration room');
344
+ }
345
+
346
+ // Enrich operation with metadata
347
+ const enriched = {
348
+ ...operation,
349
+ userId: this.state.userId,
350
+ timestamp: Date.now(),
351
+ lamportClock: ++this.state.lamportClock,
352
+ };
353
+
354
+ // Add to local log
355
+ this.state.operationLog.push(enriched);
356
+
357
+ // Broadcast to all peers
358
+ await this._broadcastToPeers('operation', enriched);
359
+
360
+ this._broadcastEvent('collab:operationSent', enriched);
361
+ },
362
+
363
+ /**
364
+ * Send a chat message to all peers.
365
+ * Message appears as floating text bubble in 3D view.
366
+ *
367
+ * @param {Object} options
368
+ * @param {string} options.text Message text (max 500 chars)
369
+ * @returns {Promise<void>}
370
+ *
371
+ * @example
372
+ * await kernel.exec('collab.sendMessage', {
373
+ * text: 'I just added a hole here!'
374
+ * });
375
+ */
376
+ async sendMessage(options = {}) {
377
+ const { text } = options;
378
+
379
+ if (!text || text.trim().length === 0) {
380
+ return;
381
+ }
382
+
383
+ if (text.length > 500) {
384
+ throw new Error('Message too long (max 500 chars)');
385
+ }
386
+
387
+ const message = {
388
+ userId: this.state.userId,
389
+ userName: this.state.userName,
390
+ text: text.trim(),
391
+ timestamp: Date.now(),
392
+ };
393
+
394
+ // Broadcast to peers
395
+ await this._broadcastToPeers('message', message);
396
+
397
+ // Show in local chat (for sender)
398
+ this._showChatBubble(message);
399
+
400
+ this._broadcastEvent('collab:messageSent', message);
401
+ },
402
+
403
+ // ========================================================================
404
+ // PUBLIC API — Info and Config
405
+ // ========================================================================
406
+
407
+ /**
408
+ * Get current room info.
409
+ *
410
+ * @returns {Promise<Object|null>}
411
+ * { code, role, userName, peerCount, connected, operationCount }
412
+ */
413
+ async getRoomInfo() {
414
+ if (!this.state.connected) {
415
+ return null;
416
+ }
417
+
418
+ return {
419
+ code: this.state.roomCode,
420
+ role: this.state.role,
421
+ userName: this.state.userName,
422
+ userId: this.state.userId,
423
+ peerCount: this.state.peers.size,
424
+ connected: this.state.connected,
425
+ operationCount: this.state.operationLog.length,
426
+ lamportClock: this.state.lamportClock,
427
+ };
428
+ },
429
+
430
+ /**
431
+ * Get operations since a given Lamport clock value.
432
+ * Used for syncing late-joiners with operation history.
433
+ *
434
+ * @param {number} since Lamport clock threshold (default 0)
435
+ * @returns {Promise<Array<Object>>} Operations with clock >= since
436
+ */
437
+ async getOperationsSince(since = 0) {
438
+ return this.state.operationLog.filter((op) => op.lamportClock >= since);
439
+ },
440
+
441
+ // ========================================================================
442
+ // INTERNAL HELPERS — Network and Signaling
443
+ // ========================================================================
444
+
445
+ /**
446
+ * Discover peers by contacting signaling server.
447
+ * In production, this would reach out to a central server.
448
+ * For demo, simulates peer discovery.
449
+ *
450
+ * @private
451
+ * @async
452
+ * @returns {Promise<void>}
453
+ */
454
+ async _discoverPeers() {
455
+ // Simulate discovery with timeout
456
+ return new Promise((resolve) => {
457
+ setTimeout(() => {
458
+ // In production: contact signaling server with room code
459
+ // Server returns list of peer addresses
460
+ // For each: initiate WebRTC connection
461
+ resolve();
462
+ }, 500);
463
+ });
464
+ },
465
+
466
+ /**
467
+ * Broadcast data to all connected peers via WebRTC or fallback.
468
+ *
469
+ * @private
470
+ * @async
471
+ * @param {string} type Message type
472
+ * @param {Object} data Message payload
473
+ * @returns {Promise<void>}
474
+ */
475
+ async _broadcastToPeers(type, data) {
476
+ const message = JSON.stringify({ type, data });
477
+
478
+ for (const [peerId, dc] of this.state.dataChannels) {
479
+ if (dc && dc.readyState === 'open') {
480
+ try {
481
+ dc.send(message);
482
+ } catch (err) {
483
+ console.warn(`Failed to send to peer ${peerId}:`, err.message);
484
+ }
485
+ }
486
+ }
487
+ },
488
+
489
+ /**
490
+ * Handle incoming message from peer.
491
+ *
492
+ * @private
493
+ * @param {Object} message Parsed message object
494
+ */
495
+ _handlePeerMessage(message) {
496
+ const { type, data } = message;
497
+
498
+ switch (type) {
499
+ case 'operation':
500
+ this._mergeRemoteOperation(data);
501
+ break;
502
+ case 'message':
503
+ this._showChatBubble(data);
504
+ this._broadcastEvent('collab:messageReceived', data);
505
+ break;
506
+ case 'cursorMove':
507
+ this._updatePeerCursor(data);
508
+ break;
509
+ case 'roleChange':
510
+ this._handleRoleChange(data);
511
+ break;
512
+ default:
513
+ console.log('[Collaboration] Unknown message type:', type);
514
+ }
515
+ },
516
+
517
+ /**
518
+ * Merge a remote operation using CRDT logic.
519
+ * Resolves conflicts by Lamport clock ordering.
520
+ *
521
+ * @private
522
+ * @param {Object} operation Remote operation
523
+ */
524
+ _mergeRemoteOperation(operation) {
525
+ // Update our Lamport clock to maintain causality
526
+ this.state.lamportClock = Math.max(
527
+ this.state.lamportClock,
528
+ operation.lamportClock
529
+ ) + 1;
530
+
531
+ // Add to log
532
+ this.state.operationLog.push(operation);
533
+
534
+ // CRDT merge: sort by (lamportClock, userId) to get consistent ordering
535
+ this.state.operationLog.sort((a, b) => {
536
+ if (a.lamportClock !== b.lamportClock) {
537
+ return a.lamportClock - b.lamportClock;
538
+ }
539
+ return a.userId.localeCompare(b.userId);
540
+ });
541
+
542
+ // Broadcast event for app to re-sync geometry
543
+ this._broadcastEvent('collab:operationReceived', operation);
544
+ },
545
+
546
+ /**
547
+ * Handle role change notification.
548
+ *
549
+ * @private
550
+ * @param {Object} data { userId, role }
551
+ */
552
+ _handleRoleChange(data) {
553
+ const { userId, role } = data;
554
+ const peer = this.state.peers.get(userId);
555
+ if (peer) {
556
+ peer.role = role;
557
+ }
558
+ },
559
+
560
+ /**
561
+ * Gracefully disconnect from room.
562
+ *
563
+ * @private
564
+ * @async
565
+ * @returns {Promise<void>}
566
+ */
567
+ async _disconnect() {
568
+ // Close all peer connections
569
+ for (const pc of this.state.peerConnections.values()) {
570
+ pc.close();
571
+ }
572
+ this.state.peerConnections.clear();
573
+ this.state.dataChannels.clear();
574
+
575
+ // Close signaling socket if present
576
+ if (this.state.signalingSocket) {
577
+ this.state.signalingSocket.close();
578
+ }
579
+
580
+ // Broadcast goodbye
581
+ await this._broadcastToPeers('userLeft', {
582
+ userId: this.state.userId,
583
+ });
584
+ }
585
+
586
+ // ========================================================================
587
+ // INTERNAL HELPERS — UI and Display
588
+ // ========================================================================
589
+
590
+ /**
591
+ * Show a notification toast at top of screen.
592
+ *
593
+ * @private
594
+ * @param {string} message
595
+ * @param {string} type 'success' | 'error' | 'info' | 'warning'
596
+ */
597
+ _showNotification(message, type = 'info') {
598
+ const toast = document.createElement('div');
599
+ toast.className = `collab-toast collab-toast-${type}`;
600
+ toast.textContent = message;
601
+ document.body.appendChild(toast);
602
+
603
+ setTimeout(() => toast.remove(), 4000);
604
+ },
605
+
606
+ /**
607
+ * Display chat message as floating bubble in 3D view.
608
+ *
609
+ * @private
610
+ * @param {Object} message { userId, userName, text, timestamp }
611
+ */
612
+ _showChatBubble(message) {
613
+ const bubble = document.createElement('div');
614
+ bubble.className = 'collab-chat-bubble';
615
+ bubble.innerHTML = `
616
+ <strong>${message.userName}</strong><br>
617
+ ${message.text}
618
+ `;
619
+
620
+ const peer = this.state.peers.get(message.userId);
621
+ bubble.style.backgroundColor = peer ? peer.color : '#2196F3';
622
+ bubble.style.color = '#fff';
623
+
624
+ document.body.appendChild(bubble);
625
+
626
+ // Fade out and remove after 8s
627
+ setTimeout(() => {
628
+ bubble.style.opacity = '0';
629
+ setTimeout(() => bubble.remove(), 500);
630
+ }, 8000);
631
+ },
632
+
633
+ /**
634
+ * Update a peer's cursor position in 3D space.
635
+ *
636
+ * @private
637
+ * @param {Object} data { userId, x, y, z }
638
+ */
639
+ _updatePeerCursor(data) {
640
+ const { userId, x, y, z } = data;
641
+
642
+ const peer = this.state.peers.get(userId);
643
+ if (peer) {
644
+ peer.cursorPos = { x, y, z };
645
+ }
646
+
647
+ // Trigger re-render of cursor indicators
648
+ this._broadcastEvent('collab:cursorMoved', data);
649
+ },
650
+
651
+ // ========================================================================
652
+ // INTERNAL HELPERS — Event Handlers
653
+ // ========================================================================
654
+
655
+ /**
656
+ * Handle mouse move events to broadcast cursor position.
657
+ * Throttled to 10Hz to reduce network traffic.
658
+ *
659
+ * @private
660
+ * @param {MouseEvent} e
661
+ */
662
+ _onCursorMove(e) {
663
+ if (!this.state.connected) return;
664
+
665
+ // Throttle: only send every 100ms
666
+ if (this.state.lastCursorBroadcast &&
667
+ Date.now() - this.state.lastCursorBroadcast < 100) {
668
+ return;
669
+ }
670
+
671
+ this.state.lastCursorBroadcast = Date.now();
672
+
673
+ // Convert screen coords to 3D (this is app-specific)
674
+ // For now, just broadcast screen position
675
+ const cursorData = {
676
+ userId: this.state.userId,
677
+ screenX: e.clientX,
678
+ screenY: e.clientY,
679
+ timestamp: Date.now(),
680
+ };
681
+
682
+ this._broadcastToPeers('cursorMove', cursorData);
683
+ },
684
+
685
+ /**
686
+ * Handle Enter key in chat input.
687
+ *
688
+ * @private
689
+ * @param {KeyboardEvent} e
690
+ */
691
+ _onChatKeyPress(e) {
692
+ if (e.key === 'Enter' && e.ctrlKey) {
693
+ const chatInput = document.querySelector('.collab-chat-input');
694
+ if (chatInput) {
695
+ const text = chatInput.value.trim();
696
+ if (text) {
697
+ this.sendMessage({ text });
698
+ chatInput.value = '';
699
+ }
700
+ }
701
+ }
702
+ },
703
+
704
+ // ========================================================================
705
+ // INTERNAL HELPERS — Utilities
706
+ // ========================================================================
707
+
708
+ /**
709
+ * Generate a UUID v4.
710
+ *
711
+ * @private
712
+ * @returns {string}
713
+ */
714
+ _generateUUID() {
715
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
716
+ const r = (Math.random() * 16) | 0;
717
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
718
+ return v.toString(16);
719
+ });
720
+ },
721
+
722
+ /**
723
+ * Generate a random 6-character room code.
724
+ * Uses uppercase alphanumeric (no vowels to avoid profanity).
725
+ *
726
+ * @private
727
+ * @returns {string}
728
+ */
729
+ _generateRoomCode() {
730
+ const chars = 'BCDFGHJKLMNPQRSTVWXYZ0123456789';
731
+ let code = '';
732
+ for (let i = 0; i < 6; i++) {
733
+ code += chars[Math.floor(Math.random() * chars.length)];
734
+ }
735
+ return code;
736
+ },
737
+
738
+ /**
739
+ * Broadcast a custom event to the app.
740
+ *
741
+ * @private
742
+ * @param {string} eventName
743
+ * @param {Object} detail
744
+ */
745
+ _broadcastEvent(eventName, detail) {
746
+ const event = new CustomEvent(eventName, { detail });
747
+ document.dispatchEvent(event);
748
+ },
749
+
750
+ // ========================================================================
751
+ // WEBRTC & CRDT ENHANCEMENTS (Fusion 360-parity)
752
+ // ========================================================================
753
+
754
+ /**
755
+ * Initialize WebRTC peer connection with signaling.
756
+ * Establishes data channels for CRDT synchronization.
757
+ * @private
758
+ * @async
759
+ * @param {string} peerId Peer user ID
760
+ * @returns {Promise<RTCPeerConnection>}
761
+ */
762
+ async _initPeerConnection(peerId) {
763
+ const pc = new RTCPeerConnection({
764
+ iceServers: [
765
+ { urls: 'stun:stun.l.google.com:19302' },
766
+ { urls: 'stun:stun1.l.google.com:19302' },
767
+ ],
768
+ });
769
+
770
+ // Create data channel for CRDT ops
771
+ const dc = pc.createDataChannel('crdt', { ordered: true });
772
+ this._setupDataChannelHandlers(dc, peerId);
773
+
774
+ pc.onicecandidate = (e) => {
775
+ if (e.candidate) {
776
+ this._broadcastToPeers('iceCandidate', {
777
+ from: this.state.userId,
778
+ to: peerId,
779
+ candidate: e.candidate,
780
+ });
781
+ }
782
+ };
783
+
784
+ pc.ondatachannel = (e) => {
785
+ this._setupDataChannelHandlers(e.channel, peerId);
786
+ };
787
+
788
+ this.state.peerConnections.set(peerId, pc);
789
+ return pc;
790
+ },
791
+
792
+ /**
793
+ * Setup data channel handlers for CRDT sync.
794
+ * @private
795
+ * @param {RTCDataChannel} dc
796
+ * @param {string} peerId
797
+ */
798
+ _setupDataChannelHandlers(dc, peerId) {
799
+ dc.onopen = () => {
800
+ console.log(`[Collaboration] Data channel open with ${peerId}`);
801
+ this.state.dataChannels.set(peerId, dc);
802
+ };
803
+
804
+ dc.onmessage = (e) => {
805
+ try {
806
+ const message = JSON.parse(e.data);
807
+ this._handlePeerMessage(message);
808
+ } catch (err) {
809
+ console.error('[Collaboration] Failed to parse peer message:', err);
810
+ }
811
+ };
812
+
813
+ dc.onerror = (err) => {
814
+ console.error(`[Collaboration] Data channel error (${peerId}):`, err);
815
+ };
816
+
817
+ dc.onclose = () => {
818
+ console.log(`[Collaboration] Data channel closed with ${peerId}`);
819
+ this.state.dataChannels.delete(peerId);
820
+ };
821
+ },
822
+
823
+ /**
824
+ * Generate CRDT-based operation ID (clock + user UUID).
825
+ * Ensures deterministic ordering without central clock.
826
+ * @private
827
+ * @returns {string}
828
+ */
829
+ _generateOpId() {
830
+ const timestamp = Date.now().toString(36);
831
+ const random = Math.random().toString(36).slice(2);
832
+ const userPrefix = this.state.userId.slice(0, 4);
833
+ return `${timestamp}-${userPrefix}-${random}`;
834
+ },
835
+
836
+ // ========================================================================
837
+ // CURSOR PRESENCE & 3D VISUALIZATION (Fusion 360-parity)
838
+ // ========================================================================
839
+
840
+ /**
841
+ * Render peer cursor as 3D cone in viewport.
842
+ * Updates cursor position each frame.
843
+ * @private
844
+ * @param {Object} cursorData { userId, screenX, screenY, timestamp }
845
+ */
846
+ _renderPeerCursor(cursorData) {
847
+ const { userId, screenX, screenY } = cursorData;
848
+ const peer = this.state.peers.get(userId);
849
+ if (!peer) return;
850
+
851
+ // This would integrate with the 3D viewport
852
+ // In real app: convert screen coords to 3D via raycasting
853
+ const cursorElement = document.getElementById(`cursor-${userId}`);
854
+ if (cursorElement) {
855
+ cursorElement.style.left = screenX + 'px';
856
+ cursorElement.style.top = screenY + 'px';
857
+ }
858
+ },
859
+
860
+ /**
861
+ * Highlight geometry selected by another user.
862
+ * Shows which parts/faces other users have selected (different colors per user).
863
+ * @private
864
+ * @param {Object} selectionData { userId, partId, faceIndex, color }
865
+ */
866
+ _renderRemoteSelection(selectionData) {
867
+ const { userId, partId, color } = selectionData;
868
+ // Integration point with viewport module
869
+ // Would highlight part with semi-transparent overlay in user's color
870
+ this._broadcastEvent('collab:remoteSelectionChanged', selectionData);
871
+ },
872
+
873
+ /**
874
+ * Show "user is typing" indicator in chat.
875
+ * @private
876
+ * @param {string} userId
877
+ */
878
+ _showTypingIndicator(userId) {
879
+ const peer = this.state.peers.get(userId);
880
+ if (!peer) return;
881
+
882
+ const indicator = document.createElement('div');
883
+ indicator.className = 'collab-typing-indicator';
884
+ indicator.innerHTML = `<strong>${peer.name}</strong> is typing...`;
885
+ indicator.style.color = peer.color;
886
+ document.body.appendChild(indicator);
887
+
888
+ setTimeout(() => indicator.remove(), 3000);
889
+ },
890
+
891
+ /**
892
+ * Render comment annotation pinned to 3D geometry.
893
+ * Comments appear as numbered bubbles in the 3D view.
894
+ * @private
895
+ * @param {Object} comment { userId, text, partId, faceIndex, timestamp, resolved }
896
+ */
897
+ _renderGeometryComment(comment) {
898
+ const { userId, text, partId } = comment;
899
+ const peer = this.state.peers.get(userId);
900
+
901
+ const commentBubble = document.createElement('div');
902
+ commentBubble.className = 'collab-geometry-comment';
903
+ commentBubble.innerHTML = `
904
+ <div style="background: ${peer?.color || '#2196F3'}; color: white; padding: 8px 12px; border-radius: 4px; max-width: 200px;">
905
+ <strong>${peer?.name || 'User'}</strong>
906
+ <p style="margin: 4px 0; font-size: 12px;">${text}</p>
907
+ <small>${new Date(comment.timestamp).toLocaleTimeString()}</small>
908
+ </div>
909
+ `;
910
+ document.body.appendChild(commentBubble);
911
+ },
912
+
913
+ // ========================================================================
914
+ // VOICE CHAT & SPATIAL AUDIO (Fusion 360-parity)
915
+ // ========================================================================
916
+
917
+ /**
918
+ * Start voice chat session with peers.
919
+ * Uses WebRTC audio tracks for peer-to-peer audio.
920
+ * @async
921
+ * @returns {Promise<void>}
922
+ */
923
+ async startVoiceChat() {
924
+ try {
925
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
926
+
927
+ for (const [peerId, pc] of this.state.peerConnections) {
928
+ const audioTrack = stream.getAudioTracks()[0];
929
+ if (audioTrack) {
930
+ pc.addTrack(audioTrack, stream);
931
+ }
932
+ }
933
+
934
+ this._showNotification('Voice chat started', 'success');
935
+ this._broadcastEvent('collab:voiceChatStarted', {});
936
+ } catch (err) {
937
+ this._showNotification(`Voice chat failed: ${err.message}`, 'error');
938
+ }
939
+ },
940
+
941
+ /**
942
+ * Stop voice chat and close audio tracks.
943
+ * @async
944
+ * @returns {Promise<void>}
945
+ */
946
+ async stopVoiceChat() {
947
+ for (const pc of this.state.peerConnections.values()) {
948
+ pc.getSenders().forEach(sender => {
949
+ if (sender.track?.kind === 'audio') {
950
+ sender.track.stop();
951
+ }
952
+ });
953
+ }
954
+ this._showNotification('Voice chat stopped', 'info');
955
+ },
956
+
957
+ /**
958
+ * Enable spatial audio — voices come from cursor position in 3D.
959
+ * @param {boolean} enabled
960
+ */
961
+ setSpatialAudio(enabled) {
962
+ if (enabled) {
963
+ this._showNotification('Spatial audio enabled', 'success');
964
+ }
965
+ },
966
+
967
+ // ========================================================================
968
+ // CONFLICT RESOLUTION & MERGE DIALOG (Fusion 360-parity)
969
+ // ========================================================================
970
+
971
+ /**
972
+ * Show visual diff when two users modify same feature.
973
+ * Displays merge/pick-one dialog.
974
+ * @private
975
+ * @param {Object} conflict { feature, userId1, state1, userId2, state2 }
976
+ */
977
+ _showConflictDialog(conflict) {
978
+ const { feature, userId1, userId2, state1, state2 } = conflict;
979
+ const dialog = document.createElement('div');
980
+ dialog.className = 'collab-conflict-dialog';
981
+ dialog.innerHTML = `
982
+ <div style="padding: 16px; background: #fff; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); max-width: 500px;">
983
+ <h3>Merge Conflict</h3>
984
+ <p><strong>${feature}</strong> was modified by two users:</p>
985
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 12px 0;">
986
+ <div style="padding: 8px; background: #f0f0f0; border-radius: 4px; border-left: 3px solid #4CAF50;">
987
+ <strong>${this.state.peers.get(userId1)?.name || 'User 1'}</strong>
988
+ <pre style="margin: 4px 0; font-size: 11px; overflow-x: auto;">${JSON.stringify(state1, null, 2)}</pre>
989
+ </div>
990
+ <div style="padding: 8px; background: #f0f0f0; border-radius: 4px; border-left: 3px solid #2196F3;">
991
+ <strong>${this.state.peers.get(userId2)?.name || 'User 2'}</strong>
992
+ <pre style="margin: 4px 0; font-size: 11px; overflow-x: auto;">${JSON.stringify(state2, null, 2)}</pre>
993
+ </div>
994
+ </div>
995
+ <div style="display: flex; gap: 8px; justify-content: flex-end;">
996
+ <button class="btn btn-secondary" onclick="this.parentElement.parentElement.remove()">Cancel</button>
997
+ <button class="btn btn-primary" onclick="alert('Keep user 1 version')">Keep User 1</button>
998
+ <button class="btn btn-primary" onclick="alert('Keep user 2 version')">Keep User 2</button>
999
+ <button class="btn btn-primary" onclick="alert('Manual merge')">Manual Merge</button>
1000
+ </div>
1001
+ </div>
1002
+ `;
1003
+ document.body.appendChild(dialog);
1004
+ },
1005
+
1006
+ // ========================================================================
1007
+ // OFFLINE QUEUE & SYNC ON RECONNECT (Fusion 360-parity)
1008
+ // ========================================================================
1009
+
1010
+ /**
1011
+ * Queue operations when offline, sync on reconnect.
1012
+ * @private
1013
+ */
1014
+ state_offlineQueue: [],
1015
+
1016
+ /**
1017
+ * Buffer operation while offline.
1018
+ * @private
1019
+ * @param {Object} operation
1020
+ */
1021
+ _queueOfflineOperation(operation) {
1022
+ if (!navigator.onLine) {
1023
+ this.state_offlineQueue.push(operation);
1024
+ console.log(`[Collaboration] Queued operation (offline): ${operation.type}`);
1025
+ }
1026
+ },
1027
+
1028
+ /**
1029
+ * Flush offline queue when reconnected.
1030
+ * @private
1031
+ * @async
1032
+ */
1033
+ async _flushOfflineQueue() {
1034
+ while (this.state_offlineQueue.length > 0) {
1035
+ const op = this.state_offlineQueue.shift();
1036
+ try {
1037
+ await this.broadcastOperation(op);
1038
+ } catch (err) {
1039
+ console.error('[Collaboration] Failed to sync queued operation:', err);
1040
+ this.state_offlineQueue.unshift(op); // Re-queue if failed
1041
+ break;
1042
+ }
1043
+ }
1044
+ if (this.state_offlineQueue.length === 0) {
1045
+ this._showNotification('Offline changes synced', 'success');
1046
+ }
1047
+ },
1048
+
1049
+ /**
1050
+ * Listen for online/offline events.
1051
+ * @private
1052
+ */
1053
+ _setupOfflineSync() {
1054
+ window.addEventListener('online', () => {
1055
+ this._flushOfflineQueue();
1056
+ });
1057
+ },
1058
+
1059
+ // ========================================================================
1060
+ // SHARE LINKS WITH PERMISSIONS (Fusion 360-parity)
1061
+ // ========================================================================
1062
+
1063
+ /**
1064
+ * Generate a share link with read-only or edit access.
1065
+ * Optional expiry and password protection.
1066
+ * @async
1067
+ * @param {Object} options
1068
+ * @param {string} [options.access='view'] 'view' | 'edit'
1069
+ * @param {number} [options.expiryDays] Days until link expires
1070
+ * @param {string} [options.password] Optional password protection
1071
+ * @returns {Promise<string>} Share link
1072
+ */
1073
+ async generateShareLink(options = {}) {
1074
+ const { access = 'view', expiryDays, password } = options;
1075
+
1076
+ const token = this._generateUUID();
1077
+ const link = {
1078
+ token,
1079
+ roomCode: this.state.roomCode,
1080
+ access,
1081
+ createdAt: Date.now(),
1082
+ expiresAt: expiryDays ? Date.now() + expiryDays * 86400000 : null,
1083
+ password: password ? this._hashPassword(password) : null,
1084
+ };
1085
+
1086
+ // Store in localStorage (in real app: backend)
1087
+ const links = JSON.parse(localStorage.getItem('collab_shareLinks') || '[]');
1088
+ links.push(link);
1089
+ localStorage.setItem('collab_shareLinks', JSON.stringify(links));
1090
+
1091
+ const shareUrl = `${window.location.origin}?collab=${link.token}`;
1092
+ this._showNotification(`Share link copied`, 'success');
1093
+ return shareUrl;
1094
+ },
1095
+
1096
+ /**
1097
+ * Hash password for share link (basic, for demo only).
1098
+ * @private
1099
+ * @param {string} password
1100
+ * @returns {string}
1101
+ */
1102
+ _hashPassword(password) {
1103
+ return btoa(password); // Not real hashing, for demo
1104
+ },
1105
+
1106
+ /**
1107
+ * Join via share link with access validation.
1108
+ * @async
1109
+ * @param {string} token Share link token
1110
+ * @param {string} [password] Password if protected
1111
+ * @returns {Promise<boolean>}
1112
+ */
1113
+ async joinViaShareLink(token, password = null) {
1114
+ const links = JSON.parse(localStorage.getItem('collab_shareLinks') || '[]');
1115
+ const link = links.find(l => l.token === token);
1116
+
1117
+ if (!link) {
1118
+ throw new Error('Invalid share link');
1119
+ }
1120
+
1121
+ if (link.expiresAt && Date.now() > link.expiresAt) {
1122
+ throw new Error('Share link expired');
1123
+ }
1124
+
1125
+ if (link.password && this._hashPassword(password) !== link.password) {
1126
+ throw new Error('Incorrect password');
1127
+ }
1128
+
1129
+ // Join room with link's access level
1130
+ return this.joinRoom({
1131
+ code: link.roomCode,
1132
+ userName: 'Guest',
1133
+ });
1134
+ },
1135
+
1136
+ // ========================================================================
1137
+ // ACTIVITY FEED & NOTIFICATIONS (Fusion 360-parity)
1138
+ // ========================================================================
1139
+
1140
+ /**
1141
+ * Get activity timeline of all user actions.
1142
+ * @async
1143
+ * @param {Object} options
1144
+ * @param {number} [options.limit=50] Max entries to return
1145
+ * @returns {Promise<Array<Object>>}
1146
+ */
1147
+ async getActivityFeed(options = {}) {
1148
+ const { limit = 50 } = options;
1149
+
1150
+ const activities = [];
1151
+ for (const op of this.state.operationLog.slice(0, limit)) {
1152
+ const peer = this.state.peers.get(op.userId);
1153
+ activities.push({
1154
+ timestamp: op.timestamp,
1155
+ user: peer?.name || 'Unknown',
1156
+ action: op.type,
1157
+ details: op.params,
1158
+ lamportClock: op.lamportClock,
1159
+ });
1160
+ }
1161
+ return activities;
1162
+ },
1163
+
1164
+ /**
1165
+ * Send real-time notification when user joins/leaves/changes.
1166
+ * @private
1167
+ * @param {string} type 'join' | 'leave' | 'change'
1168
+ * @param {Object} userData
1169
+ */
1170
+ _notifyUserEvent(type, userData) {
1171
+ let message = '';
1172
+ switch (type) {
1173
+ case 'join':
1174
+ message = `${userData.name} joined the room`;
1175
+ break;
1176
+ case 'leave':
1177
+ message = `${userData.name} left the room`;
1178
+ break;
1179
+ case 'change':
1180
+ message = `${userData.name} is modeling`;
1181
+ break;
1182
+ }
1183
+ this._showNotification(message, 'info');
1184
+ },
1185
+
1186
+ // ========================================================================
1187
+ // FOLLOW MODE (Fusion 360-parity)
1188
+ // ========================================================================
1189
+
1190
+ /**
1191
+ * Follow another user's camera view.
1192
+ * Your viewport rotates/zooms to match their camera.
1193
+ * @async
1194
+ * @param {string} userId User to follow (null = stop following)
1195
+ * @returns {Promise<void>}
1196
+ */
1197
+ async followUser(userId) {
1198
+ if (userId) {
1199
+ this._showNotification(`Following ${this.state.peers.get(userId)?.name}`, 'info');
1200
+ } else {
1201
+ this._showNotification('Stopped following', 'info');
1202
+ }
1203
+ this.state.followingUserId = userId;
1204
+ this._broadcastEvent('collab:followingChanged', { userId });
1205
+ },
1206
+
1207
+ /**
1208
+ * Broadcast camera view to all followers.
1209
+ * Called every frame to sync camera position/rotation.
1210
+ * @private
1211
+ * @param {Object} cameraState { position, rotation, fov }
1212
+ */
1213
+ _broadcastCameraView(cameraState) {
1214
+ this._broadcastToPeers('cameraView', {
1215
+ userId: this.state.userId,
1216
+ camera: cameraState,
1217
+ timestamp: Date.now(),
1218
+ });
1219
+ },
1220
+
1221
+ // ========================================================================
1222
+ // HELP SYSTEM INTEGRATION
1223
+ // ========================================================================
1224
+
1225
+ helpEntries: [
1226
+ {
1227
+ title: 'Create a Collaboration Room',
1228
+ description:
1229
+ 'Click Collaborate → Create Room. Share the 6-character code with teammates. You\'ll see their cursors and all their edits in real-time.',
1230
+ category: 'Collaboration',
1231
+ shortcut: 'Ctrl+Shift+C',
1232
+ },
1233
+ {
1234
+ title: 'Join a Collaboration Room',
1235
+ description:
1236
+ 'Enter the room code shared by the host. You can immediately see their 3D model and cursor position.',
1237
+ category: 'Collaboration',
1238
+ shortcut: 'Ctrl+Shift+J',
1239
+ },
1240
+ {
1241
+ title: 'Change User Roles',
1242
+ description:
1243
+ 'Room owner can set roles: Owner (full), Editor (can model), Viewer (read-only). Right-click user in panel to change role.',
1244
+ category: 'Collaboration',
1245
+ shortcut: null,
1246
+ },
1247
+ {
1248
+ title: 'Send a Chat Message',
1249
+ description:
1250
+ 'Press Ctrl+Enter to open chat. Type your message and press Enter. Messages appear as floating bubbles in the 3D view for 8 seconds.',
1251
+ category: 'Collaboration',
1252
+ shortcut: 'Ctrl+Enter',
1253
+ },
1254
+ {
1255
+ title: 'View Connected Users',
1256
+ description:
1257
+ 'Open the Collaborate panel to see all connected users, their roles, and network latency (ping).',
1258
+ category: 'Collaboration',
1259
+ shortcut: null,
1260
+ },
1261
+ {
1262
+ title: 'Leave a Room',
1263
+ description:
1264
+ 'Click Collaborate → Leave Room. Your peers are notified and no longer see your cursor.',
1265
+ category: 'Collaboration',
1266
+ shortcut: null,
1267
+ },
1268
+ {
1269
+ title: 'Voice Chat',
1270
+ description:
1271
+ 'Enable voice communication with room members. Audio tracks automatically flow between peers via WebRTC. Optional spatial audio makes voices come from cursor position.',
1272
+ category: 'Collaboration',
1273
+ shortcut: 'Ctrl+Alt+V',
1274
+ },
1275
+ {
1276
+ title: 'Comments on Geometry',
1277
+ description:
1278
+ 'Right-click a part or face → Add Comment. Comments appear as numbered bubbles pinned to geometry. Supports threaded replies and resolve/unresolve status.',
1279
+ category: 'Collaboration',
1280
+ shortcut: null,
1281
+ },
1282
+ {
1283
+ title: 'Generate Share Link',
1284
+ description:
1285
+ 'Share your room with a public link. Control access (view-only or edit), optional password protection, and link expiry. Share links appear in Collaborate panel.',
1286
+ category: 'Collaboration',
1287
+ shortcut: null,
1288
+ },
1289
+ {
1290
+ title: 'Activity Feed',
1291
+ description:
1292
+ 'View timeline of all changes in the room. Shows who did what, when, and the exact parameters they used. Useful for understanding what changed while you were away.',
1293
+ category: 'Collaboration',
1294
+ shortcut: null,
1295
+ },
1296
+ {
1297
+ title: 'Follow User',
1298
+ description:
1299
+ 'Click a user\'s avatar in the panel to follow their camera. Your viewport syncs with theirs in real-time. Click again to stop following.',
1300
+ category: 'Collaboration',
1301
+ shortcut: null,
1302
+ },
1303
+ {
1304
+ title: 'Conflict Resolution',
1305
+ description:
1306
+ 'If two users modify the same feature simultaneously, a visual diff dialog appears. Choose whose version to keep or manually merge.',
1307
+ category: 'Collaboration',
1308
+ shortcut: null,
1309
+ },
1310
+ ],
1311
+
1312
+ // ========================================================================
1313
+ // UI PANEL — HTML and Styling
1314
+ // ========================================================================
1315
+
1316
+ /**
1317
+ * Get the HTML for the collaboration panel.
1318
+ * Displays room info, user list, and chat box.
1319
+ *
1320
+ * @returns {string} HTML markup
1321
+ */
1322
+ getUI() {
1323
+ return `
1324
+ <div class="collab-panel" id="collab-panel">
1325
+ <div class="collab-header">
1326
+ <h3>Collaboration</h3>
1327
+ <button class="collab-close-btn" data-close-panel="collab-panel">×</button>
1328
+ </div>
1329
+
1330
+ <div class="collab-content">
1331
+ <!-- Room Info Section -->
1332
+ <div class="collab-section" id="collab-room-info">
1333
+ <div class="collab-status disconnected">
1334
+ <span class="collab-dot"></span>
1335
+ Not connected
1336
+ </div>
1337
+ <button id="collab-create-btn" class="collab-button collab-button-primary">
1338
+ Create Room
1339
+ </button>
1340
+ <button id="collab-join-btn" class="collab-button">
1341
+ Join Room
1342
+ </button>
1343
+ </div>
1344
+
1345
+ <!-- User List Section -->
1346
+ <div class="collab-section" id="collab-user-section" style="display: none;">
1347
+ <h4>Users in Room</h4>
1348
+ <ul id="collab-user-list" class="collab-user-list"></ul>
1349
+ </div>
1350
+
1351
+ <!-- Chat Section -->
1352
+ <div class="collab-section" id="collab-chat-section" style="display: none;">
1353
+ <h4>Chat (Ctrl+Enter)</h4>
1354
+ <div id="collab-chat-history" class="collab-chat-history"></div>
1355
+ <input
1356
+ type="text"
1357
+ id="collab-chat-input"
1358
+ class="collab-chat-input"
1359
+ placeholder="Type message..."
1360
+ />
1361
+ </div>
1362
+ </div>
1363
+ </div>
1364
+
1365
+ <style>
1366
+ .collab-panel {
1367
+ position: fixed;
1368
+ right: 0;
1369
+ top: 80px;
1370
+ width: 320px;
1371
+ height: 600px;
1372
+ background: #1e1e1e;
1373
+ border-left: 1px solid #333;
1374
+ border-radius: 0;
1375
+ box-shadow: -2px 2px 8px rgba(0, 0, 0, 0.3);
1376
+ display: flex;
1377
+ flex-direction: column;
1378
+ z-index: 1000;
1379
+ }
1380
+
1381
+ .collab-header {
1382
+ display: flex;
1383
+ justify-content: space-between;
1384
+ align-items: center;
1385
+ padding: 12px;
1386
+ border-bottom: 1px solid #333;
1387
+ }
1388
+
1389
+ .collab-header h3 {
1390
+ margin: 0;
1391
+ color: #e0e0e0;
1392
+ font-size: 14px;
1393
+ font-weight: 600;
1394
+ }
1395
+
1396
+ .collab-close-btn {
1397
+ background: none;
1398
+ border: none;
1399
+ color: #999;
1400
+ font-size: 20px;
1401
+ cursor: pointer;
1402
+ padding: 0;
1403
+ width: 24px;
1404
+ height: 24px;
1405
+ }
1406
+
1407
+ .collab-close-btn:hover {
1408
+ color: #e0e0e0;
1409
+ }
1410
+
1411
+ .collab-content {
1412
+ flex: 1;
1413
+ overflow-y: auto;
1414
+ padding: 12px;
1415
+ }
1416
+
1417
+ .collab-section {
1418
+ margin-bottom: 16px;
1419
+ }
1420
+
1421
+ .collab-section h4 {
1422
+ margin: 0 0 8px 0;
1423
+ color: #999;
1424
+ font-size: 11px;
1425
+ text-transform: uppercase;
1426
+ letter-spacing: 0.5px;
1427
+ }
1428
+
1429
+ .collab-status {
1430
+ display: flex;
1431
+ align-items: center;
1432
+ gap: 8px;
1433
+ padding: 8px;
1434
+ border-radius: 4px;
1435
+ font-size: 12px;
1436
+ margin-bottom: 8px;
1437
+ }
1438
+
1439
+ .collab-status.connected {
1440
+ background: #1b5e20;
1441
+ color: #81c784;
1442
+ }
1443
+
1444
+ .collab-status.disconnected {
1445
+ background: #33333;
1446
+ color: #999;
1447
+ }
1448
+
1449
+ .collab-dot {
1450
+ width: 6px;
1451
+ height: 6px;
1452
+ border-radius: 50%;
1453
+ display: inline-block;
1454
+ }
1455
+
1456
+ .collab-status.connected .collab-dot {
1457
+ background: #81c784;
1458
+ animation: pulse 2s infinite;
1459
+ }
1460
+
1461
+ @keyframes pulse {
1462
+ 0%, 100% { opacity: 1; }
1463
+ 50% { opacity: 0.5; }
1464
+ }
1465
+
1466
+ .collab-button {
1467
+ width: 100%;
1468
+ padding: 8px;
1469
+ margin-bottom: 6px;
1470
+ border: none;
1471
+ border-radius: 4px;
1472
+ background: #333;
1473
+ color: #e0e0e0;
1474
+ font-size: 12px;
1475
+ cursor: pointer;
1476
+ transition: background 0.2s;
1477
+ }
1478
+
1479
+ .collab-button:hover {
1480
+ background: #444;
1481
+ }
1482
+
1483
+ .collab-button-primary {
1484
+ background: #0284C7;
1485
+ color: white;
1486
+ }
1487
+
1488
+ .collab-button-primary:hover {
1489
+ background: #0369a1;
1490
+ }
1491
+
1492
+ .collab-user-list {
1493
+ list-style: none;
1494
+ padding: 0;
1495
+ margin: 0;
1496
+ }
1497
+
1498
+ .collab-user-item {
1499
+ padding: 8px;
1500
+ border-radius: 4px;
1501
+ background: #2a2a2a;
1502
+ margin-bottom: 6px;
1503
+ display: flex;
1504
+ align-items: center;
1505
+ gap: 8px;
1506
+ font-size: 12px;
1507
+ color: #e0e0e0;
1508
+ }
1509
+
1510
+ .collab-user-color {
1511
+ width: 12px;
1512
+ height: 12px;
1513
+ border-radius: 2px;
1514
+ flex-shrink: 0;
1515
+ }
1516
+
1517
+ .collab-user-name {
1518
+ flex: 1;
1519
+ overflow: hidden;
1520
+ text-overflow: ellipsis;
1521
+ white-space: nowrap;
1522
+ }
1523
+
1524
+ .collab-user-role {
1525
+ font-size: 10px;
1526
+ background: #444;
1527
+ padding: 2px 6px;
1528
+ border-radius: 2px;
1529
+ text-transform: uppercase;
1530
+ }
1531
+
1532
+ .collab-chat-history {
1533
+ background: #2a2a2a;
1534
+ border-radius: 4px;
1535
+ padding: 8px;
1536
+ height: 150px;
1537
+ overflow-y: auto;
1538
+ margin-bottom: 8px;
1539
+ font-size: 11px;
1540
+ }
1541
+
1542
+ .collab-chat-message {
1543
+ margin-bottom: 6px;
1544
+ padding: 4px;
1545
+ border-left: 2px solid #0284C7;
1546
+ padding-left: 6px;
1547
+ }
1548
+
1549
+ .collab-chat-name {
1550
+ font-weight: 600;
1551
+ color: #0284C7;
1552
+ font-size: 10px;
1553
+ }
1554
+
1555
+ .collab-chat-text {
1556
+ color: #e0e0e0;
1557
+ word-wrap: break-word;
1558
+ }
1559
+
1560
+ .collab-chat-input {
1561
+ width: 100%;
1562
+ padding: 8px;
1563
+ border: 1px solid #444;
1564
+ border-radius: 4px;
1565
+ background: #2a2a2a;
1566
+ color: #e0e0e0;
1567
+ font-size: 12px;
1568
+ box-sizing: border-box;
1569
+ }
1570
+
1571
+ .collab-chat-input:focus {
1572
+ outline: none;
1573
+ border-color: #0284C7;
1574
+ }
1575
+
1576
+ .collab-toast {
1577
+ position: fixed;
1578
+ bottom: 20px;
1579
+ left: 20px;
1580
+ padding: 12px 16px;
1581
+ border-radius: 4px;
1582
+ font-size: 12px;
1583
+ animation: slideIn 0.3s ease;
1584
+ z-index: 10000;
1585
+ }
1586
+
1587
+ @keyframes slideIn {
1588
+ from {
1589
+ transform: translateX(-100%);
1590
+ opacity: 0;
1591
+ }
1592
+ to {
1593
+ transform: translateX(0);
1594
+ opacity: 1;
1595
+ }
1596
+ }
1597
+
1598
+ .collab-toast-success {
1599
+ background: #1b5e20;
1600
+ color: #81c784;
1601
+ }
1602
+
1603
+ .collab-toast-error {
1604
+ background: #b71c1c;
1605
+ color: #ff5252;
1606
+ }
1607
+
1608
+ .collab-toast-info {
1609
+ background: #01579b;
1610
+ color: #81d4fa;
1611
+ }
1612
+ </style>
1613
+ `;
1614
+ },
1615
+ };