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