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,1449 @@
1
+ /**
2
+ * connected-fabs.js — Connected Fab Shop Network for cycleCAD
3
+ *
4
+ * Implements the "CAD → CAM → Connected Fabs" pipeline from architecture slide 10.
5
+ * Connects designs directly to distributed manufacturing partners for:
6
+ * - CNC machining (3/5-axis milling, turning)
7
+ * - 3D printing (FDM, SLA, SLS)
8
+ * - Laser cutting
9
+ * - Sheet metal (bending, welding)
10
+ * - PCB manufacturing
11
+ * - Injection molding
12
+ *
13
+ * The module manages:
14
+ * - Fab shop registry with capabilities, pricing, locations
15
+ * - Smart routing (find best fab for part requirements)
16
+ * - Job submission with token escrow
17
+ * - Webhook simulation for manufacturing status updates
18
+ * - Job history and tracking
19
+ * - UI panel with 4 tabs (Browse Fabs, Submit Job, My Jobs, Fab Dashboard)
20
+ *
21
+ * Storage: localStorage key 'cyclecad_fab_registry' + 'cyclecad_fab_jobs'
22
+ * Integration: Uses window.cycleCAD.tokens for escrow, fires custom events
23
+ */
24
+
25
+ (function() {
26
+ 'use strict';
27
+
28
+ // ============================================================================
29
+ // Constants
30
+ // ============================================================================
31
+
32
+ const STORAGE_KEYS = {
33
+ registry: 'cyclecad_fab_registry',
34
+ jobs: 'cyclecad_fab_jobs',
35
+ jobCounter: 'cyclecad_job_counter',
36
+ };
37
+
38
+ const JOB_STATES = {
39
+ DRAFT: { label: 'Draft', color: '#a0a0a0', icon: '◯' },
40
+ SUBMITTED: { label: 'Submitted', color: '#58a6ff', icon: '📤' },
41
+ ACCEPTED: { label: 'Accepted', color: '#3fb950', icon: '✓' },
42
+ IN_PROGRESS: { label: 'In Progress', color: '#d29922', icon: '⚙' },
43
+ QC: { label: 'Quality Check', color: '#d29922', icon: '✔' },
44
+ SHIPPED: { label: 'Shipped', color: '#58a6ff', icon: '📦' },
45
+ DELIVERED: { label: 'Delivered', color: '#3fb950', icon: '🏁' },
46
+ COMPLETED: { label: 'Completed', color: '#3fb950', icon: '✓' },
47
+ CANCELLED: { label: 'Cancelled', color: '#f85149', icon: '✕' },
48
+ };
49
+
50
+ const MANUFACTURING_TYPES = {
51
+ '3d_print_fdm': { label: '3D Print (FDM)', cost: 25, unit: 'tokens' },
52
+ '3d_print_sla': { label: '3D Print (SLA)', cost: 35, unit: 'tokens' },
53
+ '3d_print_sls': { label: '3D Print (SLS)', cost: 30, unit: 'tokens' },
54
+ 'laser_cut': { label: 'Laser Cut', cost: 15, unit: 'tokens' },
55
+ 'cnc_3axis': { label: 'CNC 3-Axis', cost: 60, unit: 'tokens' },
56
+ 'cnc_5axis': { label: 'CNC 5-Axis', cost: 100, unit: 'tokens' },
57
+ 'cnc_lathe': { label: 'CNC Lathe (Turning)', cost: 40, unit: 'tokens' },
58
+ 'injection_mold': { label: 'Injection Mold', cost: 250, unit: 'tokens' },
59
+ 'sheet_metal': { label: 'Sheet Metal', cost: 45, unit: 'tokens' },
60
+ 'pcb_mfg': { label: 'PCB Manufacturing', cost: 50, unit: 'tokens' },
61
+ 'waterjet_cut': { label: 'Waterjet Cut', cost: 50, unit: 'tokens' },
62
+ 'bending': { label: 'Sheet Bending', cost: 35, unit: 'tokens' },
63
+ };
64
+
65
+ // ============================================================================
66
+ // Demo Fab Data
67
+ // ============================================================================
68
+
69
+ const DEMO_FABS = [
70
+ {
71
+ id: 'fab_001',
72
+ name: 'Berlin CNC Works',
73
+ location: { city: 'Berlin', country: 'DE', lat: 52.52, lng: 13.41 },
74
+ capabilities: ['cnc_3axis', 'cnc_5axis', 'laser_cut', 'waterjet_cut'],
75
+ materials: ['aluminum', 'steel', 'brass', 'acetal', 'nylon', 'titanium'],
76
+ maxPartSize: { x: 500, y: 500, z: 300 },
77
+ pricing: {
78
+ cnc_3axis: 0.08, // € per mm³
79
+ cnc_5axis: 0.15,
80
+ laser_cut: 0.03, // € per mm
81
+ waterjet_cut: 0.12,
82
+ },
83
+ leadTime: { standard: 5, express: 2 },
84
+ rating: 4.7,
85
+ reviews: 142,
86
+ certifications: ['ISO 9001', 'AS9100', 'DIN 65151'],
87
+ status: 'active',
88
+ webhookUrl: 'https://api.berlincnc.de/webhook',
89
+ apiKey: null,
90
+ description: 'High-precision CNC machining with 40 years experience in automotive',
91
+ },
92
+ {
93
+ id: 'fab_002',
94
+ name: 'Munich Additive',
95
+ location: { city: 'Munich', country: 'DE', lat: 48.14, lng: 11.58 },
96
+ capabilities: ['3d_print_fdm', '3d_print_sla', '3d_print_sls'],
97
+ materials: ['pla', 'petg', 'abs', 'nylon', 'resin_standard', 'resin_tough', 'resin_flex'],
98
+ maxPartSize: { x: 300, y: 300, z: 400 },
99
+ pricing: {
100
+ '3d_print_fdm': 0.02,
101
+ '3d_print_sla': 0.05,
102
+ '3d_print_sls': 0.08,
103
+ },
104
+ leadTime: { standard: 3, express: 1 },
105
+ rating: 4.8,
106
+ reviews: 356,
107
+ certifications: ['ISO 13485'],
108
+ status: 'active',
109
+ webhookUrl: 'https://api.munich-additive.de/webhook',
110
+ apiKey: null,
111
+ description: 'Expert in functional 3D printed parts. Medical and industrial focus.',
112
+ },
113
+ {
114
+ id: 'fab_003',
115
+ name: 'Rotterdam Metal',
116
+ location: { city: 'Rotterdam', country: 'NL', lat: 51.92, lng: 4.47 },
117
+ capabilities: ['cnc_3axis', 'cnc_lathe', 'bending', 'sheet_metal', 'waterjet_cut'],
118
+ materials: ['steel', 'aluminum', 'copper', 'brass', 'stainless_steel'],
119
+ maxPartSize: { x: 1000, y: 800, z: 500 },
120
+ pricing: {
121
+ cnc_3axis: 0.07,
122
+ cnc_lathe: 0.06,
123
+ bending: 0.04,
124
+ sheet_metal: 0.05,
125
+ waterjet_cut: 0.10,
126
+ },
127
+ leadTime: { standard: 4, express: 2 },
128
+ rating: 4.5,
129
+ reviews: 89,
130
+ certifications: ['ISO 9001', 'ISO 14001'],
131
+ status: 'active',
132
+ webhookUrl: 'https://api.rotterdam-metal.nl/webhook',
133
+ apiKey: null,
134
+ description: 'Full-service metal fabrication. Specializes in large assemblies and sheet metal.',
135
+ },
136
+ {
137
+ id: 'fab_004',
138
+ name: 'Lyon Precision',
139
+ location: { city: 'Lyon', country: 'FR', lat: 45.76, lng: 4.84 },
140
+ capabilities: ['cnc_5axis', 'cnc_lathe', 'laser_cut'],
141
+ materials: ['aluminum', 'titanium', 'steel', 'inconel', 'carbon_fiber'],
142
+ maxPartSize: { x: 400, y: 400, z: 250 },
143
+ pricing: {
144
+ cnc_5axis: 0.16,
145
+ cnc_lathe: 0.07,
146
+ laser_cut: 0.04,
147
+ },
148
+ leadTime: { standard: 6, express: 3 },
149
+ rating: 4.9,
150
+ reviews: 234,
151
+ certifications: ['ISO 9001', 'AS9100', 'NADCAP'],
152
+ status: 'active',
153
+ webhookUrl: 'https://api.lyon-precision.fr/webhook',
154
+ apiKey: null,
155
+ description: 'Aerospace-grade precision. 5-axis work on exotic materials.',
156
+ },
157
+ {
158
+ id: 'fab_005',
159
+ name: 'Milano Rapid',
160
+ location: { city: 'Milan', country: 'IT', lat: 45.46, lng: 9.19 },
161
+ capabilities: ['3d_print_sla', '3d_print_sls', 'injection_mold'],
162
+ materials: ['resin_standard', 'resin_tough', 'nylon_sls', 'thermoplastic_polyurethane'],
163
+ maxPartSize: { x: 250, y: 250, z: 300 },
164
+ pricing: {
165
+ '3d_print_sla': 0.06,
166
+ '3d_print_sls': 0.09,
167
+ injection_mold: 0.25,
168
+ },
169
+ leadTime: { standard: 2, express: 1 },
170
+ rating: 4.6,
171
+ reviews: 178,
172
+ certifications: ['ISO 9001', 'IATF 16949'],
173
+ status: 'active',
174
+ webhookUrl: 'https://api.milano-rapid.it/webhook',
175
+ apiKey: null,
176
+ description: 'Rapid prototyping and low-volume injection molding. Fashion tech partner.',
177
+ },
178
+ {
179
+ id: 'fab_006',
180
+ name: 'Barcelona Sheet',
181
+ location: { city: 'Barcelona', country: 'ES', lat: 41.39, lng: 2.17 },
182
+ capabilities: ['laser_cut', 'bending', 'sheet_metal', 'waterjet_cut'],
183
+ materials: ['steel', 'aluminum', 'acrylic', 'wood', 'cardboard', 'leather'],
184
+ maxPartSize: { x: 2000, y: 1000, z: 50 },
185
+ pricing: {
186
+ laser_cut: 0.02,
187
+ bending: 0.03,
188
+ sheet_metal: 0.04,
189
+ waterjet_cut: 0.08,
190
+ },
191
+ leadTime: { standard: 3, express: 1 },
192
+ rating: 4.4,
193
+ reviews: 125,
194
+ certifications: ['ISO 9001'],
195
+ status: 'active',
196
+ webhookUrl: 'https://api.barcelona-sheet.es/webhook',
197
+ apiKey: null,
198
+ description: 'Large-format sheet cutting and bending. Industrial design partner.',
199
+ },
200
+ {
201
+ id: 'fab_007',
202
+ name: 'Prague PCB',
203
+ location: { city: 'Prague', country: 'CZ', lat: 50.08, lng: 14.44 },
204
+ capabilities: ['pcb_mfg'],
205
+ materials: ['fr4', 'cem1', 'polyimide', 'ceramic'],
206
+ maxPartSize: { x: 500, y: 500, z: 8 },
207
+ pricing: {
208
+ pcb_mfg: 0.15, // € per cm² for single-sided prototype
209
+ },
210
+ leadTime: { standard: 7, express: 3 },
211
+ rating: 4.3,
212
+ reviews: 67,
213
+ certifications: ['ISO 9001', 'IPC-A-600'],
214
+ status: 'active',
215
+ webhookUrl: 'https://api.prague-pcb.cz/webhook',
216
+ apiKey: null,
217
+ description: 'PCB design, manufacturing, and assembly. IoT and wearables focus.',
218
+ },
219
+ {
220
+ id: 'fab_008',
221
+ name: 'Vienna Mold',
222
+ location: { city: 'Vienna', country: 'AT', lat: 48.21, lng: 16.37 },
223
+ capabilities: ['injection_mold', 'bending'],
224
+ materials: ['abs', 'polycarbonate', 'nylon', 'polypropylene', 'pps'],
225
+ maxPartSize: { x: 400, y: 300, z: 200 },
226
+ pricing: {
227
+ injection_mold: 0.28,
228
+ },
229
+ leadTime: { standard: 14, express: 7 },
230
+ rating: 4.7,
231
+ reviews: 98,
232
+ certifications: ['ISO 9001', 'IATF 16949', 'ISO 50001'],
233
+ status: 'active',
234
+ webhookUrl: 'https://api.vienna-mold.at/webhook',
235
+ apiKey: null,
236
+ description: 'Injection molding & blow molding. Consumer products and automotive.',
237
+ },
238
+ ];
239
+
240
+ // ============================================================================
241
+ // State
242
+ // ============================================================================
243
+
244
+ let fabRegistry = [];
245
+ let jobs = {};
246
+ let jobCounter = 0;
247
+ let eventListeners = {};
248
+
249
+ // ============================================================================
250
+ // Initialization
251
+ // ============================================================================
252
+
253
+ function init() {
254
+ loadRegistry();
255
+ loadJobs();
256
+ if (fabRegistry.length === 0) {
257
+ // First run — populate with demo fabs
258
+ DEMO_FABS.forEach(fab => registerFab(fab));
259
+ }
260
+ console.log(`[Connected Fabs] Initialized. ${fabRegistry.length} fabs, ${Object.keys(jobs).length} jobs.`);
261
+ }
262
+
263
+ // ============================================================================
264
+ // Fab Registry Management
265
+ // ============================================================================
266
+
267
+ /**
268
+ * Register a new fab shop
269
+ */
270
+ function registerFab(fabData) {
271
+ const fab = {
272
+ ...fabData,
273
+ id: fabData.id || 'fab_' + Date.now(),
274
+ createdAt: fabData.createdAt || new Date().toISOString(),
275
+ lastActive: fabData.lastActive || new Date().toISOString(),
276
+ jobsCompleted: fabData.jobsCompleted || 0,
277
+ };
278
+
279
+ const existing = fabRegistry.findIndex(f => f.id === fab.id);
280
+ if (existing >= 0) {
281
+ fabRegistry[existing] = fab;
282
+ } else {
283
+ fabRegistry.push(fab);
284
+ }
285
+
286
+ saveRegistry();
287
+ emit('fab-registered', { fabId: fab.id, name: fab.name });
288
+ return fab;
289
+ }
290
+
291
+ /**
292
+ * Get all fabs, optionally filtered
293
+ */
294
+ function listFabs(filters = {}) {
295
+ let results = fabRegistry;
296
+
297
+ if (filters.capability) {
298
+ results = results.filter(fab =>
299
+ fab.capabilities.includes(filters.capability)
300
+ );
301
+ }
302
+
303
+ if (filters.material) {
304
+ results = results.filter(fab =>
305
+ fab.materials.includes(filters.material)
306
+ );
307
+ }
308
+
309
+ if (filters.country) {
310
+ results = results.filter(fab =>
311
+ fab.location.country === filters.country
312
+ );
313
+ }
314
+
315
+ if (filters.minRating) {
316
+ results = results.filter(fab =>
317
+ fab.rating >= filters.minRating
318
+ );
319
+ }
320
+
321
+ if (filters.status) {
322
+ results = results.filter(fab =>
323
+ fab.status === filters.status
324
+ );
325
+ }
326
+
327
+ return results;
328
+ }
329
+
330
+ /**
331
+ * Get a specific fab by ID
332
+ */
333
+ function getFab(fabId) {
334
+ return fabRegistry.find(f => f.id === fabId) || null;
335
+ }
336
+
337
+ /**
338
+ * Update fab info
339
+ */
340
+ function updateFab(fabId, updates) {
341
+ const fab = getFab(fabId);
342
+ if (!fab) return null;
343
+
344
+ Object.assign(fab, updates, {
345
+ lastActive: new Date().toISOString(),
346
+ });
347
+
348
+ saveRegistry();
349
+ emit('fab-updated', { fabId, updates });
350
+ return fab;
351
+ }
352
+
353
+ /**
354
+ * Remove a fab from registry
355
+ */
356
+ function removeFab(fabId) {
357
+ fabRegistry = fabRegistry.filter(f => f.id !== fabId);
358
+ saveRegistry();
359
+ emit('fab-removed', { fabId });
360
+ }
361
+
362
+ // ============================================================================
363
+ // Smart Routing & Matching
364
+ // ============================================================================
365
+
366
+ /**
367
+ * Find the best fab(s) for a manufacturing job
368
+ * Considers: capability match, size fit, material, price, lead time, rating, distance
369
+ */
370
+ function findBestFab(requirements) {
371
+ const {
372
+ capability, // string: 'cnc_3axis', '3d_print_fdm', etc.
373
+ material, // string: 'steel', 'aluminum', etc.
374
+ partSize, // { x, y, z } in mm
375
+ quantity, // number of parts
376
+ maxPrice, // optional budget in € per unit
377
+ maxLeadTime, // optional max days for standard
378
+ userLocation, // optional { lat, lng } for distance calc
379
+ } = requirements;
380
+
381
+ let candidates = fabRegistry.filter(fab =>
382
+ fab.status === 'active' &&
383
+ fab.capabilities.includes(capability)
384
+ );
385
+
386
+ // Filter by material
387
+ if (material) {
388
+ candidates = candidates.filter(fab =>
389
+ fab.materials.includes(material)
390
+ );
391
+ }
392
+
393
+ // Filter by size fit
394
+ if (partSize) {
395
+ candidates = candidates.filter(fab =>
396
+ fab.maxPartSize.x >= partSize.x &&
397
+ fab.maxPartSize.y >= partSize.y &&
398
+ fab.maxPartSize.z >= partSize.z
399
+ );
400
+ }
401
+
402
+ // Score and rank candidates
403
+ const scored = candidates.map(fab => {
404
+ let score = 100;
405
+
406
+ // Material match bonus
407
+ if (material && fab.materials.includes(material)) {
408
+ score += 20;
409
+ }
410
+
411
+ // Rating bonus
412
+ score += fab.rating * 5;
413
+
414
+ // Lead time bonus (prefer faster)
415
+ if (maxLeadTime) {
416
+ if (fab.leadTime.standard <= maxLeadTime) {
417
+ score += 30;
418
+ }
419
+ }
420
+
421
+ // Price consideration (lower is better)
422
+ const basePrice = fab.pricing[capability] || 0.1;
423
+ if (maxPrice && basePrice <= maxPrice) {
424
+ score += 15;
425
+ }
426
+
427
+ // Distance penalty (if location provided)
428
+ let distance = 0;
429
+ if (userLocation && fab.location.lat && fab.location.lng) {
430
+ distance = haversineDistance(
431
+ userLocation.lat, userLocation.lng,
432
+ fab.location.lat, fab.location.lng
433
+ );
434
+ score -= (distance / 100); // 100 km = 1 point penalty
435
+ }
436
+
437
+ return { fab, score, distance };
438
+ });
439
+
440
+ // Sort by score (highest first)
441
+ scored.sort((a, b) => b.score - a.score);
442
+
443
+ return scored;
444
+ }
445
+
446
+ /**
447
+ * Get a quote from a specific fab
448
+ */
449
+ function getQuote(fabId, jobData) {
450
+ const fab = getFab(fabId);
451
+ if (!fab) return null;
452
+
453
+ const {
454
+ capability,
455
+ partSize, // { x, y, z }
456
+ quantity,
457
+ material,
458
+ urgency, // 'standard' or 'express'
459
+ } = jobData;
460
+
461
+ if (!fab.capabilities.includes(capability)) {
462
+ return { error: 'Fab does not have this capability' };
463
+ }
464
+
465
+ const basePrice = fab.pricing[capability] || 0.1;
466
+ let totalCost = basePrice;
467
+
468
+ // Estimate based on geometry
469
+ if (partSize) {
470
+ const volume = (partSize.x * partSize.y * partSize.z) / 1000; // cm³
471
+ totalCost = basePrice * volume * quantity;
472
+ }
473
+
474
+ // Urgency surcharge
475
+ const leadTime = urgency === 'express' ? fab.leadTime.express : fab.leadTime.standard;
476
+ const expedite = urgency === 'express' ? 1.3 : 1.0;
477
+
478
+ totalCost *= expedite;
479
+
480
+ return {
481
+ fabId,
482
+ fabName: fab.name,
483
+ basePrice,
484
+ quantity,
485
+ totalCost: Math.round(totalCost * 100) / 100,
486
+ currency: '€',
487
+ leadDays: leadTime,
488
+ material,
489
+ capability: MANUFACTURING_TYPES[capability]?.label || capability,
490
+ };
491
+ }
492
+
493
+ // ============================================================================
494
+ // Job Management
495
+ // ============================================================================
496
+
497
+ /**
498
+ * Submit a job for manufacturing
499
+ * Creates job, routes to best fab, holds tokens in escrow
500
+ */
501
+ function submitJob(jobData) {
502
+ const {
503
+ name, // string: 'Part name'
504
+ capability, // string: manufacturing type
505
+ material, // string
506
+ partSize, // { x, y, z }
507
+ quantity, // number
508
+ description, // string
509
+ urgency, // 'standard' or 'express'
510
+ userLocation, // optional { lat, lng } for routing
511
+ fabricFile, // optional: imported CAD file reference
512
+ } = jobData;
513
+
514
+ // Find best fab
515
+ const fabResults = findBestFab({
516
+ capability,
517
+ material,
518
+ partSize,
519
+ quantity,
520
+ userLocation,
521
+ });
522
+
523
+ if (fabResults.length === 0) {
524
+ return { error: 'No fabs available for this job' };
525
+ }
526
+
527
+ const selectedFab = fabResults[0].fab;
528
+ const quote = getQuote(selectedFab.id, jobData);
529
+
530
+ // Create job record
531
+ jobCounter++;
532
+ const jobId = 'job_' + jobCounter;
533
+ const job = {
534
+ id: jobId,
535
+ name,
536
+ description,
537
+ status: 'SUBMITTED',
538
+ capability,
539
+ material,
540
+ partSize,
541
+ quantity,
542
+ urgency,
543
+ fabricFile,
544
+ fabId: selectedFab.id,
545
+ fabName: selectedFab.name,
546
+ quote,
547
+ costInTokens: Math.round(MANUFACTURING_TYPES[capability]?.cost || 50),
548
+ escrowId: null,
549
+ createdAt: new Date().toISOString(),
550
+ submittedAt: new Date().toISOString(),
551
+ acceptedAt: null,
552
+ completedAt: null,
553
+ notes: [],
554
+ webhookEvents: [],
555
+ };
556
+
557
+ // Create token escrow if token engine available
558
+ if (window.cycleCAD && window.cycleCAD.tokens && window.cycleCAD.tokens.createEscrow) {
559
+ try {
560
+ const escrow = window.cycleCAD.tokens.createEscrow(
561
+ job.costInTokens,
562
+ jobId,
563
+ selectedFab.id,
564
+ { jobName: name, manufacturingType: capability }
565
+ );
566
+ job.escrowId = escrow.id;
567
+ } catch (e) {
568
+ console.warn('[Connected Fabs] Token escrow failed:', e.message);
569
+ }
570
+ }
571
+
572
+ jobs[jobId] = job;
573
+ saveJobs();
574
+ saveJobCounter();
575
+
576
+ emit('job-submitted', {
577
+ jobId,
578
+ fabId: selectedFab.id,
579
+ fabName: selectedFab.name,
580
+ costInTokens: job.costInTokens,
581
+ });
582
+
583
+ return job;
584
+ }
585
+
586
+ /**
587
+ * Get a job by ID
588
+ */
589
+ function getJob(jobId) {
590
+ return jobs[jobId] || null;
591
+ }
592
+
593
+ /**
594
+ * List jobs with optional filters
595
+ */
596
+ function listJobs(filters = {}) {
597
+ let results = Object.values(jobs);
598
+
599
+ if (filters.status) {
600
+ results = results.filter(j => j.status === filters.status);
601
+ }
602
+
603
+ if (filters.fabId) {
604
+ results = results.filter(j => j.fabId === filters.fabId);
605
+ }
606
+
607
+ if (filters.material) {
608
+ results = results.filter(j => j.material === filters.material);
609
+ }
610
+
611
+ // Sort by creation date (newest first)
612
+ results.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
613
+
614
+ return results;
615
+ }
616
+
617
+ /**
618
+ * Cancel a job and release escrow
619
+ */
620
+ function cancelJob(jobId) {
621
+ const job = getJob(jobId);
622
+ if (!job) return null;
623
+
624
+ const previousStatus = job.status;
625
+ job.status = 'CANCELLED';
626
+ job.completedAt = new Date().toISOString();
627
+
628
+ // Release escrow if exists
629
+ if (job.escrowId && window.cycleCAD && window.cycleCAD.tokens) {
630
+ try {
631
+ window.cycleCAD.tokens.cancelEscrow(job.escrowId);
632
+ } catch (e) {
633
+ console.warn('[Connected Fabs] Escrow cancel failed:', e.message);
634
+ }
635
+ }
636
+
637
+ saveJobs();
638
+ emit('job-cancelled', { jobId, previousStatus });
639
+
640
+ return job;
641
+ }
642
+
643
+ /**
644
+ * Rate a completed job
645
+ */
646
+ function rateJob(jobId, rating, review = '') {
647
+ const job = getJob(jobId);
648
+ if (!job) return null;
649
+
650
+ job.rating = Math.max(1, Math.min(5, rating));
651
+ job.review = review;
652
+ job.ratedAt = new Date().toISOString();
653
+
654
+ // Update fab ratings
655
+ const fab = getFab(job.fabId);
656
+ if (fab) {
657
+ const oldRating = fab.rating;
658
+ const totalReviews = fab.reviews || 0;
659
+ fab.rating = (oldRating * totalReviews + rating) / (totalReviews + 1);
660
+ fab.reviews = totalReviews + 1;
661
+ saveRegistry();
662
+ }
663
+
664
+ saveJobs();
665
+ emit('job-rated', { jobId, rating, review });
666
+
667
+ return job;
668
+ }
669
+
670
+ // ============================================================================
671
+ // Webhook System (Fab Status Updates)
672
+ // ============================================================================
673
+
674
+ /**
675
+ * Simulate a webhook event from a fab
676
+ * In production, fabs would POST to your webhook endpoint
677
+ */
678
+ function simulateWebhook(fabId, jobId, event, data = {}) {
679
+ const job = getJob(jobId);
680
+ if (!job) return { error: 'Job not found' };
681
+
682
+ const fab = getFab(fabId);
683
+ if (!fab) return { error: 'Fab not found' };
684
+
685
+ if (job.fabId !== fabId) {
686
+ return { error: 'Job is not assigned to this fab' };
687
+ }
688
+
689
+ // Validate event type
690
+ const validEvents = ['job.accepted', 'job.started', 'job.qc_passed', 'job.shipped', 'job.delivered'];
691
+ if (!validEvents.includes(event)) {
692
+ return { error: `Unknown event: ${event}` };
693
+ }
694
+
695
+ // Update job status based on event
696
+ const eventToStatus = {
697
+ 'job.accepted': 'ACCEPTED',
698
+ 'job.started': 'IN_PROGRESS',
699
+ 'job.qc_passed': 'QC',
700
+ 'job.shipped': 'SHIPPED',
701
+ 'job.delivered': 'DELIVERED',
702
+ };
703
+
704
+ const newStatus = eventToStatus[event];
705
+ job.status = newStatus;
706
+
707
+ if (event === 'job.accepted' && !job.acceptedAt) {
708
+ job.acceptedAt = new Date().toISOString();
709
+ }
710
+
711
+ if (event === 'job.delivered' && !job.completedAt) {
712
+ job.completedAt = new Date().toISOString();
713
+ fab.jobsCompleted = (fab.jobsCompleted || 0) + 1;
714
+ }
715
+
716
+ // Record webhook event
717
+ job.webhookEvents.push({
718
+ event,
719
+ timestamp: new Date().toISOString(),
720
+ data,
721
+ fabName: fab.name,
722
+ });
723
+
724
+ saveJobs();
725
+ saveRegistry();
726
+
727
+ emit('job-status-changed', {
728
+ jobId,
729
+ oldStatus: job.status,
730
+ newStatus,
731
+ event,
732
+ });
733
+
734
+ console.log(`[Connected Fabs] Webhook: ${event} for job ${jobId}`);
735
+
736
+ return { ok: true, event, status: newStatus };
737
+ }
738
+
739
+ /**
740
+ * Get all webhook events for a job
741
+ */
742
+ function getWebhookLog(jobId) {
743
+ const job = getJob(jobId);
744
+ if (!job) return [];
745
+ return job.webhookEvents || [];
746
+ }
747
+
748
+ // ============================================================================
749
+ // UI Panel
750
+ // ============================================================================
751
+
752
+ /**
753
+ * Generate HTML for Connected Fabs panel
754
+ */
755
+ function getPanelHTML() {
756
+ return `
757
+ <div id="connected-fabs-panel" style="
758
+ display: none;
759
+ position: fixed;
760
+ right: 0;
761
+ top: 48px;
762
+ width: 600px;
763
+ height: calc(100% - 48px);
764
+ background: var(--bg-secondary);
765
+ border-left: 1px solid var(--border-color);
766
+ flex-direction: column;
767
+ z-index: 200;
768
+ box-shadow: var(--shadow-lg);
769
+ ">
770
+ <!-- Tab Navigation -->
771
+ <div style="
772
+ display: flex;
773
+ border-bottom: 1px solid var(--border-color);
774
+ background: var(--bg-tertiary);
775
+ ">
776
+ <button data-tab="fabs" class="fab-tab-btn" style="
777
+ flex: 1;
778
+ padding: 8px;
779
+ border: none;
780
+ background: none;
781
+ color: var(--accent-blue);
782
+ cursor: pointer;
783
+ font-weight: 500;
784
+ border-bottom: 2px solid var(--accent-blue);
785
+ ">🏭 Browse Fabs</button>
786
+ <button data-tab="submit" class="fab-tab-btn" style="
787
+ flex: 1;
788
+ padding: 8px;
789
+ border: none;
790
+ background: none;
791
+ color: var(--text-secondary);
792
+ cursor: pointer;
793
+ font-weight: 500;
794
+ ">📤 Submit Job</button>
795
+ <button data-tab="jobs" class="fab-tab-btn" style="
796
+ flex: 1;
797
+ padding: 8px;
798
+ border: none;
799
+ background: none;
800
+ color: var(--text-secondary);
801
+ cursor: pointer;
802
+ font-weight: 500;
803
+ ">📋 My Jobs</button>
804
+ <button data-tab="dashboard" class="fab-tab-btn" style="
805
+ flex: 1;
806
+ padding: 8px;
807
+ border: none;
808
+ background: none;
809
+ color: var(--text-secondary);
810
+ cursor: pointer;
811
+ font-weight: 500;
812
+ ">⚙ Dashboard</button>
813
+ </div>
814
+
815
+ <!-- Content Area -->
816
+ <div id="fab-panel-content" style="
817
+ flex: 1;
818
+ overflow-y: auto;
819
+ padding: 12px;
820
+ "></div>
821
+ </div>
822
+ `;
823
+ }
824
+
825
+ /**
826
+ * Render Browse Fabs tab
827
+ */
828
+ function renderBrowseFabs() {
829
+ const html = `
830
+ <div>
831
+ <h3 style="margin-bottom: 12px; color: var(--accent-blue);">Connected Fab Network</h3>
832
+
833
+ <div style="margin-bottom: 16px; padding: 8px; background: var(--bg-tertiary); border-radius: 4px;">
834
+ <input type="text" id="fab-search" placeholder="Search by name or location..." style="
835
+ width: 100%;
836
+ padding: 6px;
837
+ background: var(--bg-secondary);
838
+ border: 1px solid var(--border-color);
839
+ color: var(--text-primary);
840
+ border-radius: 3px;
841
+ font-size: 12px;
842
+ ">
843
+ </div>
844
+
845
+ <div id="fab-list" style="display: flex; flex-direction: column; gap: 8px;">
846
+ ${fabRegistry.map(fab => `
847
+ <div style="
848
+ padding: 12px;
849
+ background: var(--bg-tertiary);
850
+ border: 1px solid var(--border-color);
851
+ border-radius: 4px;
852
+ cursor: pointer;
853
+ transition: all 150ms;
854
+ " onmouseover="this.style.background='var(--bg-primary)'" onmouseout="this.style.background='var(--bg-tertiary)'">
855
+ <div style="display: flex; justify-content: space-between; align-items: center;">
856
+ <strong>${fab.name}</strong>
857
+ <span style="color: #DB2777; font-weight: bold;">★ ${fab.rating.toFixed(1)}</span>
858
+ </div>
859
+ <div style="font-size: 11px; color: var(--text-secondary); margin-top: 4px;">
860
+ 📍 ${fab.location.city}, ${fab.location.country}
861
+ • ${fab.reviews} reviews
862
+ • Lead: ${fab.leadTime.standard}d / ${fab.leadTime.express}d express
863
+ </div>
864
+ <div style="font-size: 11px; color: var(--text-muted); margin-top: 6px;">
865
+ ${fab.capabilities.map(c => `<span style="
866
+ display: inline-block;
867
+ padding: 2px 6px;
868
+ background: var(--accent-blue-dark);
869
+ border-radius: 2px;
870
+ margin-right: 4px;
871
+ ">${MANUFACTURING_TYPES[c]?.label || c}</span>`).join('')}
872
+ </div>
873
+ <div style="font-size: 10px; color: var(--text-muted); margin-top: 6px;">
874
+ Materials: ${fab.materials.join(', ')}
875
+ </div>
876
+ <div style="margin-top: 8px;">
877
+ <button onclick="window.cycleCAD.fabs._showFabDetails('${fab.id}')" style="
878
+ padding: 4px 8px;
879
+ background: var(--accent-blue);
880
+ color: white;
881
+ border: none;
882
+ border-radius: 2px;
883
+ cursor: pointer;
884
+ font-size: 11px;
885
+ ">View Details</button>
886
+ </div>
887
+ </div>
888
+ `).join('')}
889
+ </div>
890
+ </div>
891
+ `;
892
+ return html;
893
+ }
894
+
895
+ /**
896
+ * Render Submit Job tab
897
+ */
898
+ function renderSubmitJob() {
899
+ const capabilities = Object.entries(MANUFACTURING_TYPES).map(([k, v]) => k);
900
+ const html = `
901
+ <div>
902
+ <h3 style="margin-bottom: 12px; color: var(--accent-blue);">Submit Manufacturing Job</h3>
903
+
904
+ <form id="fab-submit-form" style="display: flex; flex-direction: column; gap: 12px;">
905
+ <div>
906
+ <label style="display: block; margin-bottom: 4px; font-size: 11px; color: var(--text-secondary);">Part Name</label>
907
+ <input type="text" name="name" placeholder="e.g., Bracket Assembly" style="
908
+ width: 100%;
909
+ padding: 6px;
910
+ background: var(--bg-tertiary);
911
+ border: 1px solid var(--border-color);
912
+ color: var(--text-primary);
913
+ border-radius: 3px;
914
+ font-size: 12px;
915
+ ">
916
+ </div>
917
+
918
+ <div>
919
+ <label style="display: block; margin-bottom: 4px; font-size: 11px; color: var(--text-secondary);">Manufacturing Type</label>
920
+ <select name="capability" style="
921
+ width: 100%;
922
+ padding: 6px;
923
+ background: var(--bg-tertiary);
924
+ border: 1px solid var(--border-color);
925
+ color: var(--text-primary);
926
+ border-radius: 3px;
927
+ font-size: 12px;
928
+ ">
929
+ <option value="">Select...</option>
930
+ ${capabilities.map(c => `
931
+ <option value="${c}">${MANUFACTURING_TYPES[c]?.label || c}</option>
932
+ `).join('')}
933
+ </select>
934
+ </div>
935
+
936
+ <div>
937
+ <label style="display: block; margin-bottom: 4px; font-size: 11px; color: var(--text-secondary);">Material</label>
938
+ <input type="text" name="material" placeholder="e.g., aluminum 6061" style="
939
+ width: 100%;
940
+ padding: 6px;
941
+ background: var(--bg-tertiary);
942
+ border: 1px solid var(--border-color);
943
+ color: var(--text-primary);
944
+ border-radius: 3px;
945
+ font-size: 12px;
946
+ ">
947
+ </div>
948
+
949
+ <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px;">
950
+ <div>
951
+ <label style="display: block; margin-bottom: 4px; font-size: 11px; color: var(--text-secondary);">Width (mm)</label>
952
+ <input type="number" name="width" placeholder="100" style="
953
+ width: 100%;
954
+ padding: 6px;
955
+ background: var(--bg-tertiary);
956
+ border: 1px solid var(--border-color);
957
+ color: var(--text-primary);
958
+ border-radius: 3px;
959
+ font-size: 12px;
960
+ ">
961
+ </div>
962
+ <div>
963
+ <label style="display: block; margin-bottom: 4px; font-size: 11px; color: var(--text-secondary);">Height (mm)</label>
964
+ <input type="number" name="height" placeholder="80" style="
965
+ width: 100%;
966
+ padding: 6px;
967
+ background: var(--bg-tertiary);
968
+ border: 1px solid var(--border-color);
969
+ color: var(--text-primary);
970
+ border-radius: 3px;
971
+ font-size: 12px;
972
+ ">
973
+ </div>
974
+ <div>
975
+ <label style="display: block; margin-bottom: 4px; font-size: 11px; color: var(--text-secondary);">Depth (mm)</label>
976
+ <input type="number" name="depth" placeholder="50" style="
977
+ width: 100%;
978
+ padding: 6px;
979
+ background: var(--bg-tertiary);
980
+ border: 1px solid var(--border-color);
981
+ color: var(--text-primary);
982
+ border-radius: 3px;
983
+ font-size: 12px;
984
+ ">
985
+ </div>
986
+ </div>
987
+
988
+ <div>
989
+ <label style="display: block; margin-bottom: 4px; font-size: 11px; color: var(--text-secondary);">Quantity</label>
990
+ <input type="number" name="quantity" value="1" min="1" style="
991
+ width: 100%;
992
+ padding: 6px;
993
+ background: var(--bg-tertiary);
994
+ border: 1px solid var(--border-color);
995
+ color: var(--text-primary);
996
+ border-radius: 3px;
997
+ font-size: 12px;
998
+ ">
999
+ </div>
1000
+
1001
+ <div>
1002
+ <label style="display: block; margin-bottom: 4px; font-size: 11px; color: var(--text-secondary);">Urgency</label>
1003
+ <select name="urgency" style="
1004
+ width: 100%;
1005
+ padding: 6px;
1006
+ background: var(--bg-tertiary);
1007
+ border: 1px solid var(--border-color);
1008
+ color: var(--text-primary);
1009
+ border-radius: 3px;
1010
+ font-size: 12px;
1011
+ ">
1012
+ <option value="standard">Standard (best price)</option>
1013
+ <option value="express">Express (+30% cost)</option>
1014
+ </select>
1015
+ </div>
1016
+
1017
+ <div>
1018
+ <label style="display: block; margin-bottom: 4px; font-size: 11px; color: var(--text-secondary);">Description</label>
1019
+ <textarea name="description" placeholder="Special requirements, finish, tolerance..." style="
1020
+ width: 100%;
1021
+ padding: 6px;
1022
+ background: var(--bg-tertiary);
1023
+ border: 1px solid var(--border-color);
1024
+ color: var(--text-primary);
1025
+ border-radius: 3px;
1026
+ font-size: 12px;
1027
+ min-height: 60px;
1028
+ resize: vertical;
1029
+ "></textarea>
1030
+ </div>
1031
+
1032
+ <button type="submit" style="
1033
+ padding: 8px;
1034
+ background: #DB2777;
1035
+ color: white;
1036
+ border: none;
1037
+ border-radius: 3px;
1038
+ cursor: pointer;
1039
+ font-weight: 500;
1040
+ ">Submit Job</button>
1041
+ </form>
1042
+
1043
+ <div id="fab-submit-result" style="margin-top: 12px; display: none;"></div>
1044
+ </div>
1045
+ `;
1046
+ return html;
1047
+ }
1048
+
1049
+ /**
1050
+ * Render My Jobs tab
1051
+ */
1052
+ function renderMyJobs() {
1053
+ const jobList = listJobs();
1054
+ const html = `
1055
+ <div>
1056
+ <h3 style="margin-bottom: 12px; color: var(--accent-blue);">My Manufacturing Jobs</h3>
1057
+
1058
+ ${jobList.length === 0 ? `
1059
+ <div style="padding: 16px; text-align: center; color: var(--text-muted);">
1060
+ No jobs submitted yet. Start by creating a new job above.
1061
+ </div>
1062
+ ` : `
1063
+ <div style="display: flex; flex-direction: column; gap: 8px;">
1064
+ ${jobList.map(job => `
1065
+ <div style="
1066
+ padding: 12px;
1067
+ background: var(--bg-tertiary);
1068
+ border-left: 4px solid ${JOB_STATES[job.status]?.color || '#999'};
1069
+ border-radius: 3px;
1070
+ ">
1071
+ <div style="display: flex; justify-content: space-between; align-items: center;">
1072
+ <strong>${job.name}</strong>
1073
+ <span style="
1074
+ padding: 2px 6px;
1075
+ background: ${JOB_STATES[job.status]?.color || '#999'};
1076
+ color: white;
1077
+ border-radius: 2px;
1078
+ font-size: 11px;
1079
+ ">${JOB_STATES[job.status]?.label || job.status}</span>
1080
+ </div>
1081
+ <div style="font-size: 11px; color: var(--text-secondary); margin-top: 4px;">
1082
+ 📍 ${job.fabName} • ${MANUFACTURING_TYPES[job.capability]?.label || job.capability}
1083
+ <br>
1084
+ 💰 ${job.costInTokens} tokens • Qty: ${job.quantity}
1085
+ </div>
1086
+ <div style="font-size: 10px; color: var(--text-muted); margin-top: 6px;">
1087
+ Created: ${new Date(job.createdAt).toLocaleDateString()}
1088
+ ${job.acceptedAt ? `<br>Accepted: ${new Date(job.acceptedAt).toLocaleDateString()}` : ''}
1089
+ </div>
1090
+ <div style="margin-top: 8px; display: flex; gap: 4px;">
1091
+ <button onclick="window.cycleCAD.fabs._showJobDetails('${job.id}')" style="
1092
+ padding: 4px 8px;
1093
+ background: var(--accent-blue);
1094
+ color: white;
1095
+ border: none;
1096
+ border-radius: 2px;
1097
+ cursor: pointer;
1098
+ font-size: 11px;
1099
+ ">Details</button>
1100
+ ${job.status === 'SUBMITTED' ? `
1101
+ <button onclick="window.cycleCAD.fabs.cancelJob('${job.id}'); location.reload();" style="
1102
+ padding: 4px 8px;
1103
+ background: var(--accent-red);
1104
+ color: white;
1105
+ border: none;
1106
+ border-radius: 2px;
1107
+ cursor: pointer;
1108
+ font-size: 11px;
1109
+ ">Cancel</button>
1110
+ ` : ''}
1111
+ </div>
1112
+ </div>
1113
+ `).join('')}
1114
+ </div>
1115
+ `}
1116
+ </div>
1117
+ `;
1118
+ return html;
1119
+ }
1120
+
1121
+ /**
1122
+ * Render Fab Dashboard tab (for fab owners)
1123
+ */
1124
+ function renderDashboard() {
1125
+ const completedCount = Object.values(jobs).filter(j => j.status === 'COMPLETED').length;
1126
+ const inProgressCount = Object.values(jobs).filter(j => j.status === 'IN_PROGRESS').length;
1127
+ const totalTokens = Object.values(jobs).reduce((sum, j) => sum + (j.costInTokens || 0), 0);
1128
+
1129
+ const html = `
1130
+ <div>
1131
+ <h3 style="margin-bottom: 12px; color: var(--accent-blue);">Manufacturing Dashboard</h3>
1132
+
1133
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 16px;">
1134
+ <div style="
1135
+ padding: 12px;
1136
+ background: var(--bg-tertiary);
1137
+ border-radius: 4px;
1138
+ text-align: center;
1139
+ ">
1140
+ <div style="font-size: 24px; font-weight: bold; color: var(--accent-blue);">${Object.keys(jobs).length}</div>
1141
+ <div style="font-size: 11px; color: var(--text-secondary);">Total Jobs</div>
1142
+ </div>
1143
+ <div style="
1144
+ padding: 12px;
1145
+ background: var(--bg-tertiary);
1146
+ border-radius: 4px;
1147
+ text-align: center;
1148
+ ">
1149
+ <div style="font-size: 24px; font-weight: bold; color: #DB2777;">${inProgressCount}</div>
1150
+ <div style="font-size: 11px; color: var(--text-secondary);">In Progress</div>
1151
+ </div>
1152
+ <div style="
1153
+ padding: 12px;
1154
+ background: var(--bg-tertiary);
1155
+ border-radius: 4px;
1156
+ text-align: center;
1157
+ ">
1158
+ <div style="font-size: 24px; font-weight: bold; color: var(--accent-green);">${completedCount}</div>
1159
+ <div style="font-size: 11px; color: var(--text-secondary);">Completed</div>
1160
+ </div>
1161
+ <div style="
1162
+ padding: 12px;
1163
+ background: var(--bg-tertiary);
1164
+ border-radius: 4px;
1165
+ text-align: center;
1166
+ ">
1167
+ <div style="font-size: 24px; font-weight: bold; color: var(--accent-blue);">${totalTokens}</div>
1168
+ <div style="font-size: 11px; color: var(--text-secondary);">Tokens Generated</div>
1169
+ </div>
1170
+ </div>
1171
+
1172
+ <h4 style="margin-bottom: 8px; color: var(--text-secondary);">Network Stats</h4>
1173
+ <div style="padding: 12px; background: var(--bg-tertiary); border-radius: 4px; font-size: 12px;">
1174
+ <div>🏭 Active Fabs: ${fabRegistry.filter(f => f.status === 'active').length}</div>
1175
+ <div>📍 Countries: ${new Set(fabRegistry.map(f => f.location.country)).size}</div>
1176
+ <div>⭐ Avg Rating: ${(fabRegistry.reduce((sum, f) => sum + f.rating, 0) / fabRegistry.length).toFixed(2)}</div>
1177
+ <div>📦 Completed Jobs: ${completedCount}</div>
1178
+ </div>
1179
+ </div>
1180
+ `;
1181
+ return html;
1182
+ }
1183
+
1184
+ /**
1185
+ * Attach tab click handlers and form submission
1186
+ */
1187
+ function setupPanelHandlers() {
1188
+ const tabButtons = document.querySelectorAll('.fab-tab-btn');
1189
+ tabButtons.forEach(btn => {
1190
+ btn.addEventListener('click', (e) => {
1191
+ const tab = e.target.dataset.tab;
1192
+ switchTab(tab);
1193
+ });
1194
+ });
1195
+
1196
+ // Form submission
1197
+ const form = document.getElementById('fab-submit-form');
1198
+ if (form) {
1199
+ form.addEventListener('submit', (e) => {
1200
+ e.preventDefault();
1201
+ const formData = new FormData(form);
1202
+ const jobData = {
1203
+ name: formData.get('name'),
1204
+ capability: formData.get('capability'),
1205
+ material: formData.get('material'),
1206
+ partSize: {
1207
+ x: parseInt(formData.get('width')) || 100,
1208
+ y: parseInt(formData.get('height')) || 100,
1209
+ z: parseInt(formData.get('depth')) || 50,
1210
+ },
1211
+ quantity: parseInt(formData.get('quantity')) || 1,
1212
+ urgency: formData.get('urgency'),
1213
+ description: formData.get('description'),
1214
+ };
1215
+
1216
+ const job = submitJob(jobData);
1217
+ if (job.error) {
1218
+ alert('Error: ' + job.error);
1219
+ } else {
1220
+ const resultDiv = document.getElementById('fab-submit-result');
1221
+ resultDiv.innerHTML = `
1222
+ <div style="
1223
+ padding: 12px;
1224
+ background: var(--accent-green);
1225
+ color: white;
1226
+ border-radius: 4px;
1227
+ ">
1228
+ ✓ Job ${job.id} submitted to ${job.fabName}!
1229
+ <br>
1230
+ <small>Cost: ${job.costInTokens} tokens • Lead: ${job.quote.leadDays} days</small>
1231
+ </div>
1232
+ `;
1233
+ resultDiv.style.display = 'block';
1234
+ form.reset();
1235
+ setTimeout(() => { resultDiv.style.display = 'none'; }, 3000);
1236
+ }
1237
+ });
1238
+ }
1239
+ }
1240
+
1241
+ /**
1242
+ * Switch tab content
1243
+ */
1244
+ function switchTab(tabName) {
1245
+ const contentDiv = document.getElementById('fab-panel-content');
1246
+ const tabButtons = document.querySelectorAll('.fab-tab-btn');
1247
+
1248
+ tabButtons.forEach(btn => {
1249
+ if (btn.dataset.tab === tabName) {
1250
+ btn.style.color = 'var(--accent-blue)';
1251
+ btn.style.borderBottom = '2px solid var(--accent-blue)';
1252
+ } else {
1253
+ btn.style.color = 'var(--text-secondary)';
1254
+ btn.style.borderBottom = 'none';
1255
+ }
1256
+ });
1257
+
1258
+ let html = '';
1259
+ switch (tabName) {
1260
+ case 'fabs':
1261
+ html = renderBrowseFabs();
1262
+ break;
1263
+ case 'submit':
1264
+ html = renderSubmitJob();
1265
+ break;
1266
+ case 'jobs':
1267
+ html = renderMyJobs();
1268
+ break;
1269
+ case 'dashboard':
1270
+ html = renderDashboard();
1271
+ break;
1272
+ }
1273
+
1274
+ contentDiv.innerHTML = html;
1275
+ setupPanelHandlers();
1276
+ }
1277
+
1278
+ /**
1279
+ * Toggle panel visibility
1280
+ */
1281
+ function togglePanel() {
1282
+ const panel = document.getElementById('connected-fabs-panel');
1283
+ if (!panel) return;
1284
+
1285
+ if (panel.style.display === 'none' || panel.style.display === '') {
1286
+ panel.style.display = 'flex';
1287
+ switchTab('fabs'); // Default to Browse tab
1288
+ } else {
1289
+ panel.style.display = 'none';
1290
+ }
1291
+ }
1292
+
1293
+ // ============================================================================
1294
+ // Persistence
1295
+ // ============================================================================
1296
+
1297
+ function loadRegistry() {
1298
+ try {
1299
+ const data = localStorage.getItem(STORAGE_KEYS.registry);
1300
+ if (data) {
1301
+ fabRegistry = JSON.parse(data);
1302
+ }
1303
+ } catch (e) {
1304
+ console.error('[Connected Fabs] Error loading registry:', e);
1305
+ }
1306
+ }
1307
+
1308
+ function saveRegistry() {
1309
+ try {
1310
+ localStorage.setItem(STORAGE_KEYS.registry, JSON.stringify(fabRegistry));
1311
+ } catch (e) {
1312
+ console.error('[Connected Fabs] Error saving registry:', e);
1313
+ }
1314
+ }
1315
+
1316
+ function loadJobs() {
1317
+ try {
1318
+ const data = localStorage.getItem(STORAGE_KEYS.jobs);
1319
+ if (data) {
1320
+ jobs = JSON.parse(data);
1321
+ }
1322
+ const counter = localStorage.getItem(STORAGE_KEYS.jobCounter);
1323
+ if (counter) {
1324
+ jobCounter = parseInt(counter);
1325
+ }
1326
+ } catch (e) {
1327
+ console.error('[Connected Fabs] Error loading jobs:', e);
1328
+ }
1329
+ }
1330
+
1331
+ function saveJobs() {
1332
+ try {
1333
+ localStorage.setItem(STORAGE_KEYS.jobs, JSON.stringify(jobs));
1334
+ } catch (e) {
1335
+ console.error('[Connected Fabs] Error saving jobs:', e);
1336
+ }
1337
+ }
1338
+
1339
+ function saveJobCounter() {
1340
+ try {
1341
+ localStorage.setItem(STORAGE_KEYS.jobCounter, jobCounter.toString());
1342
+ } catch (e) {
1343
+ console.error('[Connected Fabs] Error saving job counter:', e);
1344
+ }
1345
+ }
1346
+
1347
+ // ============================================================================
1348
+ // Utilities
1349
+ // ============================================================================
1350
+
1351
+ /**
1352
+ * Haversine distance formula (km)
1353
+ */
1354
+ function haversineDistance(lat1, lon1, lat2, lon2) {
1355
+ const R = 6371; // Earth's radius in km
1356
+ const dLat = (lat2 - lat1) * Math.PI / 180;
1357
+ const dLon = (lon2 - lon1) * Math.PI / 180;
1358
+ const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
1359
+ Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
1360
+ Math.sin(dLon / 2) * Math.sin(dLon / 2);
1361
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
1362
+ return R * c;
1363
+ }
1364
+
1365
+ /**
1366
+ * Event system
1367
+ */
1368
+ function on(event, listener) {
1369
+ if (!eventListeners[event]) {
1370
+ eventListeners[event] = [];
1371
+ }
1372
+ eventListeners[event].push(listener);
1373
+ }
1374
+
1375
+ function off(event, listener) {
1376
+ if (eventListeners[event]) {
1377
+ eventListeners[event] = eventListeners[event].filter(l => l !== listener);
1378
+ }
1379
+ }
1380
+
1381
+ function emit(event, data) {
1382
+ if (eventListeners[event]) {
1383
+ eventListeners[event].forEach(listener => {
1384
+ try {
1385
+ listener(data);
1386
+ } catch (e) {
1387
+ console.error(`[Connected Fabs] Event listener error (${event}):`, e);
1388
+ }
1389
+ });
1390
+ }
1391
+ }
1392
+
1393
+ // ============================================================================
1394
+ // Public API
1395
+ // ============================================================================
1396
+
1397
+ // Initialize on load
1398
+ init();
1399
+
1400
+ // Expose API on window.cycleCAD
1401
+ window.cycleCAD = window.cycleCAD || {};
1402
+ window.cycleCAD.fabs = {
1403
+ // Fab management
1404
+ registerFab,
1405
+ listFabs,
1406
+ getFab,
1407
+ updateFab,
1408
+ removeFab,
1409
+
1410
+ // Routing & quoting
1411
+ findBestFab,
1412
+ getQuote,
1413
+
1414
+ // Job management
1415
+ submitJob,
1416
+ getJob,
1417
+ listJobs,
1418
+ cancelJob,
1419
+ rateJob,
1420
+
1421
+ // Webhooks
1422
+ simulateWebhook,
1423
+ getWebhookLog,
1424
+
1425
+ // UI
1426
+ togglePanel,
1427
+ getPanelHTML,
1428
+ switchTab,
1429
+
1430
+ // Events
1431
+ on,
1432
+ off,
1433
+
1434
+ // Internal (for debug)
1435
+ _showFabDetails: (fabId) => {
1436
+ const fab = getFab(fabId);
1437
+ console.log('[Connected Fabs] Fab Details:', fab);
1438
+ alert(`${fab.name}\n\nRating: ${fab.rating}/5 (${fab.reviews} reviews)\nCertifications: ${fab.certifications.join(', ')}\nLocation: ${fab.location.city}, ${fab.location.country}`);
1439
+ },
1440
+ _showJobDetails: (jobId) => {
1441
+ const job = getJob(jobId);
1442
+ console.log('[Connected Fabs] Job Details:', job);
1443
+ alert(`${job.name} (${job.id})\n\nStatus: ${job.status}\nFab: ${job.fabName}\nCost: ${job.costInTokens} tokens\nCreated: ${new Date(job.createdAt).toLocaleString()}`);
1444
+ },
1445
+ };
1446
+
1447
+ console.log('[Connected Fabs] Module loaded. Type window.cycleCAD.fabs to access API.');
1448
+
1449
+ })();