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.
Files changed (69) hide show
  1. package/API-BUILD-MANIFEST.txt +339 -0
  2. package/API-SERVER.md +535 -0
  3. package/Architecture-Deck.pptx +0 -0
  4. package/CLAUDE.md +172 -11
  5. package/CLI-BUILD-SUMMARY.md +504 -0
  6. package/CLI-INDEX.md +356 -0
  7. package/CLI-README.md +466 -0
  8. package/COLLABORATION-INTEGRATION-GUIDE.md +325 -0
  9. package/CONNECTED_FABS_GUIDE.md +612 -0
  10. package/CONNECTED_FABS_README.md +310 -0
  11. package/DELIVERABLES.md +343 -0
  12. package/DFM-ANALYZER-INTEGRATION.md +368 -0
  13. package/DFM-QUICK-START.js +253 -0
  14. package/Dockerfile +69 -0
  15. package/IMPLEMENTATION.md +327 -0
  16. package/LICENSE +31 -0
  17. package/MARKETPLACE_QUICK_REFERENCE.txt +294 -0
  18. package/MCP-INDEX.md +264 -0
  19. package/QUICKSTART-API.md +388 -0
  20. package/QUICKSTART-CLI.md +211 -0
  21. package/QUICKSTART-MCP.md +196 -0
  22. package/README-MCP.md +208 -0
  23. package/TEST-TOKEN-ENGINE.md +319 -0
  24. package/TOKEN-ENGINE-SUMMARY.md +266 -0
  25. package/TOKENS-README.md +263 -0
  26. package/TOOLS-REFERENCE.md +254 -0
  27. package/app/index.html +168 -3
  28. package/app/js/TOKEN-INTEGRATION.md +391 -0
  29. package/app/js/agent-api.js +3 -3
  30. package/app/js/ai-copilot.js +1435 -0
  31. package/app/js/cam-pipeline.js +840 -0
  32. package/app/js/collaboration-ui.js +995 -0
  33. package/app/js/collaboration.js +1116 -0
  34. package/app/js/connected-fabs-example.js +404 -0
  35. package/app/js/connected-fabs.js +1449 -0
  36. package/app/js/dfm-analyzer.js +1760 -0
  37. package/app/js/marketplace.js +1994 -0
  38. package/app/js/material-library.js +2115 -0
  39. package/app/js/token-dashboard.js +563 -0
  40. package/app/js/token-engine.js +743 -0
  41. package/app/test-agent.html +1801 -0
  42. package/bin/cyclecad-cli.js +662 -0
  43. package/bin/cyclecad-mcp +2 -0
  44. package/bin/server.js +242 -0
  45. package/cycleCAD-Architecture.pptx +0 -0
  46. package/cycleCAD-Investor-Deck.pptx +0 -0
  47. package/demo-mcp.sh +60 -0
  48. package/docs/API-SERVER-SUMMARY.md +375 -0
  49. package/docs/API-SERVER.md +667 -0
  50. package/docs/CAM-EXAMPLES.md +344 -0
  51. package/docs/CAM-INTEGRATION.md +612 -0
  52. package/docs/CAM-QUICK-REFERENCE.md +199 -0
  53. package/docs/CLI-INTEGRATION.md +510 -0
  54. package/docs/CLI.md +872 -0
  55. package/docs/MARKETPLACE-API-SCHEMA.json +564 -0
  56. package/docs/MARKETPLACE-INTEGRATION.md +467 -0
  57. package/docs/MARKETPLACE-SETUP.html +439 -0
  58. package/docs/MCP-SERVER.md +403 -0
  59. package/examples/api-client-example.js +488 -0
  60. package/examples/api-client-example.py +359 -0
  61. package/examples/batch-manufacturing.txt +28 -0
  62. package/examples/batch-simple.txt +26 -0
  63. package/model-marketplace.html +1273 -0
  64. package/package.json +14 -3
  65. package/server/api-server.js +1120 -0
  66. package/server/mcp-server.js +1161 -0
  67. package/test-api-server.js +432 -0
  68. package/test-mcp.js +198 -0
  69. package/~$cycleCAD-Investor-Deck.pptx +0 -0
