cyclecad 2.1.0 → 3.1.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.
- package/BILLING-IMPLEMENTATION-SUMMARY.md +425 -0
- package/BILLING-INDEX.md +293 -0
- package/BILLING-INTEGRATION-GUIDE.md +414 -0
- package/COLLABORATION-INDEX.md +440 -0
- package/COLLABORATION-SYSTEM-SUMMARY.md +548 -0
- package/DELIVERABLES.txt +296 -445
- package/DOCKER-BUILD-MANIFEST.txt +483 -0
- package/DOCKER-FILES-REFERENCE.md +440 -0
- package/DOCKER-INFRASTRUCTURE.md +475 -0
- package/DOCKER-README.md +435 -0
- package/Dockerfile +33 -55
- package/ENHANCEMENT_COMPLETION_REPORT.md +383 -0
- package/ENHANCEMENT_SUMMARY.txt +308 -0
- package/FEATURE_INVENTORY.md +235 -0
- package/FUSION360_FEATURES_SUMMARY.md +452 -0
- package/FUSION360_PARITY_ENHANCEMENTS.md +461 -0
- package/FUSION360_PARITY_SUMMARY.md +520 -0
- package/FUSION360_QUICK_REFERENCE.md +351 -0
- package/MODULE_API_REFERENCE.md +712 -0
- package/MODULE_INVENTORY.txt +264 -0
- package/PWA-FILES-CREATED.txt +350 -0
- package/QUICK-START-TESTING.md +126 -0
- package/STEP-IMPORT-QUICKSTART.md +347 -0
- package/STEP-IMPORT-SYSTEM-SUMMARY.md +502 -0
- package/app/css/mobile.css +1074 -0
- package/app/icons/generate-icons.js +203 -0
- package/app/index.html +1342 -5031
- package/app/js/app.js +1312 -514
- package/app/js/billing-ui.js +990 -0
- package/app/js/brep-kernel.js +933 -981
- package/app/js/collab-client.js +750 -0
- package/app/js/mobile-nav.js +623 -0
- package/app/js/mobile-toolbar.js +476 -0
- package/app/js/modules/animation-module.js +497 -3
- package/app/js/modules/billing-module.js +724 -0
- package/app/js/modules/cam-module.js +507 -2
- package/app/js/modules/collaboration-module.js +513 -0
- package/app/js/modules/constraint-module.js +1266 -0
- package/app/js/modules/data-module.js +544 -1146
- package/app/js/modules/formats-module.js +438 -738
- package/app/js/modules/inspection-module.js +393 -0
- package/app/js/modules/mesh-module-enhanced.js +880 -0
- package/app/js/modules/plugin-module.js +597 -0
- package/app/js/modules/rendering-module.js +460 -0
- package/app/js/modules/scripting-module.js +593 -475
- package/app/js/modules/sketch-module.js +998 -2
- package/app/js/modules/step-module-enhanced.js +938 -0
- package/app/js/modules/surface-module.js +312 -0
- package/app/js/modules/version-module.js +420 -0
- package/app/js/offline-manager.js +705 -0
- package/app/js/responsive-init.js +360 -0
- package/app/js/touch-handler.js +429 -0
- package/app/manifest.json +211 -0
- package/app/offline.html +508 -0
- package/app/sw.js +571 -0
- package/app/tests/billing-tests.html +779 -0
- package/app/tests/brep-tests.html +980 -0
- package/app/tests/collab-tests.html +743 -0
- package/app/tests/mobile-tests.html +1299 -0
- package/app/tests/pwa-tests.html +1134 -0
- package/app/tests/step-tests.html +1042 -0
- package/app/tests/test-agent-v3.html +719 -0
- package/cycleCAD-Architecture-v2.pptx +0 -0
- package/docker-compose.yml +225 -0
- package/docs/BILLING-HELP.json +260 -0
- package/docs/BILLING-README.md +639 -0
- package/docs/BILLING-TUTORIAL.md +736 -0
- package/docs/BREP-HELP.json +326 -0
- package/docs/BREP-TUTORIAL.md +802 -0
- package/docs/COLLABORATION-HELP.json +228 -0
- package/docs/COLLABORATION-TUTORIAL.md +818 -0
- package/docs/DOCKER-HELP.json +224 -0
- package/docs/DOCKER-TUTORIAL.md +974 -0
- package/docs/MOBILE-HELP.json +243 -0
- package/docs/MOBILE-RESPONSIVE-README.md +378 -0
- package/docs/MOBILE-TUTORIAL.md +747 -0
- package/docs/PWA-HELP.json +228 -0
- package/docs/PWA-README.md +662 -0
- package/docs/PWA-TUTORIAL.md +757 -0
- package/docs/STEP-HELP.json +481 -0
- package/docs/STEP-IMPORT-TUTORIAL.md +824 -0
- package/docs/TESTING-GUIDE.md +528 -0
- package/docs/TESTING-HELP.json +182 -0
- package/fusion-vs-cyclecad.html +1771 -0
- package/nginx.conf +237 -0
- package/package.json +1 -1
- package/server/Dockerfile.converter +51 -0
- package/server/Dockerfile.signaling +28 -0
- package/server/billing-server.js +487 -0
- package/server/converter-enhanced.py +528 -0
- package/server/requirements-converter.txt +29 -0
- package/server/signaling-server.js +801 -0
- package/tests/docker-tests.sh +389 -0
- package/~$cycleCAD-Architecture-v2.pptx +0 -0
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cycleCAD Collaboration Client
|
|
3
|
+
* Browser-side WebSocket client for real-time multi-user collaboration
|
|
4
|
+
*
|
|
5
|
+
* Features:
|
|
6
|
+
* - WebSocket connection with auto-reconnect (exponential backoff)
|
|
7
|
+
* - Room management (create, join, leave)
|
|
8
|
+
* - WebRTC peer connection setup (offer/answer/ICE)
|
|
9
|
+
* - CRDT document sync (operation log)
|
|
10
|
+
* - Cursor sharing (throttled position updates)
|
|
11
|
+
* - Selection sharing (synchronized part selection)
|
|
12
|
+
* - Chat messages with timestamps
|
|
13
|
+
* - User list with avatars/colors
|
|
14
|
+
* - Conflict resolution (CRDT for geometry, LWW for properties)
|
|
15
|
+
* - Offline operation queue (syncs on reconnect)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
class CollaborationClient {
|
|
19
|
+
constructor(signalServerUrl = 'ws://localhost:8788') {
|
|
20
|
+
this.signalServerUrl = signalServerUrl;
|
|
21
|
+
this.ws = null;
|
|
22
|
+
this.clientId = null;
|
|
23
|
+
this.userId = null;
|
|
24
|
+
this.roomId = null;
|
|
25
|
+
this.isConnected = false;
|
|
26
|
+
this.isReconnecting = false;
|
|
27
|
+
this.reconnectAttempts = 0;
|
|
28
|
+
this.maxReconnectAttempts = 10;
|
|
29
|
+
this.reconnectDelay = 1000;
|
|
30
|
+
|
|
31
|
+
// User management
|
|
32
|
+
this.users = new Map(); // userId -> User object
|
|
33
|
+
this.localUser = null;
|
|
34
|
+
|
|
35
|
+
// CRDT & operations
|
|
36
|
+
this.operations = []; // local operation log
|
|
37
|
+
this.operationIndex = 0;
|
|
38
|
+
this.offlineQueue = []; // operations queued while offline
|
|
39
|
+
|
|
40
|
+
// WebRTC
|
|
41
|
+
this.peers = new Map(); // userId -> RTCPeerConnection
|
|
42
|
+
this.dataChannels = new Map(); // userId -> RTCDataChannel
|
|
43
|
+
this.iceServers = [
|
|
44
|
+
{ urls: 'stun:stun.l.google.com:19302' },
|
|
45
|
+
{ urls: 'stun:stun1.l.google.com:19302' }
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// Cursor sharing (throttled)
|
|
49
|
+
this.cursorUpdateInterval = 100; // ms
|
|
50
|
+
this.lastCursorUpdate = 0;
|
|
51
|
+
this.pendingCursorUpdate = null;
|
|
52
|
+
|
|
53
|
+
// Selection sharing
|
|
54
|
+
this.localSelection = [];
|
|
55
|
+
this.remoteSelections = new Map(); // userId -> [partIds]
|
|
56
|
+
|
|
57
|
+
// Chat & presence
|
|
58
|
+
this.chatMessages = [];
|
|
59
|
+
this.maxChatHistory = 50;
|
|
60
|
+
this.userPresence = new Map(); // userId -> { status, lastSeen }
|
|
61
|
+
|
|
62
|
+
// Callbacks
|
|
63
|
+
this.callbacks = {
|
|
64
|
+
onConnected: () => {},
|
|
65
|
+
onDisconnected: () => {},
|
|
66
|
+
onUserJoined: () => {},
|
|
67
|
+
onUserLeft: () => {},
|
|
68
|
+
onOperationReceived: () => {},
|
|
69
|
+
onChatMessage: () => {},
|
|
70
|
+
onCursorUpdate: () => {},
|
|
71
|
+
onSelectionUpdate: () => {},
|
|
72
|
+
onError: () => {}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
this.init();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
init() {
|
|
79
|
+
this.connect();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ========== Connection Management ==========
|
|
83
|
+
|
|
84
|
+
connect() {
|
|
85
|
+
if (this.isConnected || this.isReconnecting) return;
|
|
86
|
+
|
|
87
|
+
console.log(`[CollabClient] Connecting to ${this.signalServerUrl}...`);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
this.ws = new WebSocket(this.signalServerUrl);
|
|
91
|
+
|
|
92
|
+
this.ws.onopen = () => this.onConnectionOpen();
|
|
93
|
+
this.ws.onmessage = (event) => this.onMessage(event.data);
|
|
94
|
+
this.ws.onclose = () => this.onConnectionClose();
|
|
95
|
+
this.ws.onerror = (error) => this.onConnectionError(error);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.error('[CollabClient] Connection error:', error);
|
|
98
|
+
this.scheduleReconnect();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
onConnectionOpen() {
|
|
103
|
+
console.log('[CollabClient] Connected to signaling server');
|
|
104
|
+
this.isConnected = true;
|
|
105
|
+
this.isReconnecting = false;
|
|
106
|
+
this.reconnectAttempts = 0;
|
|
107
|
+
this.reconnectDelay = 1000;
|
|
108
|
+
|
|
109
|
+
this.callbacks.onConnected();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
onConnectionClose() {
|
|
113
|
+
console.log('[CollabClient] Disconnected from signaling server');
|
|
114
|
+
this.isConnected = false;
|
|
115
|
+
this.callbacks.onDisconnected();
|
|
116
|
+
|
|
117
|
+
this.scheduleReconnect();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
onConnectionError(error) {
|
|
121
|
+
console.error('[CollabClient] Connection error:', error);
|
|
122
|
+
this.callbacks.onError(error);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
scheduleReconnect() {
|
|
126
|
+
if (this.isReconnecting || this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
127
|
+
console.error('[CollabClient] Max reconnect attempts reached');
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
this.isReconnecting = true;
|
|
132
|
+
const delay = Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), 30000);
|
|
133
|
+
console.log(`[CollabClient] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1})`);
|
|
134
|
+
|
|
135
|
+
setTimeout(() => {
|
|
136
|
+
this.reconnectAttempts++;
|
|
137
|
+
this.isReconnecting = false;
|
|
138
|
+
this.connect();
|
|
139
|
+
}, delay);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ========== Message Handling ==========
|
|
143
|
+
|
|
144
|
+
onMessage(data) {
|
|
145
|
+
try {
|
|
146
|
+
const message = JSON.parse(data);
|
|
147
|
+
this.handleMessage(message);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
console.error('[CollabClient] Parse error:', error);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
handleMessage(message) {
|
|
154
|
+
const { type, payload } = message;
|
|
155
|
+
|
|
156
|
+
switch (type) {
|
|
157
|
+
case 'welcome':
|
|
158
|
+
this.clientId = message.clientId;
|
|
159
|
+
console.log(`[CollabClient] Welcome! Client ID: ${this.clientId}`);
|
|
160
|
+
break;
|
|
161
|
+
|
|
162
|
+
case 'room-created':
|
|
163
|
+
console.log('[CollabClient] Room created:', message.roomId);
|
|
164
|
+
break;
|
|
165
|
+
|
|
166
|
+
case 'room-joined':
|
|
167
|
+
this.onRoomJoined(message);
|
|
168
|
+
break;
|
|
169
|
+
|
|
170
|
+
case 'room-left':
|
|
171
|
+
this.onRoomLeft();
|
|
172
|
+
break;
|
|
173
|
+
|
|
174
|
+
case 'user-joined':
|
|
175
|
+
this.onUserJoined(message);
|
|
176
|
+
break;
|
|
177
|
+
|
|
178
|
+
case 'user-left':
|
|
179
|
+
this.onUserLeft(message);
|
|
180
|
+
break;
|
|
181
|
+
|
|
182
|
+
case 'user-status':
|
|
183
|
+
this.onUserStatus(message);
|
|
184
|
+
break;
|
|
185
|
+
|
|
186
|
+
case 'cursor-update':
|
|
187
|
+
this.onCursorUpdate(message);
|
|
188
|
+
break;
|
|
189
|
+
|
|
190
|
+
case 'selection-update':
|
|
191
|
+
this.onSelectionUpdate(message);
|
|
192
|
+
break;
|
|
193
|
+
|
|
194
|
+
case 'operation':
|
|
195
|
+
this.onOperationReceived(message);
|
|
196
|
+
break;
|
|
197
|
+
|
|
198
|
+
case 'chat-message':
|
|
199
|
+
this.onChatMessage(message);
|
|
200
|
+
break;
|
|
201
|
+
|
|
202
|
+
case 'signaling-offer':
|
|
203
|
+
this.onSignalingOffer(message);
|
|
204
|
+
break;
|
|
205
|
+
|
|
206
|
+
case 'signaling-answer':
|
|
207
|
+
this.onSignalingAnswer(message);
|
|
208
|
+
break;
|
|
209
|
+
|
|
210
|
+
case 'ice-candidate':
|
|
211
|
+
this.onIceCandidate(message);
|
|
212
|
+
break;
|
|
213
|
+
|
|
214
|
+
case 'room-reset':
|
|
215
|
+
this.onRoomReset();
|
|
216
|
+
break;
|
|
217
|
+
|
|
218
|
+
case 'room-closed':
|
|
219
|
+
this.onRoomClosed();
|
|
220
|
+
break;
|
|
221
|
+
|
|
222
|
+
case 'error':
|
|
223
|
+
console.error('[CollabClient] Server error:', message.message);
|
|
224
|
+
this.callbacks.onError(new Error(message.message));
|
|
225
|
+
break;
|
|
226
|
+
|
|
227
|
+
default:
|
|
228
|
+
console.warn('[CollabClient] Unknown message type:', type);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ========== Room Management ==========
|
|
233
|
+
|
|
234
|
+
createRoom(roomId, options = {}) {
|
|
235
|
+
if (!this.isConnected) {
|
|
236
|
+
throw new Error('Not connected to signaling server');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
this.send({
|
|
240
|
+
type: 'create-room',
|
|
241
|
+
payload: {
|
|
242
|
+
roomId,
|
|
243
|
+
name: options.name || `Room ${roomId}`,
|
|
244
|
+
password: options.password || null,
|
|
245
|
+
maxUsers: options.maxUsers || 10
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
joinRoom(roomId, userId, userName, password = null) {
|
|
251
|
+
if (!this.isConnected) {
|
|
252
|
+
throw new Error('Not connected to signaling server');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
this.roomId = roomId;
|
|
256
|
+
this.userId = userId;
|
|
257
|
+
|
|
258
|
+
this.send({
|
|
259
|
+
type: 'join-room',
|
|
260
|
+
payload: {
|
|
261
|
+
roomId,
|
|
262
|
+
userId,
|
|
263
|
+
userName,
|
|
264
|
+
password
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
leaveRoom() {
|
|
270
|
+
if (!this.isConnected || !this.roomId) return;
|
|
271
|
+
|
|
272
|
+
this.send({
|
|
273
|
+
type: 'leave-room',
|
|
274
|
+
payload: {}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
this.cleanup();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
onRoomJoined(message) {
|
|
281
|
+
const { roomId, userId, room, operations, chatHistory } = message;
|
|
282
|
+
|
|
283
|
+
this.roomId = roomId;
|
|
284
|
+
this.userId = userId;
|
|
285
|
+
this.operations = operations || [];
|
|
286
|
+
this.operationIndex = this.operations.length;
|
|
287
|
+
this.chatMessages = chatHistory || [];
|
|
288
|
+
|
|
289
|
+
// Initialize users
|
|
290
|
+
this.users.clear();
|
|
291
|
+
if (room.users) {
|
|
292
|
+
room.users.forEach(userInfo => {
|
|
293
|
+
this.users.set(userInfo.id, {
|
|
294
|
+
id: userInfo.id,
|
|
295
|
+
name: userInfo.name,
|
|
296
|
+
color: userInfo.color,
|
|
297
|
+
cursor: userInfo.cursor,
|
|
298
|
+
selection: userInfo.selection,
|
|
299
|
+
status: userInfo.status
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
console.log(`[CollabClient] Joined room ${roomId} as ${userId}`);
|
|
305
|
+
this.callbacks.onConnected();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
onRoomLeft() {
|
|
309
|
+
console.log('[CollabClient] Left room');
|
|
310
|
+
this.cleanup();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
cleanup() {
|
|
314
|
+
// Close all peer connections
|
|
315
|
+
this.peers.forEach(peer => peer.close());
|
|
316
|
+
this.peers.clear();
|
|
317
|
+
this.dataChannels.clear();
|
|
318
|
+
|
|
319
|
+
// Clear room state
|
|
320
|
+
this.roomId = null;
|
|
321
|
+
this.userId = null;
|
|
322
|
+
this.users.clear();
|
|
323
|
+
this.remoteSelections.clear();
|
|
324
|
+
this.userPresence.clear();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ========== User Presence ==========
|
|
328
|
+
|
|
329
|
+
onUserJoined(message) {
|
|
330
|
+
const { user } = message;
|
|
331
|
+
|
|
332
|
+
this.users.set(user.id, {
|
|
333
|
+
id: user.id,
|
|
334
|
+
name: user.name,
|
|
335
|
+
color: user.color,
|
|
336
|
+
cursor: user.cursor,
|
|
337
|
+
selection: user.selection,
|
|
338
|
+
status: user.status
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
console.log(`[CollabClient] User joined: ${user.name} (${user.id})`);
|
|
342
|
+
|
|
343
|
+
// Initiate WebRTC connection
|
|
344
|
+
if (user.id !== this.userId) {
|
|
345
|
+
this.setupPeerConnection(user.id);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
this.callbacks.onUserJoined({
|
|
349
|
+
userId: user.id,
|
|
350
|
+
name: user.name,
|
|
351
|
+
color: user.color
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
onUserLeft(message) {
|
|
356
|
+
const { userId } = message;
|
|
357
|
+
|
|
358
|
+
this.users.delete(userId);
|
|
359
|
+
this.peers.get(userId)?.close();
|
|
360
|
+
this.peers.delete(userId);
|
|
361
|
+
this.dataChannels.delete(userId);
|
|
362
|
+
this.remoteSelections.delete(userId);
|
|
363
|
+
|
|
364
|
+
console.log(`[CollabClient] User left: ${userId}`);
|
|
365
|
+
|
|
366
|
+
this.callbacks.onUserLeft({ userId });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
onUserStatus(message) {
|
|
370
|
+
const { userId, status } = message;
|
|
371
|
+
|
|
372
|
+
const user = this.users.get(userId);
|
|
373
|
+
if (user) {
|
|
374
|
+
user.status = status;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
this.userPresence.set(userId, {
|
|
378
|
+
status,
|
|
379
|
+
lastSeen: new Date(message.timestamp)
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ========== Cursor & Selection ==========
|
|
384
|
+
|
|
385
|
+
updateCursor(x, y) {
|
|
386
|
+
const now = Date.now();
|
|
387
|
+
|
|
388
|
+
// Throttle cursor updates
|
|
389
|
+
if (now - this.lastCursorUpdate < this.cursorUpdateInterval) {
|
|
390
|
+
this.pendingCursorUpdate = { x, y };
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
this.lastCursorUpdate = now;
|
|
395
|
+
this.pendingCursorUpdate = null;
|
|
396
|
+
|
|
397
|
+
this.send({
|
|
398
|
+
type: 'cursor-update',
|
|
399
|
+
payload: { x, y }
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
onCursorUpdate(message) {
|
|
404
|
+
const { userId, cursor } = message;
|
|
405
|
+
|
|
406
|
+
const user = this.users.get(userId);
|
|
407
|
+
if (user) {
|
|
408
|
+
user.cursor = cursor;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
this.callbacks.onCursorUpdate({
|
|
412
|
+
userId,
|
|
413
|
+
x: cursor.x,
|
|
414
|
+
y: cursor.y
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
updateSelection(partIds) {
|
|
419
|
+
this.localSelection = partIds;
|
|
420
|
+
|
|
421
|
+
this.send({
|
|
422
|
+
type: 'selection-update',
|
|
423
|
+
payload: { selection: partIds }
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
onSelectionUpdate(message) {
|
|
428
|
+
const { userId, selection } = message;
|
|
429
|
+
|
|
430
|
+
const user = this.users.get(userId);
|
|
431
|
+
if (user) {
|
|
432
|
+
user.selection = selection;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
this.remoteSelections.set(userId, selection);
|
|
436
|
+
|
|
437
|
+
this.callbacks.onSelectionUpdate({
|
|
438
|
+
userId,
|
|
439
|
+
partIds: selection
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ========== CRDT Operations ==========
|
|
444
|
+
|
|
445
|
+
sendOperation(op) {
|
|
446
|
+
// Queue operation if offline
|
|
447
|
+
if (!this.isConnected) {
|
|
448
|
+
this.offlineQueue.push(op);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
this.send({
|
|
453
|
+
type: 'operation',
|
|
454
|
+
payload: { op }
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
this.operations.push({
|
|
458
|
+
userId: this.userId,
|
|
459
|
+
op,
|
|
460
|
+
timestamp: new Date().toISOString()
|
|
461
|
+
});
|
|
462
|
+
this.operationIndex++;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
onOperationReceived(message) {
|
|
466
|
+
const { userId, op, timestamp } = message;
|
|
467
|
+
|
|
468
|
+
this.operations.push({
|
|
469
|
+
userId,
|
|
470
|
+
op,
|
|
471
|
+
timestamp
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
this.callbacks.onOperationReceived({
|
|
475
|
+
userId,
|
|
476
|
+
op,
|
|
477
|
+
timestamp
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
syncOfflineQueue() {
|
|
482
|
+
if (this.offlineQueue.length === 0) return;
|
|
483
|
+
|
|
484
|
+
console.log(`[CollabClient] Syncing ${this.offlineQueue.length} offline operations`);
|
|
485
|
+
|
|
486
|
+
while (this.offlineQueue.length > 0) {
|
|
487
|
+
const op = this.offlineQueue.shift();
|
|
488
|
+
this.sendOperation(op);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ========== Chat Messages ==========
|
|
493
|
+
|
|
494
|
+
sendMessage(text) {
|
|
495
|
+
if (!this.isConnected || !this.roomId) {
|
|
496
|
+
throw new Error('Not in a room');
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
this.send({
|
|
500
|
+
type: 'chat-message',
|
|
501
|
+
payload: { text }
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
onChatMessage(message) {
|
|
506
|
+
const { userId, userName, userColor, text, timestamp } = message;
|
|
507
|
+
|
|
508
|
+
this.chatMessages.push({
|
|
509
|
+
userId,
|
|
510
|
+
name: userName,
|
|
511
|
+
color: userColor,
|
|
512
|
+
text,
|
|
513
|
+
timestamp
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// Keep chat history bounded
|
|
517
|
+
if (this.chatMessages.length > this.maxChatHistory) {
|
|
518
|
+
this.chatMessages.shift();
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
this.callbacks.onChatMessage({
|
|
522
|
+
userId,
|
|
523
|
+
name: userName,
|
|
524
|
+
color: userColor,
|
|
525
|
+
text,
|
|
526
|
+
timestamp
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
onRoomReset() {
|
|
531
|
+
console.log('[CollabClient] Room was reset by admin');
|
|
532
|
+
this.operations = [];
|
|
533
|
+
this.operationIndex = 0;
|
|
534
|
+
this.chatMessages = [];
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
onRoomClosed() {
|
|
538
|
+
console.log('[CollabClient] Room was closed');
|
|
539
|
+
this.leaveRoom();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ========== WebRTC Peer Connections ==========
|
|
543
|
+
|
|
544
|
+
async setupPeerConnection(remoteUserId) {
|
|
545
|
+
try {
|
|
546
|
+
const config = {
|
|
547
|
+
iceServers: this.iceServers
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
const peerConnection = new RTCPeerConnection(config);
|
|
551
|
+
this.peers.set(remoteUserId, peerConnection);
|
|
552
|
+
|
|
553
|
+
// Handle ICE candidates
|
|
554
|
+
peerConnection.onicecandidate = (event) => {
|
|
555
|
+
if (event.candidate) {
|
|
556
|
+
this.send({
|
|
557
|
+
type: 'ice-candidate',
|
|
558
|
+
payload: {
|
|
559
|
+
targetUserId: remoteUserId,
|
|
560
|
+
candidate: event.candidate
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
// Handle data channels
|
|
567
|
+
peerConnection.ondatachannel = (event) => {
|
|
568
|
+
this.setupDataChannel(remoteUserId, event.channel);
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
// Create data channel for us (we're initiating)
|
|
572
|
+
const dataChannel = peerConnection.createDataChannel('cyclecad-collab', {
|
|
573
|
+
ordered: true
|
|
574
|
+
});
|
|
575
|
+
this.setupDataChannel(remoteUserId, dataChannel);
|
|
576
|
+
|
|
577
|
+
// Create and send offer
|
|
578
|
+
const offer = await peerConnection.createOffer();
|
|
579
|
+
await peerConnection.setLocalDescription(offer);
|
|
580
|
+
|
|
581
|
+
this.send({
|
|
582
|
+
type: 'signaling-offer',
|
|
583
|
+
payload: {
|
|
584
|
+
targetUserId: remoteUserId,
|
|
585
|
+
offer
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
console.log(`[CollabClient] WebRTC offer sent to ${remoteUserId}`);
|
|
590
|
+
} catch (error) {
|
|
591
|
+
console.error(`[CollabClient] Error setting up peer connection:`, error);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async onSignalingOffer(message) {
|
|
596
|
+
const { fromUserId, offer } = message;
|
|
597
|
+
|
|
598
|
+
try {
|
|
599
|
+
let peerConnection = this.peers.get(fromUserId);
|
|
600
|
+
|
|
601
|
+
if (!peerConnection) {
|
|
602
|
+
const config = { iceServers: this.iceServers };
|
|
603
|
+
peerConnection = new RTCPeerConnection(config);
|
|
604
|
+
this.peers.set(fromUserId, peerConnection);
|
|
605
|
+
|
|
606
|
+
peerConnection.onicecandidate = (event) => {
|
|
607
|
+
if (event.candidate) {
|
|
608
|
+
this.send({
|
|
609
|
+
type: 'ice-candidate',
|
|
610
|
+
payload: {
|
|
611
|
+
targetUserId: fromUserId,
|
|
612
|
+
candidate: event.candidate
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
peerConnection.ondatachannel = (event) => {
|
|
619
|
+
this.setupDataChannel(fromUserId, event.channel);
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
await peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
|
|
624
|
+
|
|
625
|
+
const answer = await peerConnection.createAnswer();
|
|
626
|
+
await peerConnection.setLocalDescription(answer);
|
|
627
|
+
|
|
628
|
+
this.send({
|
|
629
|
+
type: 'signaling-answer',
|
|
630
|
+
payload: {
|
|
631
|
+
targetUserId: fromUserId,
|
|
632
|
+
answer
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
console.log(`[CollabClient] WebRTC answer sent to ${fromUserId}`);
|
|
637
|
+
} catch (error) {
|
|
638
|
+
console.error(`[CollabClient] Error handling offer:`, error);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async onSignalingAnswer(message) {
|
|
643
|
+
const { fromUserId, answer } = message;
|
|
644
|
+
|
|
645
|
+
try {
|
|
646
|
+
const peerConnection = this.peers.get(fromUserId);
|
|
647
|
+
if (peerConnection) {
|
|
648
|
+
await peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
|
|
649
|
+
console.log(`[CollabClient] WebRTC answer received from ${fromUserId}`);
|
|
650
|
+
}
|
|
651
|
+
} catch (error) {
|
|
652
|
+
console.error(`[CollabClient] Error handling answer:`, error);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async onIceCandidate(message) {
|
|
657
|
+
const { fromUserId, candidate } = message;
|
|
658
|
+
|
|
659
|
+
try {
|
|
660
|
+
const peerConnection = this.peers.get(fromUserId);
|
|
661
|
+
if (peerConnection && candidate) {
|
|
662
|
+
await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
|
|
663
|
+
}
|
|
664
|
+
} catch (error) {
|
|
665
|
+
console.error(`[CollabClient] Error adding ICE candidate:`, error);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
setupDataChannel(userId, dataChannel) {
|
|
670
|
+
console.log(`[CollabClient] Data channel established with ${userId}`);
|
|
671
|
+
|
|
672
|
+
dataChannel.onopen = () => {
|
|
673
|
+
console.log(`[CollabClient] Data channel open with ${userId}`);
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
dataChannel.onclose = () => {
|
|
677
|
+
console.log(`[CollabClient] Data channel closed with ${userId}`);
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
dataChannel.onmessage = (event) => {
|
|
681
|
+
try {
|
|
682
|
+
const message = JSON.parse(event.data);
|
|
683
|
+
this.handleMessage(message);
|
|
684
|
+
} catch (error) {
|
|
685
|
+
console.error('[CollabClient] Data channel parse error:', error);
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
dataChannel.onerror = (error) => {
|
|
690
|
+
console.error(`[CollabClient] Data channel error with ${userId}:`, error);
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
this.dataChannels.set(userId, dataChannel);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// ========== Utility Methods ==========
|
|
697
|
+
|
|
698
|
+
send(message) {
|
|
699
|
+
if (!this.isConnected || !this.ws) {
|
|
700
|
+
console.warn('[CollabClient] Not connected, queuing message');
|
|
701
|
+
return false;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
try {
|
|
705
|
+
this.ws.send(JSON.stringify(message));
|
|
706
|
+
return true;
|
|
707
|
+
} catch (error) {
|
|
708
|
+
console.error('[CollabClient] Send error:', error);
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
on(event, callback) {
|
|
714
|
+
if (this.callbacks.hasOwnProperty(`on${event[0].toUpperCase()}${event.slice(1)}`)) {
|
|
715
|
+
this.callbacks[`on${event[0].toUpperCase()}${event.slice(1)}`] = callback;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
getUsers() {
|
|
720
|
+
return Array.from(this.users.values());
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
getUser(userId) {
|
|
724
|
+
return this.users.get(userId);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
getRoomInfo() {
|
|
728
|
+
return {
|
|
729
|
+
roomId: this.roomId,
|
|
730
|
+
userId: this.userId,
|
|
731
|
+
users: this.getUsers(),
|
|
732
|
+
userCount: this.users.size,
|
|
733
|
+
operations: this.operations.length,
|
|
734
|
+
chatMessages: this.chatMessages.length,
|
|
735
|
+
isConnected: this.isConnected
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
disconnect() {
|
|
740
|
+
if (this.ws) {
|
|
741
|
+
this.ws.close();
|
|
742
|
+
}
|
|
743
|
+
this.cleanup();
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Export for use in browser or Node.js
|
|
748
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
749
|
+
module.exports = CollaborationClient;
|
|
750
|
+
}
|