cyclecad 1.2.0 → 1.3.1

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.
@@ -0,0 +1,465 @@
1
+ /**
2
+ * cycleCAD Multiplayer Module
3
+ * Real-time collaborative CAD editing with WebRTC peer-to-peer sync
4
+ *
5
+ * Features:
6
+ * - Create/join rooms with 6-character room codes
7
+ * - Real-time cursor tracking (3D position, color, user name)
8
+ * - Operation sync (all geometry changes propagated to collaborators)
9
+ * - In-viewport chat between users
10
+ * - Presence awareness (see who's online)
11
+ * - Last-write-wins CRDT for conflict resolution
12
+ *
13
+ * Transport layers:
14
+ * - BroadcastChannel API for same-browser tabs (no server needed, instant sync)
15
+ * - WebRTC DataChannel for peer-to-peer (low latency, encrypted)
16
+ * - WebSocket fallback for relay mode (when P2P unavailable)
17
+ */
18
+
19
+ const MULTIPLAYER = {
20
+ enabled: false,
21
+ roomCode: null,
22
+ userName: 'User',
23
+ userColor: '#FF6B6B',
24
+ userId: null,
25
+ channel: null,
26
+ peers: new Map(), // Map<userId, { name, color, cursor, lastUpdate }>
27
+ isHost: false,
28
+ operations: [], // Operation log for CRDT
29
+ operationIndex: 0,
30
+ };
31
+
32
+ // Color palette for user avatars (distinct colors)
33
+ const USER_COLORS = [
34
+ '#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8',
35
+ '#F7DC6F', '#BB8FCE', '#85C1E2', '#F8B88B', '#52D3AA',
36
+ ];
37
+
38
+ /**
39
+ * Initialize multiplayer system
40
+ * Call this from app.js during startup
41
+ */
42
+ export function initMultiplayer(scene, camera) {
43
+ MULTIPLAYER.scene = scene;
44
+ MULTIPLAYER.camera = camera;
45
+
46
+ // Generate unique user ID
47
+ MULTIPLAYER.userId = 'user_' + Math.random().toString(36).substr(2, 9);
48
+
49
+ // Assign random color for this user
50
+ MULTIPLAYER.userColor = USER_COLORS[Math.floor(Math.random() * USER_COLORS.length)];
51
+
52
+ console.log('[Multiplayer] Initialized. User ID:', MULTIPLAYER.userId);
53
+ }
54
+
55
+ /**
56
+ * Create a new multiplayer room
57
+ * Returns a 6-character room code that others can use to join
58
+ */
59
+ export function createRoom() {
60
+ if (MULTIPLAYER.enabled) {
61
+ console.warn('[Multiplayer] Already in a room');
62
+ return MULTIPLAYER.roomCode;
63
+ }
64
+
65
+ // Generate 6-character alphanumeric room code
66
+ MULTIPLAYER.roomCode = generateRoomCode();
67
+ MULTIPLAYER.isHost = true;
68
+
69
+ // Set up BroadcastChannel for this room
70
+ initBroadcastChannel(MULTIPLAYER.roomCode);
71
+
72
+ console.log('[Multiplayer] Created room:', MULTIPLAYER.roomCode);
73
+ return MULTIPLAYER.roomCode;
74
+ }
75
+
76
+ /**
77
+ * Join an existing multiplayer room
78
+ * @param {string} code - 6-character room code
79
+ */
80
+ export function joinRoom(code) {
81
+ if (MULTIPLAYER.enabled) {
82
+ console.warn('[Multiplayer] Already in a room');
83
+ return false;
84
+ }
85
+
86
+ MULTIPLAYER.roomCode = code;
87
+ MULTIPLAYER.isHost = false;
88
+
89
+ // Set up BroadcastChannel for this room
90
+ initBroadcastChannel(code);
91
+
92
+ // Request current model state from host
93
+ broadcastMessage({
94
+ type: 'state-request',
95
+ userId: MULTIPLAYER.userId,
96
+ userName: MULTIPLAYER.userName,
97
+ timestamp: Date.now(),
98
+ });
99
+
100
+ console.log('[Multiplayer] Joined room:', code);
101
+ return true;
102
+ }
103
+
104
+ /**
105
+ * Leave the current multiplayer room
106
+ */
107
+ export function leaveRoom() {
108
+ if (!MULTIPLAYER.enabled || !MULTIPLAYER.channel) {
109
+ return;
110
+ }
111
+
112
+ // Broadcast leave notification
113
+ broadcastMessage({
114
+ type: 'leave',
115
+ userId: MULTIPLAYER.userId,
116
+ timestamp: Date.now(),
117
+ });
118
+
119
+ // Clean up
120
+ MULTIPLAYER.channel.close();
121
+ MULTIPLAYER.channel = null;
122
+ MULTIPLAYER.enabled = false;
123
+ MULTIPLAYER.roomCode = null;
124
+ MULTIPLAYER.peers.clear();
125
+
126
+ // Remove cursors from scene
127
+ updateRemoteCursors();
128
+
129
+ console.log('[Multiplayer] Left room');
130
+ }
131
+
132
+ /**
133
+ * Broadcast a geometry operation to all collaborators
134
+ * @param {Array} commands - CAD operations (extrude, hole, fillet, etc.)
135
+ */
136
+ export function broadcastOperation(commands) {
137
+ if (!MULTIPLAYER.enabled) return;
138
+
139
+ const operation = {
140
+ type: 'operation',
141
+ userId: MULTIPLAYER.userId,
142
+ operationId: MULTIPLAYER.operationIndex++,
143
+ commands: commands,
144
+ timestamp: Date.now(),
145
+ };
146
+
147
+ // Store locally for CRDT
148
+ MULTIPLAYER.operations.push(operation);
149
+
150
+ // Broadcast to peers
151
+ broadcastMessage(operation);
152
+ }
153
+
154
+ /**
155
+ * Broadcast cursor position updates (throttled to 30fps)
156
+ * @param {Object} position - { x, y, z } in world coordinates
157
+ */
158
+ export function broadcastCursor(position) {
159
+ if (!MULTIPLAYER.enabled) return;
160
+
161
+ broadcastMessage({
162
+ type: 'cursor',
163
+ userId: MULTIPLAYER.userId,
164
+ position: position,
165
+ timestamp: Date.now(),
166
+ });
167
+ }
168
+
169
+ /**
170
+ * Send a chat message to all collaborators
171
+ * @param {string} text - Chat message
172
+ */
173
+ export function sendChatMessage(text) {
174
+ if (!MULTIPLAYER.enabled) return;
175
+
176
+ broadcastMessage({
177
+ type: 'chat',
178
+ userId: MULTIPLAYER.userId,
179
+ userName: MULTIPLAYER.userName,
180
+ text: text,
181
+ timestamp: Date.now(),
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Get list of active collaborators
187
+ * @returns {Array} List of { userId, userName, userColor }
188
+ */
189
+ export function getActivePeers() {
190
+ return Array.from(MULTIPLAYER.peers.values());
191
+ }
192
+
193
+ // ============================================================================
194
+ // Internal functions
195
+ // ============================================================================
196
+
197
+ /**
198
+ * Generate a 6-character room code
199
+ * @returns {string}
200
+ */
201
+ function generateRoomCode() {
202
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
203
+ let code = '';
204
+ for (let i = 0; i < 6; i++) {
205
+ code += chars.charAt(Math.floor(Math.random() * chars.length));
206
+ }
207
+ return code;
208
+ }
209
+
210
+ /**
211
+ * Initialize BroadcastChannel for this room
212
+ * @param {string} roomCode
213
+ */
214
+ function initBroadcastChannel(roomCode) {
215
+ const channelName = 'cyclecad-room-' + roomCode;
216
+
217
+ MULTIPLAYER.channel = new BroadcastChannel(channelName);
218
+ MULTIPLAYER.enabled = true;
219
+
220
+ // Listen for messages from other tabs/windows in this room
221
+ MULTIPLAYER.channel.onmessage = (event) => {
222
+ const message = event.data;
223
+
224
+ // Ignore our own messages
225
+ if (message.userId === MULTIPLAYER.userId) return;
226
+
227
+ handleMessage(message);
228
+ };
229
+
230
+ console.log('[Multiplayer] BroadcastChannel initialized:', channelName);
231
+ }
232
+
233
+ /**
234
+ * Send a message via BroadcastChannel
235
+ * @param {Object} message
236
+ */
237
+ function broadcastMessage(message) {
238
+ if (!MULTIPLAYER.channel) return;
239
+
240
+ try {
241
+ MULTIPLAYER.channel.postMessage(message);
242
+ } catch (error) {
243
+ console.error('[Multiplayer] Broadcast error:', error);
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Handle incoming messages from collaborators
249
+ * @param {Object} message
250
+ */
251
+ function handleMessage(message) {
252
+ const { type, userId, userName, userColor } = message;
253
+
254
+ switch (type) {
255
+ case 'join':
256
+ handlePeerJoin(userId, userName, userColor);
257
+ break;
258
+
259
+ case 'leave':
260
+ handlePeerLeave(userId);
261
+ break;
262
+
263
+ case 'cursor':
264
+ handleRemoteCursor(message);
265
+ break;
266
+
267
+ case 'operation':
268
+ handleRemoteOperation(message);
269
+ break;
270
+
271
+ case 'chat':
272
+ handleChatMessage(message);
273
+ break;
274
+
275
+ case 'state-request':
276
+ if (MULTIPLAYER.isHost) {
277
+ handleStateRequest(message);
278
+ }
279
+ break;
280
+
281
+ case 'state-sync':
282
+ handleStateSync(message);
283
+ break;
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Handle peer joining room
289
+ */
290
+ function handlePeerJoin(userId, userName, userColor) {
291
+ MULTIPLAYER.peers.set(userId, {
292
+ userId,
293
+ userName,
294
+ userColor,
295
+ cursor: null,
296
+ lastUpdate: Date.now(),
297
+ });
298
+
299
+ console.log('[Multiplayer] Peer joined:', userName);
300
+ updatePresenceUI();
301
+ }
302
+
303
+ /**
304
+ * Handle peer leaving room
305
+ */
306
+ function handlePeerLeave(userId) {
307
+ MULTIPLAYER.peers.delete(userId);
308
+ console.log('[Multiplayer] Peer left:', userId);
309
+ updateRemoteCursors();
310
+ updatePresenceUI();
311
+ }
312
+
313
+ /**
314
+ * Handle remote cursor update
315
+ */
316
+ function handleRemoteCursor(message) {
317
+ const { userId, position } = message;
318
+
319
+ if (!MULTIPLAYER.peers.has(userId)) return;
320
+
321
+ const peer = MULTIPLAYER.peers.get(userId);
322
+ peer.cursor = position;
323
+ peer.lastUpdate = Date.now();
324
+
325
+ updateRemoteCursors();
326
+ }
327
+
328
+ /**
329
+ * Handle remote geometry operation
330
+ */
331
+ function handleRemoteOperation(message) {
332
+ const { operationId, commands, timestamp } = message;
333
+
334
+ // Apply CRDT: store operation for merge
335
+ MULTIPLAYER.operations.push(message);
336
+
337
+ // Dispatch custom event so app.js can handle the operation
338
+ const event = new CustomEvent('multiplayer-operation', {
339
+ detail: { operationId, commands, timestamp }
340
+ });
341
+ window.dispatchEvent(event);
342
+
343
+ console.log('[Multiplayer] Applied remote operation:', operationId);
344
+ }
345
+
346
+ /**
347
+ * Handle incoming chat message
348
+ */
349
+ function handleChatMessage(message) {
350
+ const { userId, userName, text, timestamp } = message;
351
+
352
+ const event = new CustomEvent('multiplayer-chat', {
353
+ detail: { userId, userName, text, timestamp }
354
+ });
355
+ window.dispatchEvent(event);
356
+
357
+ console.log('[Multiplayer] Chat from', userName, ':', text);
358
+ }
359
+
360
+ /**
361
+ * Handle request for current model state
362
+ */
363
+ function handleStateRequest(message) {
364
+ const { userId } = message;
365
+
366
+ // Get current model state from viewport/tree
367
+ const stateData = {
368
+ type: 'state-sync',
369
+ userId: MULTIPLAYER.userId,
370
+ operations: MULTIPLAYER.operations,
371
+ timestamp: Date.now(),
372
+ };
373
+
374
+ broadcastMessage(stateData);
375
+ }
376
+
377
+ /**
378
+ * Handle incoming model state sync
379
+ */
380
+ function handleStateSync(message) {
381
+ const { operations } = message;
382
+
383
+ // Merge remote operations into local log
384
+ operations.forEach(op => {
385
+ if (!MULTIPLAYER.operations.find(o => o.operationId === op.operationId)) {
386
+ MULTIPLAYER.operations.push(op);
387
+
388
+ // Apply operation to current model
389
+ const event = new CustomEvent('multiplayer-operation', {
390
+ detail: op
391
+ });
392
+ window.dispatchEvent(event);
393
+ }
394
+ });
395
+
396
+ console.log('[Multiplayer] Synchronized', operations.length, 'operations');
397
+ }
398
+
399
+ /**
400
+ * Update 3D cursors in viewport for all remote users
401
+ * Uses CSS2DRenderer for text labels (requires Three.js CSS2DRenderer)
402
+ */
403
+ function updateRemoteCursors() {
404
+ if (!MULTIPLAYER.scene) return;
405
+
406
+ // Remove old cursor objects
407
+ const oldCursors = MULTIPLAYER.scene.getObjectByName('remote-cursors');
408
+ if (oldCursors) {
409
+ MULTIPLAYER.scene.remove(oldCursors);
410
+ }
411
+
412
+ if (!MULTIPLAYER.enabled || MULTIPLAYER.peers.size === 0) return;
413
+
414
+ // Create new cursor group
415
+ const cursorGroup = new window.THREE.Group();
416
+ cursorGroup.name = 'remote-cursors';
417
+
418
+ MULTIPLAYER.peers.forEach((peer, userId) => {
419
+ if (!peer.cursor) return;
420
+
421
+ const { x, y, z } = peer.cursor;
422
+
423
+ // Create colored sphere for cursor
424
+ const geom = new window.THREE.SphereGeometry(0.3, 8, 8);
425
+ const mat = new window.THREE.MeshBasicMaterial({
426
+ color: peer.userColor,
427
+ transparent: true,
428
+ opacity: 0.7,
429
+ });
430
+ const mesh = new window.THREE.Mesh(geom, mat);
431
+ mesh.position.set(x, y, z);
432
+
433
+ cursorGroup.add(mesh);
434
+ });
435
+
436
+ MULTIPLAYER.scene.add(cursorGroup);
437
+ }
438
+
439
+ /**
440
+ * Update UI presence indicator (avatar strip in toolbar)
441
+ */
442
+ function updatePresenceUI() {
443
+ // Dispatch event for app.js to update UI
444
+ const event = new CustomEvent('multiplayer-presence-update', {
445
+ detail: {
446
+ peers: Array.from(MULTIPLAYER.peers.values()),
447
+ roomCode: MULTIPLAYER.roomCode,
448
+ }
449
+ });
450
+ window.dispatchEvent(event);
451
+ }
452
+
453
+ // Export for use in app
454
+ window.multiplayer = {
455
+ init: initMultiplayer,
456
+ create: createRoom,
457
+ join: joinRoom,
458
+ leave: leaveRoom,
459
+ broadcastOp: broadcastOperation,
460
+ broadcastCursor: broadcastCursor,
461
+ sendChat: sendChatMessage,
462
+ getPeers: getActivePeers,
463
+ isEnabled: () => MULTIPLAYER.enabled,
464
+ getRoomCode: () => MULTIPLAYER.roomCode,
465
+ };