@@ -0,0 +1,1994 @@
1
+ /**
2
+ * marketplace.js — cycleCAD Model Marketplace Module
3
+ *
4
+ * Creators publish, discover, and purchase 3D models using $CYCLE tokens.
5
+ * Architecture:
6
+ * - Model publishing with parametric + access tiers
7
+ * - Full-text search + category browsing
8
+ * - Token-based purchase system with double-entry ledger
9
+ * - Creator dashboard with earnings analytics
10
+ * - Review system with caching discounts
11
+ * - Agent API integration
12
+ *
13
+ * Data storage: localStorage (demo) / IndexedDB (prod)
14
+ * License: All models stored with creator IP terms
15
+ *
16
+ * Version: 1.0.0
17
+ */
18
+
19
+ import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js';
20
+
21
+ // ============================================================================
22
+ // Constants
23
+ // ============================================================================
24
+
25
+ const STORAGE_KEY = 'cyclecad_marketplace';
26
+ const MODELS_DB = 'cyclecad_models';
27
+ const TRANSACTIONS_DB = 'cyclecad_transactions';
28
+ const MAX_PREVIEW_SIZE = 512; // pixels
29
+
30
+ const MODEL_CATEGORIES = [
31
+ 'Mechanical',
32
+ 'Structural',
33
+ 'Enclosure',
34
+ 'Fastener',
35
+ 'Custom',
36
+ 'Template'
37
+ ];
38
+
39
+ const ACCESS_TIERS = {
40
+ FREE_PREVIEW: {
41
+ id: 1,
42
+ name: 'Free Preview',
43
+ price: 0,
44
+ description: 'View only, no download'
45
+ },
46
+ MESH_DOWNLOAD: {
47
+ id: 2,
48
+ name: 'Mesh Download',
49
+ price: 50,
50
+ description: 'STL/OBJ export'
51
+ },
52
+ PARAMETRIC: {
53
+ id: 3,
54
+ name: 'Parametric',
55
+ price: 200,
56
+ description: 'Editable cycleCAD format'
57
+ },
58
+ FULL_IP: {
59
+ id: 4,
60
+ name: 'Full IP',
61
+ price: 1000,
62
+ description: 'STEP + source + history'
63
+ },
64
+ COMMERCIAL_USE: {
65
+ id: 5,
66
+ name: 'Commercial Use',
67
+ price: 2000,
68
+ description: 'License for resale'
69
+ },
70
+ DERIVATIVE: {
71
+ id: 6,
72
+ name: 'Derivative',
73
+ price: null, // 15% of parent model price
74
+ description: 'Fork and modify'
75
+ },
76
+ AGENT_ACCESS: {
77
+ id: 7,
78
+ name: 'Agent Access',
79
+ price: 5, // per-use
80
+ description: 'Micro-royalty per AI query'
81
+ }
82
+ };
83
+
84
+ // ============================================================================
85
+ // State
86
+ // ============================================================================
87
+
88
+ let _currentUser = null;
89
+ let _userBalance = 0;
90
+ let _allModels = [];
91
+ let _purchaseHistory = [];
92
+ let _createdModels = [];
93
+ let _viewport = null;
94
+ let _tokenEngine = null;
95
+ let _eventListeners = {};
96
+
97
+ // ============================================================================
98
+ // Initialization
99
+ // ============================================================================
100
+
101
+ /**
102
+ * Initialize the Marketplace module
103
+ */
104
+ export function initMarketplace({ viewport, tokenEngine }) {
105
+ _viewport = viewport;
106
+ _tokenEngine = tokenEngine;
107
+
108
+ // Initialize current user from localStorage
109
+ const storedUser = localStorage.getItem('cyclecad_current_user');
110
+ if (storedUser) {
111
+ _currentUser = JSON.parse(storedUser);
112
+ } else {
113
+ _currentUser = {
114
+ id: crypto.randomUUID(),
115
+ name: 'Creator_' + Math.random().toString(36).substr(2, 9),
116
+ email: '',
117
+ avatar: null,
118
+ joinedDate: new Date().toISOString(),
119
+ bio: '',
120
+ website: ''
121
+ };
122
+ localStorage.setItem('cyclecad_current_user', JSON.stringify(_currentUser));
123
+ }
124
+
125
+ // Load marketplace data
126
+ loadMarketplaceData();
127
+
128
+ // Create UI panel
129
+ createMarketplacePanel();
130
+
131
+ // Add toolbar button
132
+ addMarketplaceToolbarButton();
133
+
134
+ // Expose API globally
135
+ window.cycleCAD.marketplace = {
136
+ publish: publishModel,
137
+ search: searchModels,
138
+ browse: browseModels,
139
+ getDetails: getModelDetails,
140
+ purchase: purchaseModel,
141
+ download: downloadModel,
142
+ getPurchaseHistory,
143
+ getCreatorProfile,
144
+ getCreatorStats,
145
+ addReview,
146
+ getReviews,
147
+ withdrawEarnings,
148
+ listMyModels: listCreatorModels,
149
+ updateModel,
150
+ deleteModel,
151
+ on: addEventListener,
152
+ off: removeEventListener
153
+ };
154
+
155
+ console.log('[Marketplace] Initialized for user:', _currentUser.name);
156
+
157
+ return { userId: _currentUser.id };
158
+ }
159
+
160
+ // ============================================================================
161
+ // Model Publishing
162
+ // ============================================================================
163
+
164
+ /**
165
+ * Publish a model to the marketplace
166
+ */
167
+ export function publishModel(modelData) {
168
+ const {
169
+ name,
170
+ description,
171
+ category,
172
+ tags = [],
173
+ tiers = [],
174
+ sourceGeometry = null,
175
+ parametricData = null,
176
+ metadata = {}
177
+ } = modelData;
178
+
179
+ // Validate
180
+ if (!name || !description || !category) {
181
+ return { ok: false, error: 'Missing required fields: name, description, category' };
182
+ }
183
+ if (!MODEL_CATEGORIES.includes(category)) {
184
+ return { ok: false, error: `Invalid category. Must be one of: ${MODEL_CATEGORIES.join(', ')}` };
185
+ }
186
+
187
+ const modelId = crypto.randomUUID();
188
+ const timestamp = new Date().toISOString();
189
+
190
+ // Generate preview thumbnail
191
+ let previewImage = null;
192
+ if (sourceGeometry) {
193
+ previewImage = generatePreviewThumbnail(sourceGeometry);
194
+ }
195
+
196
+ // Build model record
197
+ const model = {
198
+ id: modelId,
199
+ creatorId: _currentUser.id,
200
+ creatorName: _currentUser.name,
201
+ name,
202
+ description,
203
+ category,
204
+ tags: Array.isArray(tags) ? tags : [],
205
+ tiers: tiers.length > 0 ? tiers : [ACCESS_TIERS.FREE_PREVIEW],
206
+ previewImage,
207
+ sourceGeometry: sourceGeometry ? serializeGeometry(sourceGeometry) : null,
208
+ parametricData,
209
+ metadata: {
210
+ ...metadata,
211
+ dimensions: extractDimensions(sourceGeometry),
212
+ polyCount: sourceGeometry ? countPolygons(sourceGeometry) : 0
213
+ },
214
+ stats: {
215
+ views: 0,
216
+ downloads: 0,
217
+ purchases: 0,
218
+ rating: 0,
219
+ reviewCount: 0
220
+ },
221
+ reviews: [],
222
+ publishedDate: timestamp,
223
+ updatedDate: timestamp,
224
+ derivedFromModelId: modelData.derivedFromModelId || null,
225
+ derivativeLicense: modelData.derivativeLicense || false
226
+ };
227
+
228
+ _allModels.push(model);
229
+ _createdModels.push(modelId);
230
+
231
+ saveMarketplaceData();
232
+ emitEvent('modelPublished', { modelId, modelName: name });
233
+
234
+ return {
235
+ ok: true,
236
+ modelId,
237
+ model
238
+ };
239
+ }
240
+
241
+ /**
242
+ * Update an existing model (creator only)
243
+ */
244
+ export function updateModel(modelId, updates) {
245
+ const modelIndex = _allModels.findIndex(m => m.id === modelId);
246
+ if (modelIndex === -1) {
247
+ return { ok: false, error: 'Model not found' };
248
+ }
249
+
250
+ const model = _allModels[modelIndex];
251
+
252
+ // Check ownership
253
+ if (model.creatorId !== _currentUser.id) {
254
+ return { ok: false, error: 'Permission denied: only creator can update model' };
255
+ }
256
+
257
+ // Merge updates
258
+ const updated = {
259
+ ...model,
260
+ ...updates,
261
+ id: model.id,
262
+ creatorId: model.creatorId,
263
+ publishedDate: model.publishedDate,
264
+ updatedDate: new Date().toISOString()
265
+ };
266
+
267
+ _allModels[modelIndex] = updated;
268
+ saveMarketplaceData();
269
+ emitEvent('modelUpdated', { modelId, updates });
270
+
271
+ return { ok: true, model: updated };
272
+ }
273
+
274
+ /**
275
+ * Delete a model (creator only)
276
+ */
277
+ export function deleteModel(modelId) {
278
+ const modelIndex = _allModels.findIndex(m => m.id === modelId);
279
+ if (modelIndex === -1) {
280
+ return { ok: false, error: 'Model not found' };
281
+ }
282
+
283
+ const model = _allModels[modelIndex];
284
+ if (model.creatorId !== _currentUser.id) {
285
+ return { ok: false, error: 'Permission denied' };
286
+ }
287
+
288
+ _allModels.splice(modelIndex, 1);
289
+ _createdModels = _createdModels.filter(id => id !== modelId);
290
+ saveMarketplaceData();
291
+ emitEvent('modelDeleted', { modelId });
292
+
293
+ return { ok: true };
294
+ }
295
+
296
+ // ============================================================================
297
+ // Model Discovery
298
+ // ============================================================================
299
+
300
+ /**
301
+ * Search models by query + filters
302
+ */
303
+ export function searchModels(query, filters = {}) {
304
+ let results = _allModels;
305
+
306
+ // Text search
307
+ if (query && query.trim()) {
308
+ const q = query.toLowerCase();
309
+ results = results.filter(m =>
310
+ m.name.toLowerCase().includes(q) ||
311
+ m.description.toLowerCase().includes(q) ||
312
+ m.tags.some(t => t.toLowerCase().includes(q)) ||
313
+ m.creatorName.toLowerCase().includes(q)
314
+ );
315
+ }
316
+
317
+ // Category filter
318
+ if (filters.category) {
319
+ results = results.filter(m => m.category === filters.category);
320
+ }
321
+
322
+ // Price range filter
323
+ if (filters.priceMin !== undefined || filters.priceMax !== undefined) {
324
+ const minPrice = filters.priceMin || 0;
325
+ const maxPrice = filters.priceMax || Infinity;
326
+ results = results.filter(m => {
327
+ const price = m.tiers[0]?.price || 0;
328
+ return price >= minPrice && price <= maxPrice;
329
+ });
330
+ }
331
+
332
+ // Rating filter
333
+ if (filters.minRating !== undefined) {
334
+ results = results.filter(m => m.stats.rating >= filters.minRating);
335
+ }
336
+
337
+ // Pagination
338
+ const page = filters.page || 0;
339
+ const pageSize = filters.pageSize || 20;
340
+ const total = results.length;
341
+ const paged = results.slice(page * pageSize, (page + 1) * pageSize);
342
+
343
+ return {
344
+ ok: true,
345
+ results: paged,
346
+ total,
347
+ page,
348
+ pageSize,
349
+ hasMore: (page + 1) * pageSize < total
350
+ };
351
+ }
352
+
353
+ /**
354
+ * Browse models by category with sorting
355
+ */
356
+ export function browseModels(category, options = {}) {
357
+ const { sort = 'newest', page = 0, pageSize = 20 } = options;
358
+
359
+ let results = category ? _allModels.filter(m => m.category === category) : _allModels;
360
+
361
+ // Sort
362
+ switch (sort) {
363
+ case 'newest':
364
+ results.sort((a, b) => new Date(b.publishedDate) - new Date(a.publishedDate));
365
+ break;
366
+ case 'popular':
367
+ results.sort((a, b) => b.stats.downloads - a.stats.downloads);
368
+ break;
369
+ case 'price-low':
370
+ results.sort((a, b) => (a.tiers[0]?.price || 0) - (b.tiers[0]?.price || 0));
371
+ break;
372
+ case 'price-high':
373
+ results.sort((a, b) => (b.tiers[0]?.price || 0) - (a.tiers[0]?.price || 0));
374
+ break;
375
+ case 'rating':
376
+ results.sort((a, b) => b.stats.rating - a.stats.rating);
377
+ break;
378
+ }
379
+
380
+ const total = results.length;
381
+ const paged = results.slice(page * pageSize, (page + 1) * pageSize);
382
+
383
+ return {
384
+ ok: true,
385
+ results: paged,
386
+ category,
387
+ sort,
388
+ total,
389
+ page,
390
+ pageSize,
391
+ hasMore: (page + 1) * pageSize < total
392
+ };
393
+ }
394
+
395
+ /**
396
+ * Get full model details + preview
397
+ */
398
+ export function getModelDetails(modelId) {
399
+ const model = _allModels.find(m => m.id === modelId);
400
+ if (!model) {
401
+ return { ok: false, error: 'Model not found' };
402
+ }
403
+
404
+ // Increment view count
405
+ model.stats.views += 1;
406
+ saveMarketplaceData();
407
+
408
+ // Get creator profile
409
+ const creatorProfile = getCreatorProfile(model.creatorId);
410
+
411
+ return {
412
+ ok: true,
413
+ model,
414
+ creator: creatorProfile.ok ? creatorProfile.profile : null,
415
+ canEdit: model.creatorId === _currentUser.id,
416
+ canDownload: isPurchased(modelId),
417
+ averageRating: model.stats.rating,
418
+ reviewCount: model.stats.reviewCount
419
+ };
420
+ }
421
+
422
+ /**
423
+ * Get creator profile + stats
424
+ */
425
+ export function getCreatorProfile(creatorId) {
426
+ const models = _allModels.filter(m => m.creatorId === creatorId);
427
+ if (models.length === 0) {
428
+ return { ok: false, error: 'Creator not found' };
429
+ }
430
+
431
+ const totalDownloads = models.reduce((sum, m) => sum + m.stats.downloads, 0);
432
+ const totalViews = models.reduce((sum, m) => sum + m.stats.views, 0);
433
+ const avgRating = models.length > 0
434
+ ? (models.reduce((sum, m) => sum + m.stats.rating, 0) / models.length).toFixed(2)
435
+ : 0;
436
+
437
+ return {
438
+ ok: true,
439
+ profile: {
440
+ creatorId,
441
+ name: models[0].creatorName,
442
+ modelCount: models.length,
443
+ totalDownloads,
444
+ totalViews,
445
+ averageRating: parseFloat(avgRating),
446
+ earnings: calculateEarnings(creatorId),
447
+ topModels: models
448
+ .sort((a, b) => b.stats.downloads - a.stats.downloads)
449
+ .slice(0, 5)
450
+ }
451
+ };
452
+ }
453
+
454
+ // ============================================================================
455
+ // Purchase & Download
456
+ // ============================================================================
457
+
458
+ /**
459
+ * Purchase access to a model at a specific tier
460
+ */
461
+ export function purchaseModel(modelId, tierId) {
462
+ const model = _allModels.find(m => m.id === modelId);
463
+ if (!model) {
464
+ return { ok: false, error: 'Model not found' };
465
+ }
466
+
467
+ // Find tier
468
+ const tierName = Object.keys(ACCESS_TIERS).find(key => ACCESS_TIERS[key].id === tierId);
469
+ if (!tierName) {
470
+ return { ok: false, error: 'Invalid tier ID' };
471
+ }
472
+
473
+ const tier = ACCESS_TIERS[tierName];
474
+ let price = tier.price;
475
+
476
+ // Handle derivative pricing
477
+ if (tierName === 'DERIVATIVE' && model.derivedFromModelId) {
478
+ const parentModel = _allModels.find(m => m.id === model.derivedFromModelId);
479
+ if (parentModel) {
480
+ price = Math.ceil(parentModel.tiers[0].price * 0.15);
481
+ }
482
+ }
483
+
484
+ // Check balance (via token engine)
485
+ if (_tokenEngine && _userBalance < price) {
486
+ return {
487
+ ok: false,
488
+ error: `Insufficient balance. Need ${price} tokens, have ${_userBalance}`
489
+ };
490
+ }
491
+
492
+ const purchaseId = crypto.randomUUID();
493
+ const timestamp = new Date().toISOString();
494
+
495
+ // Record purchase
496
+ const purchase = {
497
+ id: purchaseId,
498
+ userId: _currentUser.id,
499
+ modelId,
500
+ tierId,
501
+ tierName,
502
+ price,
503
+ purchaseDate: timestamp,
504
+ downloadedTimes: 0,
505
+ expiryDate: addDays(new Date(), 365).toISOString() // 1 year license
506
+ };
507
+
508
+ _purchaseHistory.push(purchase);
509
+
510
+ // Deduct tokens (via token engine if available)
511
+ if (_tokenEngine) {
512
+ _userBalance -= price;
513
+ }
514
+
515
+ // Increment model stats
516
+ model.stats.downloads += 1;
517
+ model.stats.purchases += 1;
518
+
519
+ // Log transaction (double-entry ledger)
520
+ logTransaction({
521
+ fromUserId: _currentUser.id,
522
+ toUserId: model.creatorId,
523
+ amount: price,
524
+ type: 'model_purchase',
525
+ relatedId: modelId,
526
+ timestamp
527
+ });
528
+
529
+ saveMarketplaceData();
530
+ emitEvent('modelPurchased', { modelId, tierId, price, purchaseId });
531
+
532
+ return {
533
+ ok: true,
534
+ purchaseId,
535
+ accessUrl: `/download/${modelId}?token=${purchaseId}`,
536
+ expiryDate: purchase.expiryDate
537
+ };
538
+ }
539
+
540
+ /**
541
+ * Download a purchased model
542
+ */
543
+ export function downloadModel(modelId, format = 'stl') {
544
+ // Check if purchased
545
+ if (!isPurchased(modelId)) {
546
+ return { ok: false, error: 'Model not purchased. Please purchase first.' };
547
+ }
548
+
549
+ const model = _allModels.find(m => m.id === modelId);
550
+ if (!model) {
551
+ return { ok: false, error: 'Model not found' };
552
+ }
553
+
554
+ let downloadData = null;
555
+ let mimeType = 'application/octet-stream';
556
+ let filename = `${model.name.replace(/\s+/g, '_')}.${format}`;
557
+
558
+ // Format-specific export
559
+ switch (format.toLowerCase()) {
560
+ case 'stl':
561
+ case 'obj':
562
+ case 'gltf':
563
+ case 'json':
564
+ if (model.sourceGeometry) {
565
+ const geometry = deserializeGeometry(model.sourceGeometry);
566
+ downloadData = exportGeometry(geometry, format);
567
+ mimeType = format === 'json' ? 'application/json' : 'application/octet-stream';
568
+ }
569
+ break;
570
+ case 'step':
571
+ if (model.parametricData) {
572
+ downloadData = model.parametricData;
573
+ mimeType = 'application/step';
574
+ filename = `${model.name.replace(/\s+/g, '_')}.step`;
575
+ }
576
+ break;
577
+ default:
578
+ return { ok: false, error: `Unsupported format: ${format}` };
579
+ }
580
+
581
+ if (!downloadData) {
582
+ return { ok: false, error: `Model doesn't have ${format} export available` };
583
+ }
584
+
585
+ // Record download
586
+ const purchase = _purchaseHistory.find(p => p.modelId === modelId);
587
+ if (purchase) {
588
+ purchase.downloadedTimes += 1;
589
+ }
590
+
591
+ model.stats.downloads += 1;
592
+ saveMarketplaceData();
593
+ emitEvent('modelDownloaded', { modelId, format, filename });
594
+
595
+ return {
596
+ ok: true,
597
+ data: downloadData,
598
+ filename,
599
+ mimeType
600
+ };
601
+ }
602
+
603
+ // ============================================================================
604
+ // Purchase History & Favorites
605
+ // ============================================================================
606
+
607
+ /**
608
+ * Get user's purchase history
609
+ */
610
+ export function getPurchaseHistory() {
611
+ return _purchaseHistory.map(p => {
612
+ const model = _allModels.find(m => m.id === p.modelId);
613
+ return {
614
+ ...p,
615
+ modelName: model?.name || 'Unknown',
616
+ modelCategory: model?.category || '',
617
+ creatorName: model?.creatorName || ''
618
+ };
619
+ });
620
+ }
621
+
622
+ /**
623
+ * Check if user has purchased a model
624
+ */
625
+ function isPurchased(modelId) {
626
+ return _purchaseHistory.some(p => p.modelId === modelId);
627
+ }
628
+
629
+ // ============================================================================
630
+ // Creator Dashboard
631
+ // ============================================================================
632
+
633
+ /**
634
+ * Get creator stats
635
+ */
636
+ export function getCreatorStats(period = 'all') {
637
+ const createdModels = _allModels.filter(m => m.creatorId === _currentUser.id);
638
+
639
+ let filteredModels = createdModels;
640
+ if (period !== 'all') {
641
+ const now = new Date();
642
+ const startDate = new Date();
643
+
644
+ switch (period) {
645
+ case 'day':
646
+ startDate.setDate(now.getDate() - 1);
647
+ break;
648
+ case 'week':
649
+ startDate.setDate(now.getDate() - 7);
650
+ break;
651
+ case 'month':
652
+ startDate.setMonth(now.getMonth() - 1);
653
+ break;
654
+ }
655
+
656
+ filteredModels = createdModels.filter(m =>
657
+ new Date(m.publishedDate) >= startDate
658
+ );
659
+ }
660
+
661
+ const stats = {
662
+ totalEarnings: calculateEarnings(_currentUser.id),
663
+ totalDownloads: filteredModels.reduce((sum, m) => sum + m.stats.downloads, 0),
664
+ totalViews: filteredModels.reduce((sum, m) => sum + m.stats.views, 0),
665
+ modelCount: createdModels.length,
666
+ averageRating: createdModels.length > 0
667
+ ? (createdModels.reduce((sum, m) => sum + m.stats.rating, 0) / createdModels.length).toFixed(2)
668
+ : 0,
669
+ topModel: createdModels.sort((a, b) => b.stats.downloads - a.stats.downloads)[0] || null,
670
+ period
671
+ };
672
+
673
+ return { ok: true, stats };
674
+ }
675
+
676
+ /**
677
+ * List models created by current user
678
+ */
679
+ export function listCreatorModels() {
680
+ return _allModels.filter(m => m.creatorId === _currentUser.id);
681
+ }
682
+
683
+ /**
684
+ * Get earnings breakdown by period
685
+ */
686
+ export function getEarningsBreakdown(period = 'daily') {
687
+ const createdModels = _allModels.filter(m => m.creatorId === _currentUser.id);
688
+ const breakdown = {};
689
+
690
+ createdModels.forEach(model => {
691
+ model.stats.downloads > 0 && model.tiers.forEach(tier => {
692
+ const date = new Date(model.publishedDate);
693
+ const key = getDateKey(date, period);
694
+ breakdown[key] = (breakdown[key] || 0) + (tier.price || 0);
695
+ });
696
+ });
697
+
698
+ return {
699
+ ok: true,
700
+ breakdown,
701
+ period,
702
+ total: Object.values(breakdown).reduce((a, b) => a + b, 0)
703
+ };
704
+ }
705
+
706
+ /**
707
+ * Withdraw earnings (placeholder for Stripe/crypto)
708
+ */
709
+ export function withdrawEarnings(amount, method = 'stripe') {
710
+ const stats = getCreatorStats();
711
+ if (!stats.ok || stats.stats.totalEarnings < amount) {
712
+ return { ok: false, error: 'Insufficient earnings to withdraw' };
713
+ }
714
+
715
+ const withdrawalId = crypto.randomUUID();
716
+ const withdrawal = {
717
+ id: withdrawalId,
718
+ amount,
719
+ method,
720
+ status: 'pending',
721
+ requestDate: new Date().toISOString(),
722
+ completedDate: null
723
+ };
724
+
725
+ // In production: integrate with Stripe API / crypto payment gateway
726
+ console.log('[Marketplace] Withdrawal request:', withdrawal);
727
+ emitEvent('withdrawalRequested', withdrawal);
728
+
729
+ return {
730
+ ok: true,
731
+ withdrawalId,
732
+ status: 'pending',
733
+ message: `Withdrawal of ${amount} tokens requested via ${method}. You'll receive it in 3-5 business days.`
734
+ };
735
+ }
736
+
737
+ // ============================================================================
738
+ // Review System
739
+ // ============================================================================
740
+
741
+ /**
742
+ * Add review to a model
743
+ */
744
+ export function addReview(modelId, rating, comment = '') {
745
+ if (!isPurchased(modelId)) {
746
+ return { ok: false, error: 'Must purchase model to leave review' };
747
+ }
748
+
749
+ const model = _allModels.find(m => m.id === modelId);
750
+ if (!model) {
751
+ return { ok: false, error: 'Model not found' };
752
+ }
753
+
754
+ const review = {
755
+ id: crypto.randomUUID(),
756
+ userId: _currentUser.id,
757
+ userName: _currentUser.name,
758
+ rating: Math.max(1, Math.min(5, Math.round(rating))),
759
+ comment,
760
+ date: new Date().toISOString(),
761
+ helpful: 0
762
+ };
763
+
764
+ model.reviews.push(review);
765
+
766
+ // Recalculate average rating
767
+ const avgRating = model.reviews.length > 0
768
+ ? (model.reviews.reduce((sum, r) => sum + r.rating, 0) / model.reviews.length)
769
+ : 0;
770
+
771
+ model.stats.rating = parseFloat(avgRating.toFixed(1));
772
+ model.stats.reviewCount = model.reviews.length;
773
+
774
+ saveMarketplaceData();
775
+ emitEvent('reviewAdded', { modelId, rating, reviewId: review.id });
776
+
777
+ return { ok: true, review };
778
+ }
779
+
780
+ /**
781
+ * Get reviews for a model (paginated)
782
+ */
783
+ export function getReviews(modelId, page = 0, pageSize = 10) {
784
+ const model = _allModels.find(m => m.id === modelId);
785
+ if (!model) {
786
+ return { ok: false, error: 'Model not found' };
787
+ }
788
+
789
+ const sorted = model.reviews.sort((a, b) =>
790
+ new Date(b.date) - new Date(a.date)
791
+ );
792
+
793
+ const total = sorted.length;
794
+ const paged = sorted.slice(page * pageSize, (page + 1) * pageSize);
795
+
796
+ return {
797
+ ok: true,
798
+ reviews: paged,
799
+ total,
800
+ page,
801
+ pageSize,
802
+ hasMore: (page + 1) * pageSize < total,
803
+ averageRating: model.stats.rating,
804
+ reviewCount: model.stats.reviewCount
805
+ };
806
+ }
807
+
808
+ // ============================================================================
809
+ // UI Panel
810
+ // ============================================================================
811
+
812
+ /**
813
+ * Create marketplace panel HTML
814
+ */
815
+ function createMarketplacePanel() {
816
+ const html = `
817
+ <div id="marketplace-panel" class="mp-panel">
818
+ <div class="mp-header">
819
+ <h2>Model Marketplace</h2>
820
+ <button class="mp-close-btn" data-close-panel="marketplace-panel">✕</button>
821
+ </div>
822
+
823
+ <div class="mp-tabs">
824
+ <button class="mp-tab-btn active" data-tab="browse">Browse</button>
825
+ <button class="mp-tab-btn" data-tab="search">Search</button>
826
+ <button class="mp-tab-btn" data-tab="my-models">My Models</button>
827
+ <button class="mp-tab-btn" data-tab="purchases">Purchases</button>
828
+ <button class="mp-tab-btn" data-tab="publish">Publish</button>
829
+ <button class="mp-tab-btn" data-tab="earnings">Earnings</button>
830
+ </div>
831
+
832
+ <!-- Browse Tab -->
833
+ <div class="mp-tab-content active" data-tab="browse">
834
+ <div class="mp-category-filter">
835
+ <select id="mp-category-select" class="mp-select">
836
+ <option value="">All Categories</option>
837
+ ${MODEL_CATEGORIES.map(cat => `<option value="${cat}">${cat}</option>`).join('')}
838
+ </select>
839
+ <select id="mp-sort-select" class="mp-select">
840
+ <option value="newest">Newest</option>
841
+ <option value="popular">Most Popular</option>
842
+ <option value="price-low">Price: Low to High</option>
843
+ <option value="price-high">Price: High to Low</option>
844
+ <option value="rating">Top Rated</option>
845
+ </select>
846
+ </div>
847
+ <div id="mp-browse-grid" class="mp-grid"></div>
848
+ <button id="mp-browse-more" class="mp-button">Load More</button>
849
+ </div>
850
+
851
+ <!-- Search Tab -->
852
+ <div class="mp-tab-content" data-tab="search">
853
+ <input id="mp-search-input" type="text" class="mp-input" placeholder="Search models...">
854
+ <div class="mp-filters">
855
+ <label class="mp-label">
856
+ Price Range:
857
+ <input id="mp-price-min" type="number" class="mp-input-small" placeholder="Min" min="0">
858
+ <input id="mp-price-max" type="number" class="mp-input-small" placeholder="Max" min="0">
859
+ </label>
860
+ <label class="mp-label">
861
+ Min Rating:
862
+ <input id="mp-rating-min" type="number" class="mp-input-small" placeholder="0-5" min="0" max="5" step="0.5">
863
+ </label>
864
+ </div>
865
+ <div id="mp-search-results" class="mp-grid"></div>
866
+ </div>
867
+
868
+ <!-- My Models Tab -->
869
+ <div class="mp-tab-content" data-tab="my-models">
870
+ <button id="mp-refresh-models" class="mp-button">Refresh</button>
871
+ <div id="mp-my-models-list" class="mp-list"></div>
872
+ </div>
873
+
874
+ <!-- Purchases Tab -->
875
+ <div class="mp-tab-content" data-tab="purchases">
876
+ <div id="mp-purchases-list" class="mp-list"></div>
877
+ </div>
878
+
879
+ <!-- Publish Tab -->
880
+ <div class="mp-tab-content" data-tab="publish">
881
+ <form id="mp-publish-form" class="mp-form">
882
+ <div class="mp-form-group">
883
+ <label class="mp-label">Model Name *</label>
884
+ <input id="mp-publish-name" type="text" class="mp-input" required>
885
+ </div>
886
+ <div class="mp-form-group">
887
+ <label class="mp-label">Description *</label>
888
+ <textarea id="mp-publish-desc" class="mp-input mp-textarea" required></textarea>
889
+ </div>
890
+ <div class="mp-form-group">
891
+ <label class="mp-label">Category *</label>
892
+ <select id="mp-publish-category" class="mp-select" required>
893
+ <option value="">Select category...</option>
894
+ ${MODEL_CATEGORIES.map(cat => `<option value="${cat}">${cat}</option>`).join('')}
895
+ </select>
896
+ </div>
897
+ <div class="mp-form-group">
898
+ <label class="mp-label">Tags (comma-separated)</label>
899
+ <input id="mp-publish-tags" type="text" class="mp-input" placeholder="e.g. metal, cnc, precision">
900
+ </div>
901
+ <div class="mp-form-group">
902
+ <label class="mp-label">Access Tier *</label>
903
+ <select id="mp-publish-tier" class="mp-select" required>
904
+ <option value="1">Free Preview (0 tokens)</option>
905
+ <option value="2">Mesh Download (50 tokens)</option>
906
+ <option value="3">Parametric (200 tokens)</option>
907
+ <option value="4">Full IP (1,000 tokens)</option>
908
+ <option value="5">Commercial (2,000 tokens)</option>
909
+ </select>
910
+ </div>
911
+ <button type="submit" class="mp-button mp-button-primary">Publish Model</button>
912
+ </form>
913
+ </div>
914
+
915
+ <!-- Earnings Tab -->
916
+ <div class="mp-tab-content" data-tab="earnings">
917
+ <div id="mp-earnings-dashboard" class="mp-dashboard"></div>
918
+ <button id="mp-withdraw-btn" class="mp-button">Withdraw Earnings</button>
919
+ </div>
920
+ </div>
921
+
922
+ <!-- Model Detail Modal -->
923
+ <div id="marketplace-modal" class="mp-modal">
924
+ <div class="mp-modal-content">
925
+ <button class="mp-modal-close">✕</button>
926
+ <div class="mp-modal-body">
927
+ <div id="mp-modal-preview" class="mp-preview"></div>
928
+ <div id="mp-modal-info" class="mp-info"></div>
929
+ </div>
930
+ </div>
931
+ </div>
932
+ `;
933
+
934
+ const panel = document.createElement('div');
935
+ panel.innerHTML = html;
936
+ document.body.appendChild(panel);
937
+
938
+ // Wire event handlers
939
+ wireMarketplacePanelEvents();
940
+
941
+ // Add styles
942
+ addMarketplaceStyles();
943
+ }
944
+
945
+ /**
946
+ * Wire marketplace panel events
947
+ */
948
+ function wireMarketplacePanelEvents() {
949
+ // Tab switching
950
+ document.querySelectorAll('.mp-tab-btn').forEach(btn => {
951
+ btn.addEventListener('click', (e) => {
952
+ const tabName = e.target.dataset.tab;
953
+ document.querySelectorAll('.mp-tab-btn').forEach(b => b.classList.remove('active'));
954
+ document.querySelectorAll('.mp-tab-content').forEach(tc => tc.classList.remove('active'));
955
+ e.target.classList.add('active');
956
+ document.querySelector(`.mp-tab-content[data-tab="${tabName}"]`).classList.add('active');
957
+
958
+ // Load tab content
959
+ if (tabName === 'browse') loadBrowseTab();
960
+ if (tabName === 'my-models') loadMyModelsTab();
961
+ if (tabName === 'purchases') loadPurchasesTab();
962
+ if (tabName === 'earnings') loadEarningsTab();
963
+ });
964
+ });
965
+
966
+ // Search
967
+ document.getElementById('mp-search-input')?.addEventListener('input', (e) => {
968
+ const query = e.target.value;
969
+ const filters = {
970
+ priceMin: parseFloat(document.getElementById('mp-price-min')?.value || 0),
971
+ priceMax: parseFloat(document.getElementById('mp-price-max')?.value || Infinity),
972
+ minRating: parseFloat(document.getElementById('mp-rating-min')?.value || 0)
973
+ };
974
+ performSearch(query, filters);
975
+ });
976
+
977
+ // Publish form
978
+ document.getElementById('mp-publish-form')?.addEventListener('submit', (e) => {
979
+ e.preventDefault();
980
+ const name = document.getElementById('mp-publish-name').value;
981
+ const description = document.getElementById('mp-publish-desc').value;
982
+ const category = document.getElementById('mp-publish-category').value;
983
+ const tags = document.getElementById('mp-publish-tags').value.split(',').map(t => t.trim());
984
+ const tierId = parseInt(document.getElementById('mp-publish-tier').value);
985
+
986
+ const result = publishModel({
987
+ name,
988
+ description,
989
+ category,
990
+ tags,
991
+ tiers: [Object.values(ACCESS_TIERS).find(t => t.id === tierId)]
992
+ });
993
+
994
+ if (result.ok) {
995
+ alert(`Model published! ID: ${result.modelId}`);
996
+ document.getElementById('mp-publish-form').reset();
997
+ loadMyModelsTab();
998
+ } else {
999
+ alert('Error: ' + result.error);
1000
+ }
1001
+ });
1002
+
1003
+ // Close modal
1004
+ document.querySelector('.mp-modal-close')?.addEventListener('click', () => {
1005
+ document.getElementById('marketplace-modal').classList.remove('show');
1006
+ });
1007
+
1008
+ // Withdraw earnings
1009
+ document.getElementById('mp-withdraw-btn')?.addEventListener('click', () => {
1010
+ const amount = prompt('Amount to withdraw (tokens):');
1011
+ if (amount && !isNaN(amount)) {
1012
+ const result = withdrawEarnings(parseInt(amount), 'stripe');
1013
+ alert(result.message || 'Withdrawal processed');
1014
+ }
1015
+ });
1016
+ }
1017
+
1018
+ /**
1019
+ * Load browse tab content
1020
+ */
1021
+ function loadBrowseTab() {
1022
+ const category = document.getElementById('mp-category-select')?.value || '';
1023
+ const sort = document.getElementById('mp-sort-select')?.value || 'newest';
1024
+
1025
+ const result = browseModels(category || null, { sort, page: 0 });
1026
+
1027
+ const grid = document.getElementById('mp-browse-grid');
1028
+ if (grid) {
1029
+ grid.innerHTML = result.results
1030
+ .map(model => createModelCard(model))
1031
+ .join('');
1032
+
1033
+ grid.querySelectorAll('.mp-card').forEach(card => {
1034
+ card.addEventListener('click', () => {
1035
+ const modelId = card.dataset.modelId;
1036
+ showModelDetail(modelId);
1037
+ });
1038
+ });
1039
+ }
1040
+ }
1041
+
1042
+ /**
1043
+ * Load my models tab
1044
+ */
1045
+ function loadMyModelsTab() {
1046
+ const myModels = listCreatorModels();
1047
+ const list = document.getElementById('mp-my-models-list');
1048
+
1049
+ if (list) {
1050
+ list.innerHTML = myModels.length === 0
1051
+ ? '<p class="mp-empty">No models published yet</p>'
1052
+ : myModels.map(m => `
1053
+ <div class="mp-item">
1054
+ <div class="mp-item-header">
1055
+ <strong>${m.name}</strong>
1056
+ <span class="mp-item-stats">${m.stats.downloads} downloads, ${m.stats.rating}/5★</span>
1057
+ </div>
1058
+ <p class="mp-item-desc">${m.description}</p>
1059
+ <div class="mp-item-actions">
1060
+ <button class="mp-button-sm" onclick="alert('Edit not yet implemented')">Edit</button>
1061
+ <button class="mp-button-sm" onclick="alert('Stats not yet implemented')">Analytics</button>
1062
+ </div>
1063
+ </div>
1064
+ `).join('');
1065
+ }
1066
+ }
1067
+
1068
+ /**
1069
+ * Load purchases tab
1070
+ */
1071
+ function loadPurchasesTab() {
1072
+ const purchases = getPurchaseHistory();
1073
+ const list = document.getElementById('mp-purchases-list');
1074
+
1075
+ if (list) {
1076
+ list.innerHTML = purchases.length === 0
1077
+ ? '<p class="mp-empty">No purchases yet</p>'
1078
+ : purchases.map(p => `
1079
+ <div class="mp-item">
1080
+ <div class="mp-item-header">
1081
+ <strong>${p.modelName}</strong>
1082
+ <span class="mp-item-stats">${p.price} tokens • ${p.tierName}</span>
1083
+ </div>
1084
+ <p class="mp-item-meta">Creator: ${p.creatorName} • ${new Date(p.purchaseDate).toLocaleDateString()}</p>
1085
+ <div class="mp-item-actions">
1086
+ <button class="mp-button-sm" onclick="downloadPurchasedModel('${p.modelId}', 'stl')">Download STL</button>
1087
+ <button class="mp-button-sm" onclick="downloadPurchasedModel('${p.modelId}', 'json')">Download JSON</button>
1088
+ </div>
1089
+ </div>
1090
+ `).join('');
1091
+ }
1092
+ }
1093
+
1094
+ /**
1095
+ * Load earnings tab
1096
+ */
1097
+ function loadEarningsTab() {
1098
+ const stats = getCreatorStats();
1099
+ const dashboard = document.getElementById('mp-earnings-dashboard');
1100
+
1101
+ if (dashboard && stats.ok) {
1102
+ dashboard.innerHTML = `
1103
+ <div class="mp-kpi-grid">
1104
+ <div class="mp-kpi">
1105
+ <div class="mp-kpi-value">${stats.stats.totalEarnings}</div>
1106
+ <div class="mp-kpi-label">Total Earnings (tokens)</div>
1107
+ </div>
1108
+ <div class="mp-kpi">
1109
+ <div class="mp-kpi-value">${stats.stats.modelCount}</div>
1110
+ <div class="mp-kpi-label">Published Models</div>
1111
+ </div>
1112
+ <div class="mp-kpi">
1113
+ <div class="mp-kpi-value">${stats.stats.totalDownloads}</div>
1114
+ <div class="mp-kpi-label">Total Downloads</div>
1115
+ </div>
1116
+ <div class="mp-kpi">
1117
+ <div class="mp-kpi-value">${stats.stats.averageRating}★</div>
1118
+ <div class="mp-kpi-label">Average Rating</div>
1119
+ </div>
1120
+ </div>
1121
+ `;
1122
+ }
1123
+ }
1124
+
1125
+ /**
1126
+ * Create model card HTML
1127
+ */
1128
+ function createModelCard(model) {
1129
+ const price = model.tiers[0]?.price || 'Free';
1130
+ return `
1131
+ <div class="mp-card" data-model-id="${model.id}">
1132
+ <div class="mp-card-preview">
1133
+ ${model.previewImage ? `<img src="${model.previewImage}" alt="${model.name}">` : '<div class="mp-card-placeholder">📦</div>'}
1134
+ </div>
1135
+ <div class="mp-card-content">
1136
+ <h3 class="mp-card-title">${model.name}</h3>
1137
+ <p class="mp-card-creator">by ${model.creatorName}</p>
1138
+ <p class="mp-card-desc">${model.description.substring(0, 60)}...</p>
1139
+ <div class="mp-card-footer">
1140
+ <span class="mp-card-rating">${model.stats.rating.toFixed(1)}★ (${model.stats.reviewCount})</span>
1141
+ <span class="mp-card-price">${price} tokens</span>
1142
+ </div>
1143
+ </div>
1144
+ </div>
1145
+ `;
1146
+ }
1147
+
1148
+ /**
1149
+ * Show model detail modal
1150
+ */
1151
+ function showModelDetail(modelId) {
1152
+ const details = getModelDetails(modelId);
1153
+ if (!details.ok) {
1154
+ alert('Model not found');
1155
+ return;
1156
+ }
1157
+
1158
+ const { model, creator, canDownload } = details;
1159
+ const modal = document.getElementById('marketplace-modal');
1160
+ const preview = document.getElementById('mp-modal-preview');
1161
+ const info = document.getElementById('mp-modal-info');
1162
+
1163
+ preview.innerHTML = model.previewImage
1164
+ ? `<img src="${model.previewImage}" style="width:100%; max-height:300px;">`
1165
+ : '<div style="height:300px; display:flex; align-items:center; justify-content:center;">No preview available</div>';
1166
+
1167
+ info.innerHTML = `
1168
+ <h2>${model.name}</h2>
1169
+ <p class="mp-creator-link">by ${model.creatorName}</p>
1170
+ <p>${model.description}</p>
1171
+ <div class="mp-metadata">
1172
+ <p><strong>Category:</strong> ${model.category}</p>
1173
+ <p><strong>Polycount:</strong> ${model.metadata.polyCount.toLocaleString()}</p>
1174
+ <p><strong>Rating:</strong> ${model.stats.rating.toFixed(1)}★ (${model.stats.reviewCount} reviews)</p>
1175
+ </div>
1176
+ <div class="mp-tiers">
1177
+ ${model.tiers.map(tier => `
1178
+ <button class="mp-tier-btn" data-tier-id="${tier.id}">
1179
+ ${tier.name} - ${tier.price || 'Custom'} tokens
1180
+ </button>
1181
+ `).join('')}
1182
+ </div>
1183
+ <div class="mp-reviews">
1184
+ <h4>Reviews</h4>
1185
+ ${model.reviews.slice(0, 3).map(r => `
1186
+ <div class="mp-review">
1187
+ <strong>${r.userName}</strong> - ${r.rating}★<br/>
1188
+ ${r.comment}
1189
+ </div>
1190
+ `).join('')}
1191
+ </div>
1192
+ `;
1193
+
1194
+ modal.classList.add('show');
1195
+
1196
+ // Wire tier buttons
1197
+ modal.querySelectorAll('.mp-tier-btn').forEach(btn => {
1198
+ btn.addEventListener('click', () => {
1199
+ const tierId = parseInt(btn.dataset.tierId);
1200
+ const result = purchaseModel(modelId, tierId);
1201
+ if (result.ok) {
1202
+ alert('Purchase successful!');
1203
+ modal.classList.remove('show');
1204
+ } else {
1205
+ alert('Error: ' + result.error);
1206
+ }
1207
+ });
1208
+ });
1209
+ }
1210
+
1211
+ /**
1212
+ * Perform search
1213
+ */
1214
+ function performSearch(query, filters) {
1215
+ const result = searchModels(query, filters);
1216
+ const grid = document.getElementById('mp-search-results');
1217
+
1218
+ if (grid) {
1219
+ grid.innerHTML = result.results.length === 0
1220
+ ? '<p class="mp-empty">No results found</p>'
1221
+ : result.results.map(model => createModelCard(model)).join('');
1222
+
1223
+ grid.querySelectorAll('.mp-card').forEach(card => {
1224
+ card.addEventListener('click', () => {
1225
+ const modelId = card.dataset.modelId;
1226
+ showModelDetail(modelId);
1227
+ });
1228
+ });
1229
+ }
1230
+ }
1231
+
1232
+ // ============================================================================
1233
+ // Helper Functions
1234
+ // ============================================================================
1235
+
1236
+ /**
1237
+ * Generate preview thumbnail from Three.js geometry
1238
+ */
1239
+ function generatePreviewThumbnail(geometry) {
1240
+ // Create hidden canvas + Three.js scene
1241
+ const canvas = document.createElement('canvas');
1242
+ canvas.width = MAX_PREVIEW_SIZE;
1243
+ canvas.height = MAX_PREVIEW_SIZE;
1244
+
1245
+ try {
1246
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
1247
+ renderer.setClearColor(0x2d2d30, 1);
1248
+
1249
+ const scene = new THREE.Scene();
1250
+ const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
1251
+ camera.position.set(100, 100, 100);
1252
+ camera.lookAt(0, 0, 0);
1253
+
1254
+ const material = new THREE.MeshPhongMaterial({ color: 0x58a6ff });
1255
+ const mesh = new THREE.Mesh(geometry, material);
1256
+ scene.add(mesh);
1257
+
1258
+ const light = new THREE.DirectionalLight(0xffffff, 1);
1259
+ light.position.set(100, 100, 100);
1260
+ scene.add(light);
1261
+ scene.add(new THREE.AmbientLight(0xffffff, 0.5));
1262
+
1263
+ renderer.render(scene, camera);
1264
+ renderer.dispose();
1265
+
1266
+ return canvas.toDataURL('image/png');
1267
+ } catch (e) {
1268
+ console.warn('[Marketplace] Preview generation failed:', e);
1269
+ return null;
1270
+ }
1271
+ }
1272
+
1273
+ /**
1274
+ * Serialize Three.js BufferGeometry for storage
1275
+ */
1276
+ function serializeGeometry(geometry) {
1277
+ const positions = geometry.getAttribute('position');
1278
+ const normals = geometry.getAttribute('normal');
1279
+ const indices = geometry.getIndex();
1280
+
1281
+ return {
1282
+ positions: Array.from(positions.array),
1283
+ normals: normals ? Array.from(normals.array) : null,
1284
+ indices: indices ? Array.from(indices.array) : null
1285
+ };
1286
+ }
1287
+
1288
+ /**
1289
+ * Deserialize geometry from stored data
1290
+ */
1291
+ function deserializeGeometry(data) {
1292
+ const geometry = new THREE.BufferGeometry();
1293
+ geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(data.positions), 3));
1294
+ if (data.normals) {
1295
+ geometry.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(data.normals), 3));
1296
+ }
1297
+ if (data.indices) {
1298
+ geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(data.indices), 1));
1299
+ }
1300
+ return geometry;
1301
+ }
1302
+
1303
+ /**
1304
+ * Export geometry in various formats
1305
+ */
1306
+ function exportGeometry(geometry, format) {
1307
+ // Simplified export (full implementation would generate proper file formats)
1308
+ switch (format) {
1309
+ case 'json':
1310
+ return JSON.stringify(serializeGeometry(geometry));
1311
+ case 'stl':
1312
+ case 'obj':
1313
+ case 'gltf':
1314
+ // Would require full exporter implementation
1315
+ return `Exported ${format} format - full implementation pending`;
1316
+ default:
1317
+ return null;
1318
+ }
1319
+ }
1320
+
1321
+ /**
1322
+ * Extract bounding box dimensions
1323
+ */
1324
+ function extractDimensions(geometry) {
1325
+ if (!geometry) return { x: 0, y: 0, z: 0 };
1326
+
1327
+ geometry.computeBoundingBox();
1328
+ const bbox = geometry.boundingBox;
1329
+ return {
1330
+ x: (bbox.max.x - bbox.min.x).toFixed(2),
1331
+ y: (bbox.max.y - bbox.min.y).toFixed(2),
1332
+ z: (bbox.max.z - bbox.min.z).toFixed(2)
1333
+ };
1334
+ }
1335
+
1336
+ /**
1337
+ * Count polygons in geometry
1338
+ */
1339
+ function countPolygons(geometry) {
1340
+ const indices = geometry.getIndex();
1341
+ return indices ? indices.count / 3 : geometry.getAttribute('position').count / 3;
1342
+ }
1343
+
1344
+ /**
1345
+ * Calculate earnings for a creator
1346
+ */
1347
+ function calculateEarnings(creatorId) {
1348
+ const models = _allModels.filter(m => m.creatorId === creatorId);
1349
+ return models.reduce((sum, m) => sum + (m.stats.downloads * (m.tiers[0]?.price || 0)), 0);
1350
+ }
1351
+
1352
+ /**
1353
+ * Log transaction (double-entry ledger)
1354
+ */
1355
+ function logTransaction(tx) {
1356
+ const transaction = {
1357
+ id: crypto.randomUUID(),
1358
+ ...tx,
1359
+ recorded: new Date().toISOString()
1360
+ };
1361
+
1362
+ // In production: store in ledger database
1363
+ console.log('[Marketplace Ledger]', transaction);
1364
+ }
1365
+
1366
+ /**
1367
+ * Get date key for breakdown
1368
+ */
1369
+ function getDateKey(date, period) {
1370
+ switch (period) {
1371
+ case 'daily':
1372
+ return date.toISOString().split('T')[0];
1373
+ case 'weekly':
1374
+ const week = Math.floor((date.getDate() - date.getDay()) / 7);
1375
+ return `${date.getFullYear()}-W${week}`;
1376
+ case 'monthly':
1377
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
1378
+ default:
1379
+ return date.getFullYear().toString();
1380
+ }
1381
+ }
1382
+
1383
+ /**
1384
+ * Add days to date
1385
+ */
1386
+ function addDays(date, days) {
1387
+ const result = new Date(date);
1388
+ result.setDate(result.getDate() + days);
1389
+ return result;
1390
+ }
1391
+
1392
+ /**
1393
+ * Add event listener
1394
+ */
1395
+ function addEventListener(event, callback) {
1396
+ if (!_eventListeners[event]) {
1397
+ _eventListeners[event] = [];
1398
+ }
1399
+ _eventListeners[event].push(callback);
1400
+ }
1401
+
1402
+ /**
1403
+ * Remove event listener
1404
+ */
1405
+ function removeEventListener(event, callback) {
1406
+ if (_eventListeners[event]) {
1407
+ _eventListeners[event] = _eventListeners[event].filter(cb => cb !== callback);
1408
+ }
1409
+ }
1410
+
1411
+ /**
1412
+ * Emit event
1413
+ */
1414
+ function emitEvent(event, data) {
1415
+ if (_eventListeners[event]) {
1416
+ _eventListeners[event].forEach(cb => cb(data));
1417
+ }
1418
+ }
1419
+
1420
+ /**
1421
+ * Add toolbar button
1422
+ */
1423
+ function addMarketplaceToolbarButton() {
1424
+ const btn = document.createElement('button');
1425
+ btn.id = 'marketplace-btn';
1426
+ btn.className = 'toolbar-btn';
1427
+ btn.innerHTML = '🛍️ Marketplace';
1428
+ btn.addEventListener('click', () => {
1429
+ const panel = document.getElementById('marketplace-panel');
1430
+ panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
1431
+ });
1432
+
1433
+ const toolbar = document.getElementById('ce-buttons') || document.querySelector('.toolbar');
1434
+ if (toolbar) {
1435
+ toolbar.appendChild(btn);
1436
+ }
1437
+ }
1438
+
1439
+ /**
1440
+ * Add marketplace styles
1441
+ */
1442
+ function addMarketplaceStyles() {
1443
+ const styles = `
1444
+ #marketplace-panel {
1445
+ position: fixed;
1446
+ right: 20px;
1447
+ top: 100px;
1448
+ width: 600px;
1449
+ height: 700px;
1450
+ background: var(--bg-secondary);
1451
+ border: 1px solid var(--border-color);
1452
+ border-radius: 8px;
1453
+ box-shadow: var(--shadow-lg);
1454
+ display: flex;
1455
+ flex-direction: column;
1456
+ z-index: 1000;
1457
+ font-size: 13px;
1458
+ }
1459
+
1460
+ .mp-header {
1461
+ padding: 16px;
1462
+ border-bottom: 1px solid var(--border-color);
1463
+ display: flex;
1464
+ justify-content: space-between;
1465
+ align-items: center;
1466
+ }
1467
+
1468
+ .mp-header h2 {
1469
+ margin: 0;
1470
+ font-size: 16px;
1471
+ }
1472
+
1473
+ .mp-close-btn {
1474
+ background: none;
1475
+ border: none;
1476
+ color: var(--text-secondary);
1477
+ cursor: pointer;
1478
+ font-size: 18px;
1479
+ }
1480
+
1481
+ .mp-tabs {
1482
+ display: flex;
1483
+ border-bottom: 1px solid var(--border-color);
1484
+ background: var(--bg-tertiary);
1485
+ overflow-x: auto;
1486
+ }
1487
+
1488
+ .mp-tab-btn {
1489
+ flex: 1;
1490
+ padding: 8px 12px;
1491
+ border: none;
1492
+ background: none;
1493
+ color: var(--text-secondary);
1494
+ cursor: pointer;
1495
+ font-size: 12px;
1496
+ white-space: nowrap;
1497
+ border-bottom: 2px solid transparent;
1498
+ }
1499
+
1500
+ .mp-tab-btn.active {
1501
+ color: var(--accent-blue);
1502
+ border-bottom-color: var(--accent-blue);
1503
+ }
1504
+
1505
+ .mp-tab-content {
1506
+ flex: 1;
1507
+ overflow-y: auto;
1508
+ padding: 16px;
1509
+ display: none;
1510
+ }
1511
+
1512
+ .mp-tab-content.active {
1513
+ display: block;
1514
+ }
1515
+
1516
+ .mp-grid {
1517
+ display: grid;
1518
+ grid-template-columns: repeat(2, 1fr);
1519
+ gap: 12px;
1520
+ margin-bottom: 12px;
1521
+ }
1522
+
1523
+ .mp-card {
1524
+ background: var(--bg-tertiary);
1525
+ border: 1px solid var(--border-color);
1526
+ border-radius: 6px;
1527
+ overflow: hidden;
1528
+ cursor: pointer;
1529
+ transition: all var(--transition-base);
1530
+ }
1531
+
1532
+ .mp-card:hover {
1533
+ border-color: var(--accent-blue);
1534
+ transform: translateY(-2px);
1535
+ }
1536
+
1537
+ .mp-card-preview {
1538
+ width: 100%;
1539
+ height: 120px;
1540
+ background: var(--bg-primary);
1541
+ display: flex;
1542
+ align-items: center;
1543
+ justify-content: center;
1544
+ overflow: hidden;
1545
+ }
1546
+
1547
+ .mp-card-preview img {
1548
+ width: 100%;
1549
+ height: 100%;
1550
+ object-fit: cover;
1551
+ }
1552
+
1553
+ .mp-card-placeholder {
1554
+ font-size: 48px;
1555
+ }
1556
+
1557
+ .mp-card-content {
1558
+ padding: 8px;
1559
+ }
1560
+
1561
+ .mp-card-title {
1562
+ font-size: 12px;
1563
+ font-weight: bold;
1564
+ margin: 0 0 4px 0;
1565
+ white-space: nowrap;
1566
+ overflow: hidden;
1567
+ text-overflow: ellipsis;
1568
+ }
1569
+
1570
+ .mp-card-creator {
1571
+ font-size: 11px;
1572
+ color: var(--text-secondary);
1573
+ margin: 0 0 4px 0;
1574
+ }
1575
+
1576
+ .mp-card-desc {
1577
+ font-size: 11px;
1578
+ color: var(--text-muted);
1579
+ margin: 0 0 6px 0;
1580
+ line-height: 1.3;
1581
+ }
1582
+
1583
+ .mp-card-footer {
1584
+ display: flex;
1585
+ justify-content: space-between;
1586
+ font-size: 11px;
1587
+ }
1588
+
1589
+ .mp-card-rating {
1590
+ color: var(--accent-yellow);
1591
+ }
1592
+
1593
+ .mp-card-price {
1594
+ color: var(--accent-green);
1595
+ font-weight: bold;
1596
+ }
1597
+
1598
+ .mp-form {
1599
+ display: flex;
1600
+ flex-direction: column;
1601
+ gap: 12px;
1602
+ }
1603
+
1604
+ .mp-form-group {
1605
+ display: flex;
1606
+ flex-direction: column;
1607
+ gap: 4px;
1608
+ }
1609
+
1610
+ .mp-label {
1611
+ font-size: 12px;
1612
+ font-weight: 500;
1613
+ color: var(--text-primary);
1614
+ }
1615
+
1616
+ .mp-input,
1617
+ .mp-select,
1618
+ .mp-textarea {
1619
+ padding: 6px 8px;
1620
+ background: var(--bg-tertiary);
1621
+ border: 1px solid var(--border-color);
1622
+ color: var(--text-primary);
1623
+ border-radius: 4px;
1624
+ font-size: 12px;
1625
+ }
1626
+
1627
+ .mp-input:focus,
1628
+ .mp-select:focus,
1629
+ .mp-textarea:focus {
1630
+ outline: none;
1631
+ border-color: var(--accent-blue);
1632
+ }
1633
+
1634
+ .mp-textarea {
1635
+ resize: vertical;
1636
+ min-height: 80px;
1637
+ font-family: monospace;
1638
+ }
1639
+
1640
+ .mp-input-small {
1641
+ width: 80px;
1642
+ }
1643
+
1644
+ .mp-button {
1645
+ padding: 8px 12px;
1646
+ background: var(--bg-tertiary);
1647
+ border: 1px solid var(--border-color);
1648
+ color: var(--text-primary);
1649
+ border-radius: 4px;
1650
+ cursor: pointer;
1651
+ font-size: 12px;
1652
+ transition: all var(--transition-fast);
1653
+ }
1654
+
1655
+ .mp-button:hover {
1656
+ background: var(--accent-blue-dark);
1657
+ border-color: var(--accent-blue);
1658
+ }
1659
+
1660
+ .mp-button-primary {
1661
+ background: var(--accent-blue);
1662
+ color: white;
1663
+ }
1664
+
1665
+ .mp-button-primary:hover {
1666
+ background: #4da3ff;
1667
+ }
1668
+
1669
+ .mp-button-sm {
1670
+ padding: 4px 8px;
1671
+ font-size: 11px;
1672
+ }
1673
+
1674
+ .mp-list {
1675
+ display: flex;
1676
+ flex-direction: column;
1677
+ gap: 12px;
1678
+ }
1679
+
1680
+ .mp-item {
1681
+ background: var(--bg-tertiary);
1682
+ border: 1px solid var(--border-color);
1683
+ border-radius: 4px;
1684
+ padding: 12px;
1685
+ }
1686
+
1687
+ .mp-item-header {
1688
+ display: flex;
1689
+ justify-content: space-between;
1690
+ margin-bottom: 8px;
1691
+ }
1692
+
1693
+ .mp-item-stats {
1694
+ font-size: 11px;
1695
+ color: var(--text-secondary);
1696
+ }
1697
+
1698
+ .mp-item-desc {
1699
+ font-size: 12px;
1700
+ color: var(--text-muted);
1701
+ margin: 0 0 8px 0;
1702
+ line-height: 1.4;
1703
+ }
1704
+
1705
+ .mp-item-meta {
1706
+ font-size: 11px;
1707
+ color: var(--text-secondary);
1708
+ margin: 0 0 8px 0;
1709
+ }
1710
+
1711
+ .mp-item-actions {
1712
+ display: flex;
1713
+ gap: 8px;
1714
+ }
1715
+
1716
+ .mp-empty {
1717
+ text-align: center;
1718
+ color: var(--text-secondary);
1719
+ padding: 40px 20px;
1720
+ }
1721
+
1722
+ .mp-kpi-grid {
1723
+ display: grid;
1724
+ grid-template-columns: repeat(2, 1fr);
1725
+ gap: 12px;
1726
+ margin-bottom: 20px;
1727
+ }
1728
+
1729
+ .mp-kpi {
1730
+ background: var(--bg-tertiary);
1731
+ border: 1px solid var(--border-color);
1732
+ border-radius: 4px;
1733
+ padding: 12px;
1734
+ text-align: center;
1735
+ }
1736
+
1737
+ .mp-kpi-value {
1738
+ font-size: 24px;
1739
+ font-weight: bold;
1740
+ color: var(--accent-blue);
1741
+ margin-bottom: 4px;
1742
+ }
1743
+
1744
+ .mp-kpi-label {
1745
+ font-size: 11px;
1746
+ color: var(--text-secondary);
1747
+ }
1748
+
1749
+ .mp-modal {
1750
+ display: none;
1751
+ position: fixed;
1752
+ top: 0;
1753
+ left: 0;
1754
+ width: 100%;
1755
+ height: 100%;
1756
+ background: rgba(0, 0, 0, 0.7);
1757
+ z-index: 2000;
1758
+ align-items: center;
1759
+ justify-content: center;
1760
+ }
1761
+
1762
+ .mp-modal.show {
1763
+ display: flex;
1764
+ }
1765
+
1766
+ .mp-modal-content {
1767
+ background: var(--bg-secondary);
1768
+ border: 1px solid var(--border-color);
1769
+ border-radius: 8px;
1770
+ width: 90%;
1771
+ max-width: 700px;
1772
+ max-height: 80vh;
1773
+ overflow-y: auto;
1774
+ position: relative;
1775
+ }
1776
+
1777
+ .mp-modal-close {
1778
+ position: absolute;
1779
+ top: 12px;
1780
+ right: 12px;
1781
+ background: none;
1782
+ border: none;
1783
+ color: var(--text-secondary);
1784
+ font-size: 20px;
1785
+ cursor: pointer;
1786
+ z-index: 1;
1787
+ }
1788
+
1789
+ .mp-modal-body {
1790
+ padding: 20px;
1791
+ }
1792
+
1793
+ .mp-preview {
1794
+ margin-bottom: 16px;
1795
+ border-radius: 4px;
1796
+ overflow: hidden;
1797
+ }
1798
+
1799
+ .mp-info h2 {
1800
+ margin: 0 0 8px 0;
1801
+ }
1802
+
1803
+ .mp-creator-link {
1804
+ color: var(--accent-blue);
1805
+ font-size: 12px;
1806
+ margin: 0 0 12px 0;
1807
+ }
1808
+
1809
+ .mp-metadata {
1810
+ background: var(--bg-tertiary);
1811
+ border: 1px solid var(--border-color);
1812
+ border-radius: 4px;
1813
+ padding: 12px;
1814
+ margin: 12px 0;
1815
+ font-size: 12px;
1816
+ }
1817
+
1818
+ .mp-metadata p {
1819
+ margin: 6px 0;
1820
+ }
1821
+
1822
+ .mp-tiers {
1823
+ display: flex;
1824
+ flex-direction: column;
1825
+ gap: 8px;
1826
+ margin: 12px 0;
1827
+ }
1828
+
1829
+ .mp-tier-btn {
1830
+ padding: 8px;
1831
+ background: var(--accent-blue-dark);
1832
+ color: white;
1833
+ border: 1px solid var(--accent-blue);
1834
+ border-radius: 4px;
1835
+ cursor: pointer;
1836
+ font-size: 12px;
1837
+ }
1838
+
1839
+ .mp-tier-btn:hover {
1840
+ background: var(--accent-blue);
1841
+ }
1842
+
1843
+ .mp-reviews {
1844
+ margin-top: 16px;
1845
+ }
1846
+
1847
+ .mp-reviews h4 {
1848
+ margin: 0 0 8px 0;
1849
+ }
1850
+
1851
+ .mp-review {
1852
+ background: var(--bg-tertiary);
1853
+ border-left: 3px solid var(--accent-blue);
1854
+ padding: 8px;
1855
+ margin-bottom: 8px;
1856
+ border-radius: 2px;
1857
+ font-size: 12px;
1858
+ line-height: 1.4;
1859
+ }
1860
+
1861
+ .mp-category-filter {
1862
+ display: flex;
1863
+ gap: 8px;
1864
+ margin-bottom: 12px;
1865
+ }
1866
+
1867
+ .mp-filters {
1868
+ display: flex;
1869
+ flex-direction: column;
1870
+ gap: 8px;
1871
+ margin-bottom: 12px;
1872
+ padding: 12px;
1873
+ background: var(--bg-tertiary);
1874
+ border-radius: 4px;
1875
+ }
1876
+
1877
+ .mp-filters .mp-label {
1878
+ display: flex;
1879
+ gap: 8px;
1880
+ align-items: center;
1881
+ font-size: 12px;
1882
+ }
1883
+
1884
+ @media (max-width: 1200px) {
1885
+ #marketplace-panel {
1886
+ width: 500px;
1887
+ }
1888
+ }
1889
+
1890
+ @media (max-width: 768px) {
1891
+ #marketplace-panel {
1892
+ width: calc(100% - 40px);
1893
+ height: calc(100% - 120px);
1894
+ }
1895
+
1896
+ .mp-grid {
1897
+ grid-template-columns: 1fr;
1898
+ }
1899
+ }
1900
+ `;
1901
+
1902
+ const styleEl = document.createElement('style');
1903
+ styleEl.textContent = styles;
1904
+ document.head.appendChild(styleEl);
1905
+ }
1906
+
1907
+ // ============================================================================
1908
+ // Data Persistence
1909
+ // ============================================================================
1910
+
1911
+ /**
1912
+ * Save marketplace data to localStorage
1913
+ */
1914
+ function saveMarketplaceData() {
1915
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({
1916
+ models: _allModels,
1917
+ purchases: _purchaseHistory,
1918
+ createdModels: _createdModels,
1919
+ lastSaved: new Date().toISOString()
1920
+ }));
1921
+ }
1922
+
1923
+ /**
1924
+ * Load marketplace data from localStorage
1925
+ */
1926
+ function loadMarketplaceData() {
1927
+ const stored = localStorage.getItem(STORAGE_KEY);
1928
+
1929
+ if (stored) {
1930
+ try {
1931
+ const data = JSON.parse(stored);
1932
+ _allModels = data.models || [];
1933
+ _purchaseHistory = data.purchases || [];
1934
+ _createdModels = data.createdModels || [];
1935
+ } catch (e) {
1936
+ console.warn('[Marketplace] Failed to load data:', e);
1937
+ }
1938
+ }
1939
+
1940
+ // Populate demo data if empty
1941
+ if (_allModels.length === 0) {
1942
+ populateDemoData();
1943
+ }
1944
+ }
1945
+
1946
+ /**
1947
+ * Populate demo models
1948
+ */
1949
+ function populateDemoData() {
1950
+ const demoModels = [
1951
+ { name: 'M8 Hex Bolt', category: 'Fastener', desc: 'Standard M8 stainless steel hex bolt' },
1952
+ { name: 'Bearing Housing', category: 'Mechanical', desc: 'Cast aluminum bearing housing with bore' },
1953
+ { name: 'L-Bracket 80mm', category: 'Structural', desc: 'Welded steel L-bracket for structural support' },
1954
+ { name: 'IP65 Box 120x80x40', category: 'Enclosure', desc: 'Weather-sealed enclosure for electronics' },
1955
+ { name: 'Parametric Bracket', category: 'Template', desc: 'Configurable bracket with variable dimensions' },
1956
+ { name: 'Shaft Coupler', category: 'Mechanical', desc: 'Flexible coupling for motor shaft' },
1957
+ { name: 'DIN Rail Enclosure', category: 'Enclosure', desc: 'Standard DIN 46277 mounting cabinet' },
1958
+ { name: 'cycleWASH Brush Holder', category: 'Custom', desc: 'Brush assembly holder for washing machine' }
1959
+ ];
1960
+
1961
+ demoModels.forEach((m, i) => {
1962
+ const model = {
1963
+ id: crypto.randomUUID(),
1964
+ creatorId: 'demo_creator',
1965
+ creatorName: 'Demo Creator',
1966
+ name: m.name,
1967
+ description: m.desc,
1968
+ category: m.category,
1969
+ tags: [m.category.toLowerCase()],
1970
+ tiers: [ACCESS_TIERS.MESH_DOWNLOAD],
1971
+ previewImage: null,
1972
+ sourceGeometry: null,
1973
+ parametricData: null,
1974
+ metadata: { dimensions: { x: 50, y: 50, z: 50 }, polyCount: 1000 },
1975
+ stats: {
1976
+ views: Math.floor(Math.random() * 500),
1977
+ downloads: Math.floor(Math.random() * 100),
1978
+ purchases: Math.floor(Math.random() * 50),
1979
+ rating: (Math.random() * 2 + 3).toFixed(1),
1980
+ reviewCount: Math.floor(Math.random() * 20)
1981
+ },
1982
+ reviews: [],
1983
+ publishedDate: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(),
1984
+ updatedDate: new Date().toISOString(),
1985
+ derivedFromModelId: null,
1986
+ derivativeLicense: false
1987
+ };
1988
+ _allModels.push(model);
1989
+ });
1990
+
1991
+ saveMarketplaceData();
1992
+ }
1993
+
1994
+ export { initMarketplace };