cyclecad 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API-BUILD-MANIFEST.txt +339 -0
- package/API-SERVER.md +535 -0
- package/Architecture-Deck.pptx +0 -0
- package/CLAUDE.md +172 -11
- package/CLI-BUILD-SUMMARY.md +504 -0
- package/CLI-INDEX.md +356 -0
- package/CLI-README.md +466 -0
- package/COLLABORATION-INTEGRATION-GUIDE.md +325 -0
- package/CONNECTED_FABS_GUIDE.md +612 -0
- package/CONNECTED_FABS_README.md +310 -0
- package/DELIVERABLES.md +343 -0
- package/DFM-ANALYZER-INTEGRATION.md +368 -0
- package/DFM-QUICK-START.js +253 -0
- package/Dockerfile +69 -0
- package/IMPLEMENTATION.md +327 -0
- package/LICENSE +31 -0
- package/MARKETPLACE_QUICK_REFERENCE.txt +294 -0
- package/MCP-INDEX.md +264 -0
- package/QUICKSTART-API.md +388 -0
- package/QUICKSTART-CLI.md +211 -0
- package/QUICKSTART-MCP.md +196 -0
- package/README-MCP.md +208 -0
- package/TEST-TOKEN-ENGINE.md +319 -0
- package/TOKEN-ENGINE-SUMMARY.md +266 -0
- package/TOKENS-README.md +263 -0
- package/TOOLS-REFERENCE.md +254 -0
- package/app/index.html +168 -3
- package/app/js/TOKEN-INTEGRATION.md +391 -0
- package/app/js/agent-api.js +3 -3
- package/app/js/ai-copilot.js +1435 -0
- package/app/js/cam-pipeline.js +840 -0
- package/app/js/collaboration-ui.js +995 -0
- package/app/js/collaboration.js +1116 -0
- package/app/js/connected-fabs-example.js +404 -0
- package/app/js/connected-fabs.js +1449 -0
- package/app/js/dfm-analyzer.js +1760 -0
- package/app/js/marketplace.js +1994 -0
- package/app/js/material-library.js +2115 -0
- package/app/js/token-dashboard.js +563 -0
- package/app/js/token-engine.js +743 -0
- package/app/test-agent.html +1801 -0
- package/bin/cyclecad-cli.js +662 -0
- package/bin/cyclecad-mcp +2 -0
- package/bin/server.js +242 -0
- package/cycleCAD-Architecture.pptx +0 -0
- package/cycleCAD-Investor-Deck.pptx +0 -0
- package/demo-mcp.sh +60 -0
- package/docs/API-SERVER-SUMMARY.md +375 -0
- package/docs/API-SERVER.md +667 -0
- package/docs/CAM-EXAMPLES.md +344 -0
- package/docs/CAM-INTEGRATION.md +612 -0
- package/docs/CAM-QUICK-REFERENCE.md +199 -0
- package/docs/CLI-INTEGRATION.md +510 -0
- package/docs/CLI.md +872 -0
- package/docs/MARKETPLACE-API-SCHEMA.json +564 -0
- package/docs/MARKETPLACE-INTEGRATION.md +467 -0
- package/docs/MARKETPLACE-SETUP.html +439 -0
- package/docs/MCP-SERVER.md +403 -0
- package/examples/api-client-example.js +488 -0
- package/examples/api-client-example.py +359 -0
- package/examples/batch-manufacturing.txt +28 -0
- package/examples/batch-simple.txt +26 -0
- package/model-marketplace.html +1273 -0
- package/package.json +14 -3
- package/server/api-server.js +1120 -0
- package/server/mcp-server.js +1161 -0
- package/test-api-server.js +432 -0
- package/test-mcp.js +198 -0
- package/~$cycleCAD-Investor-Deck.pptx +0 -0
|
@@ -0,0 +1,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
|
+
})();
|