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.
- package/.github/scripts/cad-diff.js +590 -0
- package/.github/workflows/cad-diff.yml +117 -0
- package/KILLER-README.md +377 -0
- package/README.md +354 -35
- package/app/index.html +86 -31
- package/app/js/ai-chat.js +3 -0
- package/app/js/cad-vr.js +1 -0
- package/app/js/multiplayer.js +465 -0
- package/app/js/parts-library.js +778 -0
- package/app/js/step-viewer.js +584 -0
- package/app/js/text-to-brep.js +585 -0
- package/docs/ARCHITECTURE.html +1429 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|