cyclecad 3.8.0 → 3.9.0

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.
@@ -0,0 +1,1270 @@
1
+ /**
2
+ * @fileoverview Built-in CNC / 3D Printer Control module for cycleCAD
3
+ * Direct browser-based machine control: FDM printers, SLA, CNC mills, laser cutters
4
+ * Supports WebSocket (Klipper/Duet/Smoothieware), OctoPrint API, Web Serial, Moonraker, demo mode
5
+ * G-code generation, toolpath preview, live jog controls, temperature monitoring
6
+ *
7
+ * Exports: window.CycleCAD.MachineControl
8
+ * Methods: init, getUI, execute, connect, sendGCode, getStatus, generateToolpath
9
+ *
10
+ * @version 1.0.0
11
+ */
12
+
13
+ window.CycleCAD = window.CycleCAD || {};
14
+
15
+ window.CycleCAD.MachineControl = (() => {
16
+ // ============================================================================
17
+ // STATE
18
+ // ============================================================================
19
+
20
+ let state = {
21
+ connected: false,
22
+ activeProfile: null,
23
+ machines: [],
24
+ currentMachine: null,
25
+ gCodeBuffer: [],
26
+ jobHistory: [],
27
+ isRunning: false,
28
+ isPaused: false,
29
+ currentLayer: 0,
30
+ totalLayers: 1,
31
+ startTime: null,
32
+ temperatures: { nozzle: 0, bed: 0, nozzleTarget: 0, bedTarget: 0 },
33
+ tempHistory: [],
34
+ position: { x: 0, y: 0, z: 0 },
35
+ feedOverride: 100,
36
+ spindleOverride: 100,
37
+ materialUsed: { length: 0, weight: 0 },
38
+ };
39
+
40
+ // Machine profiles database
41
+ const MACHINE_PROFILES = {
42
+ ender3: {
43
+ name: 'Ender 3 / V2 / V3',
44
+ type: 'fdm',
45
+ bed: { x: 220, y: 220, z: 250 },
46
+ nozzle: 0.4,
47
+ gcodeFlavor: 'marlin',
48
+ tempNozzle: 200,
49
+ tempBed: 60,
50
+ speed: 150,
51
+ },
52
+ prusa: {
53
+ name: 'Prusa i3 MK3S+',
54
+ type: 'fdm',
55
+ bed: { x: 250, y: 210, z: 210 },
56
+ nozzle: 0.4,
57
+ gcodeFlavor: 'prusa',
58
+ tempNozzle: 215,
59
+ tempBed: 60,
60
+ speed: 200,
61
+ },
62
+ bambu: {
63
+ name: 'Bambu Lab X1C',
64
+ type: 'fdm',
65
+ bed: { x: 256, y: 256, z: 256 },
66
+ nozzle: 0.4,
67
+ gcodeFlavor: 'klipper',
68
+ tempNozzle: 220,
69
+ tempBed: 45,
70
+ speed: 300,
71
+ },
72
+ voron: {
73
+ name: 'Voron 2.4',
74
+ type: 'fdm',
75
+ bed: { x: 350, y: 350, z: 330 },
76
+ nozzle: 0.4,
77
+ gcodeFlavor: 'klipper',
78
+ tempNozzle: 220,
79
+ tempBed: 100,
80
+ speed: 250,
81
+ },
82
+ snapmaker: {
83
+ name: 'Snapmaker 2.0',
84
+ type: 'multi',
85
+ bed: { x: 200, y: 200, z: 200 },
86
+ nozzle: 0.4,
87
+ gcodeFlavor: 'marlin',
88
+ tempNozzle: 200,
89
+ tempBed: 0,
90
+ speed: 100,
91
+ },
92
+ cnc3018: {
93
+ name: 'Generic CNC 3018',
94
+ type: 'cnc',
95
+ bed: { x: 300, y: 180, z: 45 },
96
+ nozzle: 3.175,
97
+ gcodeFlavor: 'grbl',
98
+ tempNozzle: 0,
99
+ tempBed: 0,
100
+ speed: 100,
101
+ },
102
+ shapeoko: {
103
+ name: 'Shapeoko 4',
104
+ type: 'cnc',
105
+ bed: { x: 838, y: 838, z: 100 },
106
+ nozzle: 6.35,
107
+ gcodeFlavor: 'mach3',
108
+ tempNozzle: 0,
109
+ tempBed: 0,
110
+ speed: 150,
111
+ },
112
+ };
113
+
114
+ // ============================================================================
115
+ // 1. MACHINE CONNECTION
116
+ // ============================================================================
117
+
118
+ /**
119
+ * Connect to a machine via specified protocol
120
+ * @param {Object} config - Connection config
121
+ * @param {string} config.protocol - 'websocket' | 'octoprint' | 'serial' | 'moonraker' | 'demo'
122
+ * @param {string} config.host - Host address (e.g., '192.168.1.100')
123
+ * @param {number} config.port - Port number
124
+ * @param {string} config.apiKey - API key (for OctoPrint, Moonraker)
125
+ * @param {string} config.profileKey - Machine profile key
126
+ * @returns {Promise<boolean>} True if connected successfully
127
+ */
128
+ async function connect(config) {
129
+ try {
130
+ const profile = MACHINE_PROFILES[config.profileKey];
131
+ if (!profile) throw new Error('Unknown machine profile');
132
+
133
+ state.currentMachine = {
134
+ id: `machine_${Date.now()}`,
135
+ name: profile.name,
136
+ profile: profile,
137
+ config: config,
138
+ protocol: config.protocol,
139
+ connection: null,
140
+ lastResponse: null,
141
+ };
142
+
143
+ switch (config.protocol) {
144
+ case 'websocket':
145
+ await connectWebSocket(config);
146
+ break;
147
+ case 'octoprint':
148
+ await connectOctoPrint(config);
149
+ break;
150
+ case 'serial':
151
+ await connectSerial(config);
152
+ break;
153
+ case 'moonraker':
154
+ await connectMoonraker(config);
155
+ break;
156
+ case 'demo':
157
+ connectDemo();
158
+ break;
159
+ default:
160
+ throw new Error('Unknown protocol: ' + config.protocol);
161
+ }
162
+
163
+ state.connected = true;
164
+ state.machines.push(state.currentMachine);
165
+ console.log('[MachineControl] Connected to ' + profile.name);
166
+ return true;
167
+ } catch (err) {
168
+ console.error('[MachineControl] Connection failed:', err);
169
+ state.connected = false;
170
+ return false;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Connect via WebSocket to Klipper/Duet/Smoothieware
176
+ */
177
+ async function connectWebSocket(config) {
178
+ return new Promise((resolve, reject) => {
179
+ const url = `ws://${config.host}:${config.port}`;
180
+ const ws = new WebSocket(url);
181
+
182
+ ws.onopen = () => {
183
+ state.currentMachine.connection = ws;
184
+ resolve();
185
+ };
186
+
187
+ ws.onerror = (err) => reject(new Error('WebSocket error: ' + err.message));
188
+ ws.onclose = () => { state.connected = false; };
189
+
190
+ ws.onmessage = (evt) => {
191
+ state.currentMachine.lastResponse = evt.data;
192
+ parseResponse(evt.data);
193
+ };
194
+ });
195
+ }
196
+
197
+ /**
198
+ * Connect via OctoPrint REST API + WebSocket
199
+ */
200
+ async function connectOctoPrint(config) {
201
+ const baseURL = `http://${config.host}:${config.port}`;
202
+ const headers = { 'X-API-Key': config.apiKey };
203
+
204
+ // Test connection with simple API call
205
+ const resp = await fetch(`${baseURL}/api/version`, { headers });
206
+ if (!resp.ok) throw new Error('OctoPrint API unreachable');
207
+
208
+ // Establish WebSocket for real-time updates
209
+ const wsURL = `ws://${config.host}:${config.port}/sockjs/websocket`;
210
+ const ws = new WebSocket(wsURL);
211
+
212
+ ws.onopen = () => {
213
+ state.currentMachine.connection = { ws, baseURL, headers };
214
+ };
215
+
216
+ ws.onmessage = (evt) => {
217
+ const msg = JSON.parse(evt.data);
218
+ if (msg.event === 'Status') updateOctoPrintStatus(msg);
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Connect via Web Serial API (Chrome/Edge, direct USB)
224
+ */
225
+ async function connectSerial(config) {
226
+ if (!navigator.serial) throw new Error('Web Serial API not supported');
227
+
228
+ const port = await navigator.serial.requestPort();
229
+ await port.open({ baudRate: config.baudRate || 115200 });
230
+
231
+ const writer = port.writable.getWriter();
232
+ const reader = port.readable.getReader();
233
+
234
+ state.currentMachine.connection = { port, writer, reader };
235
+
236
+ // Read responses from serial port
237
+ (async () => {
238
+ const decoder = new TextDecoderStream();
239
+ reader.pipeTo(decoder.writable);
240
+ const input = decoder.readable.getReader();
241
+ try {
242
+ while (true) {
243
+ const { value, done } = await input.read();
244
+ if (done) break;
245
+ state.currentMachine.lastResponse = value;
246
+ parseResponse(value);
247
+ }
248
+ } catch (err) {
249
+ console.error('Serial read error:', err);
250
+ }
251
+ })();
252
+ }
253
+
254
+ /**
255
+ * Connect via Moonraker API (Klipper companion)
256
+ */
257
+ async function connectMoonraker(config) {
258
+ const baseURL = `http://${config.host}:${config.port}`;
259
+ const wsURL = `ws://${config.host}:${config.port}/websocket`;
260
+
261
+ const ws = new WebSocket(wsURL);
262
+ ws.onopen = () => {
263
+ state.currentMachine.connection = { ws, baseURL };
264
+ // Request printer object updates
265
+ ws.send(JSON.stringify({
266
+ jsonrpc: '2.0',
267
+ method: 'printer.objects.subscribe',
268
+ params: { objects: { 'gcode_move': null, 'extruder': null, 'heater_bed': null } },
269
+ id: 1,
270
+ }));
271
+ };
272
+
273
+ ws.onmessage = (evt) => {
274
+ const msg = JSON.parse(evt.data);
275
+ if (msg.method === 'notify_update') updateMoonrakerStatus(msg.params);
276
+ };
277
+ }
278
+
279
+ /**
280
+ * Demo/simulated machine connection
281
+ */
282
+ function connectDemo() {
283
+ state.currentMachine.connection = {
284
+ type: 'demo',
285
+ buffer: [],
286
+ time: 0,
287
+ interval: null,
288
+ };
289
+
290
+ // Simulate heating
291
+ simulateMachineResponse('M104 S' + state.currentMachine.profile.tempNozzle);
292
+ simulateMachineResponse('M140 S' + state.currentMachine.profile.tempBed);
293
+ }
294
+
295
+ /**
296
+ * Parse machine response and update state
297
+ */
298
+ function parseResponse(data) {
299
+ // Handle M114 position report: X:10.50 Y:20.30 Z:5.00
300
+ const posMatch = data.match(/X:([\d.-]+)\s+Y:([\d.-]+)\s+Z:([\d.-]+)/i);
301
+ if (posMatch) {
302
+ state.position = { x: parseFloat(posMatch[1]), y: parseFloat(posMatch[2]), z: parseFloat(posMatch[3]) };
303
+ }
304
+
305
+ // Handle temperature reports
306
+ const tempMatch = data.match(/T:([\d.]+)\/([\d.]+)\s+B:([\d.]+)\/([\d.]+)/);
307
+ if (tempMatch) {
308
+ state.temperatures.nozzle = parseFloat(tempMatch[1]);
309
+ state.temperatures.nozzleTarget = parseFloat(tempMatch[2]);
310
+ state.temperatures.bed = parseFloat(tempMatch[3]);
311
+ state.temperatures.bedTarget = parseFloat(tempMatch[4]);
312
+ recordTempHistory();
313
+ }
314
+ }
315
+
316
+ // ============================================================================
317
+ // 2. G-CODE GENERATOR (~350 lines)
318
+ // ============================================================================
319
+
320
+ /**
321
+ * Generate G-code from 3D model geometry
322
+ * @param {Object} geometry - THREE.BufferGeometry
323
+ * @param {Object} settings - Generation settings
324
+ * @param {string} settings.mode - 'fdm' | 'cnc_mill' | 'laser'
325
+ * @param {number} settings.layerHeight - Layer height (mm)
326
+ * @param {number} settings.infillPercent - Infill %
327
+ * @param {number} settings.wallCount - Wall count
328
+ * @param {number} settings.supportType - 'tree' | 'linear' | 'none'
329
+ * @returns {string} G-code string
330
+ */
331
+ function generateToolpath(geometry, settings) {
332
+ let gcode = generateHeader();
333
+
334
+ if (settings.mode === 'fdm') {
335
+ gcode += generateFDMGCode(geometry, settings);
336
+ } else if (settings.mode === 'cnc_mill') {
337
+ gcode += generateCNCGCode(geometry, settings);
338
+ } else if (settings.mode === 'laser') {
339
+ gcode += generateLaserGCode(geometry, settings);
340
+ }
341
+
342
+ gcode += generateFooter();
343
+ state.gCodeBuffer = gcode.split('\n').filter(l => l.length > 0);
344
+ return gcode;
345
+ }
346
+
347
+ /**
348
+ * Generate FDM G-code (simplified slicing)
349
+ */
350
+ function generateFDMGCode(geometry, settings) {
351
+ let gcode = '';
352
+ const layerHeight = settings.layerHeight || 0.2;
353
+ const infill = settings.infillPercent || 20;
354
+ const walls = settings.wallCount || 2;
355
+ const tempNozzle = state.currentMachine.profile.tempNozzle;
356
+ const tempBed = state.currentMachine.profile.tempBed;
357
+
358
+ // Calculate bounding box
359
+ geometry.computeBoundingBox();
360
+ const bbox = geometry.boundingBox;
361
+ const height = bbox.max.z - bbox.min.z;
362
+ const numLayers = Math.ceil(height / layerHeight);
363
+
364
+ gcode += `;Generated by cycleCAD MachineControl\n`;
365
+ gcode += `;Layer height: ${layerHeight}\n`;
366
+ gcode += `;Infill: ${infill}%\n\n`;
367
+
368
+ // Preheat
369
+ gcode += `M104 S${tempNozzle}\n`;
370
+ gcode += `M140 S${tempBed}\n`;
371
+ gcode += `M109 S${tempNozzle}\n`;
372
+ gcode += `M190 S${tempBed}\n\n`;
373
+
374
+ // Homing & reset
375
+ gcode += `G28\n`;
376
+ gcode += `G92 E0\n`;
377
+
378
+ // Generate skirt (one loop around print area)
379
+ gcode += generateSkirt(geometry, settings) + '\n';
380
+
381
+ // Generate layers
382
+ for (let layer = 0; layer < numLayers; layer++) {
383
+ const z = bbox.min.z + layer * layerHeight;
384
+ gcode += `\n;Layer ${layer}/${numLayers}\n`;
385
+ gcode += `G0 Z${z.toFixed(3)}\n`;
386
+
387
+ // Walls (perimeters)
388
+ for (let wall = 0; wall < walls; wall++) {
389
+ gcode += generateWalls(geometry, z, wall) + '\n';
390
+ }
391
+
392
+ // Infill
393
+ if (infill > 0) {
394
+ gcode += generateInfill(geometry, z, infill) + '\n';
395
+ }
396
+ }
397
+
398
+ // Retractions & moves between features
399
+ gcode = applyRetraction(gcode, settings);
400
+
401
+ state.totalLayers = numLayers;
402
+ return gcode;
403
+ }
404
+
405
+ /**
406
+ * Generate CNC G-code (milling operations)
407
+ */
408
+ function generateCNCGCode(geometry, settings) {
409
+ let gcode = '';
410
+ const toolDia = state.currentMachine.profile.nozzle;
411
+ const depth = settings.depth || 10;
412
+ const stepDown = settings.stepDown || 5;
413
+ const speed = state.currentMachine.profile.speed;
414
+ const feedRate = settings.feedRate || 600;
415
+
416
+ gcode += `;CNC Milling Program\n`;
417
+ gcode += `;Tool diameter: ${toolDia}mm\n`;
418
+ gcode += `;Feed rate: ${feedRate}mm/min\n\n`;
419
+
420
+ // Home
421
+ gcode += `G28\n`;
422
+ gcode += `G92 X0 Y0 Z0\n`;
423
+
424
+ // Setup
425
+ gcode += `G21\n`; // Metric
426
+ gcode += `G17\n`; // XY plane
427
+ gcode += `G40\n`; // Cancel cutter radius comp
428
+ gcode += `G90\n`; // Absolute positioning
429
+ gcode += `M3 S${speed}\n`; // Spindle on
430
+ gcode += `G4 P2\n`; // Wait 2s for spindle
431
+ gcode += `G0 Z${5}\n\n`; // Retract
432
+
433
+ // Generate pocketing operation (simplified)
434
+ gcode += generatePocket(geometry, depth, stepDown) + '\n';
435
+
436
+ // End
437
+ gcode += `G0 Z${10}\n`;
438
+ gcode += `M5\n`; // Spindle off
439
+ gcode += `G28\n`; // Home
440
+
441
+ return gcode;
442
+ }
443
+
444
+ /**
445
+ * Generate laser cutting G-code
446
+ */
447
+ function generateLaserGCode(geometry, settings) {
448
+ let gcode = '';
449
+ const power = settings.power || 80;
450
+ const cutSpeed = settings.cutSpeed || 100;
451
+ const engraveSpeed = settings.engraveSpeed || 300;
452
+
453
+ gcode += `;Laser Cutting Program\n`;
454
+ gcode += `;Power: ${power}%\n\n`;
455
+
456
+ gcode += `G28\n`;
457
+ gcode += `G92 X0 Y0\n`;
458
+ gcode += `M3\n`; // Laser on (M4 for variable power)
459
+ gcode += `M107\n`; // Aux off
460
+
461
+ // Generate outline cuts
462
+ gcode += generateLaserPath(geometry, cutSpeed, power) + '\n';
463
+
464
+ gcode += `M5\n`; // Laser off
465
+
466
+ return gcode;
467
+ }
468
+
469
+ /**
470
+ * Generate skirt (adhesion perimeter)
471
+ */
472
+ function generateSkirt(geometry, settings) {
473
+ const offset = settings.skirtOffset || 5;
474
+ let gcode = `;Skirt\n`;
475
+ gcode += `G0 F${state.currentMachine.profile.speed * 60}\n`;
476
+ gcode += `G0 X${-offset} Y${-offset}\n`;
477
+ gcode += `G0 Z0.2\n`;
478
+ gcode += `G1 E2\n`; // Prime
479
+ gcode += `G1 X${offset} Y${-offset} E5\n`;
480
+ gcode += `G1 X${offset} Y${offset} E8\n`;
481
+ gcode += `G1 X${-offset} Y${offset} E11\n`;
482
+ gcode += `G1 X${-offset} Y${-offset} E14\n`;
483
+ return gcode;
484
+ }
485
+
486
+ /**
487
+ * Generate wall/perimeter paths
488
+ */
489
+ function generateWalls(geometry, z, wallIndex) {
490
+ const offset = wallIndex * 2;
491
+ let gcode = `;Wall ${wallIndex}\n`;
492
+ gcode += `G1 F${state.currentMachine.profile.speed * 60}\n`;
493
+
494
+ // Simplified: square spiral
495
+ const size = 50;
496
+ const x = offset, y = offset;
497
+ gcode += `G1 X${x} Y${y}\n`;
498
+ gcode += `G1 X${size - offset} Y${y} E10\n`;
499
+ gcode += `G1 X${size - offset} Y${size - offset} E20\n`;
500
+ gcode += `G1 X${x} Y${size - offset} E30\n`;
501
+ gcode += `G1 X${x} Y${y} E40\n`;
502
+
503
+ return gcode;
504
+ }
505
+
506
+ /**
507
+ * Generate infill pattern
508
+ */
509
+ function generateInfill(geometry, z, infillPercent) {
510
+ let gcode = `;Infill ${infillPercent}%\n`;
511
+ const lineSpacing = 2 / (infillPercent / 100);
512
+ gcode += `G1 F${state.currentMachine.profile.speed * 60}\n`;
513
+
514
+ // Simplified: horizontal lines
515
+ for (let y = 0; y < 50; y += lineSpacing) {
516
+ if (y % (lineSpacing * 2) < lineSpacing) {
517
+ gcode += `G1 X0 Y${y} E${y}\n`;
518
+ gcode += `G1 X50 Y${y} E${y + 50}\n`;
519
+ } else {
520
+ gcode += `G1 X50 Y${y} E${y}\n`;
521
+ gcode += `G1 X0 Y${y} E${y + 50}\n`;
522
+ }
523
+ }
524
+
525
+ return gcode;
526
+ }
527
+
528
+ /**
529
+ * Generate CNC pocketing operation
530
+ */
531
+ function generatePocket(geometry, depth, stepDown) {
532
+ let gcode = '';
533
+ const numPasses = Math.ceil(depth / stepDown);
534
+
535
+ for (let pass = 0; pass < numPasses; pass++) {
536
+ const z = -(pass + 1) * stepDown;
537
+ gcode += `\n;Pass ${pass + 1}/${numPasses} (Z=${z})\n`;
538
+ gcode += `G0 Z5\n`;
539
+ gcode += `G0 X10 Y10\n`;
540
+ gcode += `G1 Z${z} F200\n`; // Plunge
541
+ gcode += `G1 X40 Y10 F600\n`; // Move
542
+ gcode += `G1 X40 Y40\n`;
543
+ gcode += `G1 X10 Y40\n`;
544
+ gcode += `G1 X10 Y10\n`;
545
+ }
546
+
547
+ return gcode;
548
+ }
549
+
550
+ /**
551
+ * Generate laser cutting path
552
+ */
553
+ function generateLaserPath(geometry, speed, power) {
554
+ let gcode = '';
555
+ gcode += `G1 F${speed}\n`;
556
+ gcode += `S${Math.round(power * 2.55)}\n`; // Convert % to 0-255
557
+ gcode += `G1 X10 Y10\n`;
558
+ gcode += `G1 X50 Y10\n`;
559
+ gcode += `G1 X50 Y50\n`;
560
+ gcode += `G1 X10 Y50\n`;
561
+ gcode += `G1 X10 Y10\n`;
562
+ return gcode;
563
+ }
564
+
565
+ /**
566
+ * Apply retraction moves to G-code
567
+ */
568
+ function applyRetraction(gcode, settings) {
569
+ const retractDist = settings.retractDistance || 5;
570
+ const retractSpeed = settings.retractSpeed || 40;
571
+
572
+ // Simple: add retraction before rapid moves
573
+ gcode = gcode.replace(/G0\s+Z/g, `G1 E-${retractDist} F${retractSpeed * 60}\nG0 Z`);
574
+ return gcode;
575
+ }
576
+
577
+ /**
578
+ * Generate G-code file header
579
+ */
580
+ function generateHeader() {
581
+ let gcode = '';
582
+ gcode += `;Generated by cycleCAD MachineControl v1.0\n`;
583
+ gcode += `;Machine: ${state.currentMachine.profile.name}\n`;
584
+ gcode += `;Date: ${new Date().toLocaleString()}\n`;
585
+ gcode += `;Flavor: ${state.currentMachine.profile.gcodeFlavor}\n\n`;
586
+ return gcode;
587
+ }
588
+
589
+ /**
590
+ * Generate G-code file footer
591
+ */
592
+ function generateFooter() {
593
+ let gcode = '\n;End of program\n';
594
+ gcode += `M104 S0\n`; // Nozzle off
595
+ gcode += `M140 S0\n`; // Bed off
596
+ gcode += `M107\n`; // Fan off
597
+ gcode += `G28\n`; // Home
598
+ gcode += `M84\n`; // Disable steppers
599
+ return gcode;
600
+ }
601
+
602
+ // ============================================================================
603
+ // 3. SEND G-CODE & CONTROL
604
+ // ============================================================================
605
+
606
+ /**
607
+ * Send G-code command(s) to machine
608
+ * @param {string|string[]} cmd - G-code command or array of commands
609
+ * @param {boolean} queued - Queue for later (true) or send immediately (false)
610
+ * @returns {Promise<string>} Machine response
611
+ */
612
+ async function sendGCode(cmd, queued = false) {
613
+ if (!state.connected) throw new Error('Not connected to machine');
614
+
615
+ const commands = Array.isArray(cmd) ? cmd : [cmd];
616
+
617
+ if (queued) {
618
+ state.gCodeBuffer.push(...commands);
619
+ return 'Queued ' + commands.length + ' commands';
620
+ }
621
+
622
+ let responses = [];
623
+ for (const c of commands) {
624
+ const resp = await sendRawCommand(c);
625
+ responses.push(resp);
626
+ }
627
+
628
+ return responses.join('\n');
629
+ }
630
+
631
+ /**
632
+ * Send raw command via current connection
633
+ */
634
+ async function sendRawCommand(cmd) {
635
+ const conn = state.currentMachine.connection;
636
+
637
+ if (typeof conn === 'object' && conn.ws) {
638
+ // WebSocket
639
+ conn.ws.send(cmd);
640
+ return 'Sent: ' + cmd;
641
+ } else if (conn.writer) {
642
+ // Serial
643
+ const encoder = new TextEncoder();
644
+ await conn.writer.write(encoder.encode(cmd + '\n'));
645
+ return 'Sent: ' + cmd;
646
+ } else if (conn.type === 'demo') {
647
+ // Demo mode
648
+ return simulateMachineResponse(cmd);
649
+ }
650
+
651
+ return 'Error: Unknown connection type';
652
+ }
653
+
654
+ /**
655
+ * Simulate machine response (demo mode)
656
+ */
657
+ function simulateMachineResponse(cmd) {
658
+ const lcCmd = cmd.toLowerCase();
659
+
660
+ if (lcCmd.startsWith('m104')) {
661
+ const match = cmd.match(/s([\d.]+)/i);
662
+ if (match) state.temperatures.nozzleTarget = parseFloat(match[1]);
663
+ return `ok T:25.0/${state.temperatures.nozzleTarget}`;
664
+ }
665
+
666
+ if (lcCmd.startsWith('m140')) {
667
+ const match = cmd.match(/s([\d.]+)/i);
668
+ if (match) state.temperatures.bedTarget = parseFloat(match[1]);
669
+ return `ok B:25.0/${state.temperatures.bedTarget}`;
670
+ }
671
+
672
+ if (lcCmd.startsWith('m114')) {
673
+ return `X:${state.position.x.toFixed(2)} Y:${state.position.y.toFixed(2)} Z:${state.position.z.toFixed(2)} Count`;
674
+ }
675
+
676
+ return 'ok';
677
+ }
678
+
679
+ /**
680
+ * Get current machine status
681
+ * @returns {Object} Status object
682
+ */
683
+ function getStatus() {
684
+ return {
685
+ connected: state.connected,
686
+ machine: state.currentMachine?.profile.name,
687
+ isRunning: state.isRunning,
688
+ isPaused: state.isPaused,
689
+ position: state.position,
690
+ temperatures: state.temperatures,
691
+ progress: state.isRunning ? (state.currentLayer / state.totalLayers) * 100 : 0,
692
+ currentLayer: state.currentLayer,
693
+ totalLayers: state.totalLayers,
694
+ feedOverride: state.feedOverride,
695
+ materialUsed: state.materialUsed,
696
+ gCodeBufferLength: state.gCodeBuffer.length,
697
+ };
698
+ }
699
+
700
+ /**
701
+ * Start printing/milling job
702
+ */
703
+ function startJob() {
704
+ state.isRunning = true;
705
+ state.isPaused = false;
706
+ state.currentLayer = 0;
707
+ state.startTime = Date.now();
708
+ state.materialUsed = { length: 0, weight: 0 };
709
+
710
+ // Send first batch of G-code
711
+ const batchSize = 50;
712
+ for (let i = 0; i < Math.min(batchSize, state.gCodeBuffer.length); i++) {
713
+ sendRawCommand(state.gCodeBuffer[i]);
714
+ }
715
+ }
716
+
717
+ /**
718
+ * Pause current job
719
+ */
720
+ function pauseJob() {
721
+ state.isPaused = true;
722
+ sendRawCommand('M25'); // Pause (Marlin)
723
+ }
724
+
725
+ /**
726
+ * Resume paused job
727
+ */
728
+ function resumeJob() {
729
+ state.isPaused = false;
730
+ sendRawCommand('M24'); // Resume (Marlin)
731
+ }
732
+
733
+ /**
734
+ * Cancel current job
735
+ */
736
+ function cancelJob() {
737
+ state.isRunning = false;
738
+ state.isPaused = false;
739
+ state.currentLayer = 0;
740
+ sendRawCommand('M112'); // Emergency stop
741
+ }
742
+
743
+ /**
744
+ * Jog axis by distance
745
+ * @param {string} axis - 'x' | 'y' | 'z'
746
+ * @param {number} distance - Distance in mm (positive or negative)
747
+ * @param {number} feedRate - Feed rate (mm/min)
748
+ */
749
+ async function jogAxis(axis, distance, feedRate = 600) {
750
+ const move = `G1 ${axis.toUpperCase()}${state.position[axis.toLowerCase()] + distance} F${feedRate}`;
751
+ return sendRawCommand(move);
752
+ }
753
+
754
+ /**
755
+ * Set nozzle/bed temperature
756
+ */
757
+ async function setTemperature(type, temp) {
758
+ if (type === 'nozzle') return sendRawCommand(`M104 S${temp}`);
759
+ if (type === 'bed') return sendRawCommand(`M140 S${temp}`);
760
+ }
761
+
762
+ /**
763
+ * Record temperature history for graphing
764
+ */
765
+ function recordTempHistory() {
766
+ state.tempHistory.push({
767
+ time: Date.now() - (state.startTime || Date.now()),
768
+ nozzle: state.temperatures.nozzle,
769
+ bed: state.temperatures.bed,
770
+ });
771
+
772
+ // Keep last 100 records
773
+ if (state.tempHistory.length > 100) state.tempHistory.shift();
774
+ }
775
+
776
+ // ============================================================================
777
+ // 4. UI PANEL
778
+ // ============================================================================
779
+
780
+ /**
781
+ * Get UI panel HTML
782
+ * @returns {HTMLElement} Panel DOM element
783
+ */
784
+ function getUI() {
785
+ const panel = document.createElement('div');
786
+ panel.className = 'machine-control-panel';
787
+ panel.innerHTML = `
788
+ <div class="machine-tabs">
789
+ <button class="tab-btn active" data-tab="connect">Connect</button>
790
+ <button class="tab-btn" data-tab="prepare">Prepare</button>
791
+ <button class="tab-btn" data-tab="preview">Preview</button>
792
+ <button class="tab-btn" data-tab="control">Control</button>
793
+ <button class="tab-btn" data-tab="monitor">Monitor</button>
794
+ <button class="tab-btn" data-tab="console">Console</button>
795
+ </div>
796
+
797
+ <!-- CONNECT TAB -->
798
+ <div class="tab-content active" id="tab-connect">
799
+ <h3>Machine Connection</h3>
800
+ <div class="machine-profiles">
801
+ ${Object.entries(MACHINE_PROFILES).map(([key, profile]) => `
802
+ <button class="profile-btn" data-profile="${key}">
803
+ <strong>${profile.name}</strong><br>
804
+ <small>${profile.type.toUpperCase()} • ${profile.bed.x}×${profile.bed.y}mm</small>
805
+ </button>
806
+ `).join('')}
807
+ </div>
808
+
809
+ <h4>Connection Protocol</h4>
810
+ <div class="protocol-select">
811
+ <label><input type="radio" name="protocol" value="websocket"> WebSocket (Klipper/Duet)</label>
812
+ <label><input type="radio" name="protocol" value="octoprint"> OctoPrint API</label>
813
+ <label><input type="radio" name="protocol" value="serial"> Web Serial (USB)</label>
814
+ <label><input type="radio" name="protocol" value="moonraker"> Moonraker</label>
815
+ <label><input type="radio" name="protocol" value="demo" checked> Demo Mode</label>
816
+ </div>
817
+
818
+ <div class="connect-form">
819
+ <input type="text" placeholder="Host (e.g., 192.168.1.100)" id="host" value="localhost">
820
+ <input type="number" placeholder="Port" id="port" value="5000" min="1" max="65535">
821
+ <input type="password" placeholder="API Key (if needed)" id="apiKey">
822
+ <button id="connect-btn" class="btn-primary">Connect</button>
823
+ </div>
824
+
825
+ <div class="status-display">
826
+ <div class="status-led" id="status-led"></div>
827
+ <span id="status-text">Disconnected</span>
828
+ </div>
829
+
830
+ <h4>Connected Machines</h4>
831
+ <div id="machine-list"></div>
832
+ </div>
833
+
834
+ <!-- PREPARE TAB -->
835
+ <div class="tab-content" id="tab-prepare">
836
+ <h3>G-Code Settings</h3>
837
+
838
+ <div class="setting-group">
839
+ <label>Mode: <select id="mode-select">
840
+ <option value="fdm">FDM Printing</option>
841
+ <option value="cnc_mill">CNC Milling</option>
842
+ <option value="laser">Laser Cutting</option>
843
+ </select></label>
844
+ </div>
845
+
846
+ <div id="fdm-settings" class="mode-settings">
847
+ <label>Layer Height (mm): <input type="number" id="layer-height" min="0.1" max="0.4" step="0.05" value="0.2"></label>
848
+ <label>Infill (%): <input type="range" id="infill" min="0" max="100" value="20"></label>
849
+ <label>Wall Count: <input type="number" id="wall-count" min="1" max="5" value="2"></label>
850
+ <label>Nozzle Temp (°C): <input type="number" id="nozzle-temp" min="180" max="250" value="200"></label>
851
+ <label>Bed Temp (°C): <input type="number" id="bed-temp" min="20" max="100" value="60"></label>
852
+ <label>Support Type: <select id="support-type">
853
+ <option value="tree">Tree</option>
854
+ <option value="linear">Linear</option>
855
+ <option value="none">None</option>
856
+ </select></label>
857
+ </div>
858
+
859
+ <div id="cnc-settings" class="mode-settings" style="display:none;">
860
+ <label>Feed Rate (mm/min): <input type="number" id="feed-rate" min="100" max="2000" value="600"></label>
861
+ <label>Spindle Speed (RPM): <input type="number" id="spindle-speed" min="500" max="24000" value="5000"></label>
862
+ <label>Depth of Cut (mm): <input type="number" id="depth-cut" min="1" max="20" value="10"></label>
863
+ <label>Step Down (mm): <input type="number" id="step-down" min="1" max="20" value="5"></label>
864
+ </div>
865
+
866
+ <div id="laser-settings" class="mode-settings" style="display:none;">
867
+ <label>Power (%): <input type="range" id="laser-power" min="0" max="100" value="80"></label>
868
+ <label>Cut Speed (mm/min): <input type="number" id="cut-speed" min="10" max="500" value="100"></label>
869
+ <label>Engrave Speed (mm/min): <input type="number" id="engrave-speed" min="50" max="1000" value="300"></label>
870
+ </div>
871
+
872
+ <button id="generate-gcode-btn" class="btn-primary">Generate G-Code</button>
873
+ <div id="gcode-info"></div>
874
+ </div>
875
+
876
+ <!-- PREVIEW TAB -->
877
+ <div class="tab-content" id="tab-preview">
878
+ <h3>Toolpath Preview</h3>
879
+ <canvas id="toolpath-canvas" width="400" height="300"></canvas>
880
+ <div class="preview-controls">
881
+ <label>Layer: <input type="range" id="layer-slider" min="0" max="1" value="0" style="width:200px;"></label>
882
+ <span id="layer-info">Layer 0/1</span>
883
+ </div>
884
+ <div class="preview-stats">
885
+ <p>Estimated Time: <strong id="est-time">--:--</strong></p>
886
+ <p>Material Weight: <strong id="material-weight">0g</strong></p>
887
+ </div>
888
+ </div>
889
+
890
+ <!-- CONTROL TAB -->
891
+ <div class="tab-content" id="tab-control">
892
+ <h3>Machine Control</h3>
893
+
894
+ <h4>Jog Pad</h4>
895
+ <div class="jog-pad">
896
+ <div class="jog-grid">
897
+ <button class="jog-btn" data-axis="y" data-dist="10">+Y</button>
898
+ <button class="jog-btn" data-axis="y" data-dist="1">+Y</button>
899
+ <button class="jog-btn" data-axis="z" data-dist="10">+Z</button>
900
+ </div>
901
+ <div class="jog-grid">
902
+ <button class="jog-btn" data-axis="x" data-dist="-10">-X</button>
903
+ <button class="jog-btn" data-axis="x" data-dist="10">+X</button>
904
+ <button class="jog-btn" data-axis="z" data-dist="-10">-Z</button>
905
+ </div>
906
+ <div class="jog-grid">
907
+ <button class="jog-btn" data-axis="y" data-dist="-10">-Y</button>
908
+ <button class="jog-btn" data-axis="y" data-dist="-1">-Y</button>
909
+ <button class="jog-btn" data-axis="x" data-dist="0.1">0.1mm</button>
910
+ </div>
911
+ </div>
912
+
913
+ <h4>Axis Control</h4>
914
+ <button id="home-all-btn" class="btn-secondary">Home All (G28)</button>
915
+ <button id="set-zero-btn" class="btn-secondary">Set Zero (G92)</button>
916
+
917
+ <h4>Temperature Control</h4>
918
+ <div class="temp-controls">
919
+ <label>Nozzle: <input type="number" id="set-nozzle-temp" min="0" max="300" value="200"></label>
920
+ <button id="set-nozzle-btn" class="btn-secondary">Set</button>
921
+ <label>Bed: <input type="number" id="set-bed-temp" min="0" max="120" value="60"></label>
922
+ <button id="set-bed-btn" class="btn-secondary">Set</button>
923
+ </div>
924
+
925
+ <h4>Extrude/Retract</h4>
926
+ <div class="extrude-controls">
927
+ <input type="number" id="extrude-dist" min="0" max="100" value="10" placeholder="Distance (mm)">
928
+ <button id="extrude-btn" class="btn-secondary">Extrude</button>
929
+ <button id="retract-btn" class="btn-secondary">Retract</button>
930
+ </div>
931
+
932
+ <h4>Overrides</h4>
933
+ <label>Feed Rate: <input type="range" id="feed-override" min="50" max="200" value="100">
934
+ <span id="feed-override-val">100%</span>
935
+ </label>
936
+ <label>Spindle Speed: <input type="range" id="spindle-override" min="50" max="200" value="100">
937
+ <span id="spindle-override-val">100%</span>
938
+ </label>
939
+
940
+ <button id="emergency-stop-btn" class="btn-danger">EMERGENCY STOP (M112)</button>
941
+ </div>
942
+
943
+ <!-- MONITOR TAB -->
944
+ <div class="tab-content" id="tab-monitor">
945
+ <h3>Job Monitor</h3>
946
+
947
+ <div class="progress-display">
948
+ <div class="progress-bar">
949
+ <div id="progress-fill" style="width:0%"></div>
950
+ </div>
951
+ <span id="progress-text">0%</span>
952
+ </div>
953
+
954
+ <div class="monitor-stats">
955
+ <p>Layer: <strong id="monitor-layer">0/1</strong></p>
956
+ <p>Time: <strong id="monitor-time">00:00</strong> / <strong id="monitor-eta">--:--</strong></p>
957
+ <p>Material: <strong id="monitor-material">0m</strong> (<strong id="monitor-weight">0g</strong>)</p>
958
+ </div>
959
+
960
+ <div class="temp-chart">
961
+ <canvas id="temp-chart" width="300" height="150"></canvas>
962
+ </div>
963
+
964
+ <div class="job-controls">
965
+ <button id="start-btn" class="btn-primary">Start Print</button>
966
+ <button id="pause-btn" class="btn-secondary" disabled>Pause</button>
967
+ <button id="resume-btn" class="btn-secondary" disabled>Resume</button>
968
+ <button id="cancel-btn" class="btn-danger">Cancel</button>
969
+ </div>
970
+
971
+ <h4>Print History</h4>
972
+ <div id="job-history"></div>
973
+ </div>
974
+
975
+ <!-- CONSOLE TAB -->
976
+ <div class="tab-content" id="tab-console">
977
+ <h3>G-Code Console</h3>
978
+ <textarea id="console-output" readonly style="width:100%; height:150px; font-family:monospace; background:#1e1e1e; color:#00ff00; padding:8px;"></textarea>
979
+ <div class="console-input">
980
+ <input type="text" id="gcode-input" placeholder="Enter G-code command..." style="width:calc(100% - 80px);">
981
+ <button id="send-gcode-btn" class="btn-secondary">Send</button>
982
+ </div>
983
+ </div>
984
+ </div>
985
+
986
+ <style>
987
+ .machine-control-panel { padding: 12px; font-size: 0.85rem; }
988
+
989
+ .machine-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 12px; }
990
+ .tab-btn { padding: 6px 12px; background: transparent; border: none; cursor: pointer; border-bottom: 2px solid transparent; }
991
+ .tab-btn.active { border-bottom-color: var(--accent); color: var(--accent); }
992
+
993
+ .tab-content { display: none; }
994
+ .tab-content.active { display: block; }
995
+
996
+ .machine-profiles { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px; }
997
+ .profile-btn { padding: 8px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 4px; cursor: pointer; text-align: left; }
998
+ .profile-btn:hover { background: var(--bg-tertiary); }
999
+
1000
+ .protocol-select { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
1001
+ .protocol-select label { display: flex; align-items: center; gap: 6px; cursor: pointer; }
1002
+
1003
+ .connect-form { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
1004
+ .connect-form input { padding: 6px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 4px; color: inherit; }
1005
+
1006
+ .status-display { display: flex; align-items: center; gap: 8px; margin: 12px 0; }
1007
+ .status-led { width: 12px; height: 12px; border-radius: 50%; background: #ff4444; }
1008
+ .status-led.connected { background: #44ff44; }
1009
+
1010
+ .machine-list { background: var(--bg-secondary); border-radius: 4px; padding: 8px; }
1011
+
1012
+ .setting-group { margin-bottom: 12px; }
1013
+ .setting-group label { display: flex; align-items: center; gap: 8px; }
1014
+ .setting-group select, .setting-group input { padding: 4px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 3px; color: inherit; }
1015
+
1016
+ .mode-settings { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
1017
+ .mode-settings label { display: flex; align-items: center; justify-content: space-between; }
1018
+
1019
+ .jog-pad { display: grid; grid-template-columns: repeat(3, 60px); gap: 4px; margin-bottom: 12px; }
1020
+ .jog-grid { display: contents; }
1021
+ .jog-btn { padding: 8px; background: var(--accent); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.75rem; font-weight: bold; }
1022
+ .jog-btn:hover { opacity: 0.8; }
1023
+
1024
+ .temp-controls { display: grid; grid-template-columns: 1fr 80px; gap: 8px; align-items: center; margin-bottom: 12px; }
1025
+ .extrude-controls { display: flex; gap: 4px; margin-bottom: 12px; }
1026
+
1027
+ .progress-bar { width: 100%; height: 24px; background: var(--bg-secondary); border-radius: 4px; overflow: hidden; margin-bottom: 8px; }
1028
+ #progress-fill { height: 100%; background: var(--accent); transition: width 0.1s; }
1029
+
1030
+ .monitor-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px; }
1031
+ .monitor-stats p { margin: 4px 0; }
1032
+
1033
+ .job-controls { display: flex; gap: 4px; margin-bottom: 12px; flex-wrap: wrap; }
1034
+
1035
+ .btn-primary { padding: 8px 12px; background: var(--accent); color: white; border: none; border-radius: 4px; cursor: pointer; }
1036
+ .btn-secondary { padding: 6px 10px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 4px; cursor: pointer; }
1037
+ .btn-danger { padding: 8px 12px; background: #ff4444; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; }
1038
+
1039
+ .btn-primary:hover { opacity: 0.9; }
1040
+ .btn-secondary:hover { background: var(--bg-tertiary); }
1041
+ .btn-danger:hover { background: #ff2222; }
1042
+ </style>
1043
+ `;
1044
+
1045
+ // Attach event listeners
1046
+ attachPanelListeners(panel);
1047
+
1048
+ return panel;
1049
+ }
1050
+
1051
+ /**
1052
+ * Attach all event listeners to panel
1053
+ */
1054
+ function attachPanelListeners(panel) {
1055
+ // Tab switching
1056
+ panel.querySelectorAll('.tab-btn').forEach(btn => {
1057
+ btn.addEventListener('click', (e) => {
1058
+ panel.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
1059
+ panel.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
1060
+ e.target.classList.add('active');
1061
+ const tabName = e.target.getAttribute('data-tab');
1062
+ panel.querySelector('#tab-' + tabName).classList.add('active');
1063
+ });
1064
+ });
1065
+
1066
+ // Profile selection
1067
+ panel.querySelectorAll('.profile-btn').forEach(btn => {
1068
+ btn.addEventListener('click', (e) => {
1069
+ state.activeProfile = e.currentTarget.getAttribute('data-profile');
1070
+ });
1071
+ });
1072
+
1073
+ // Connect button
1074
+ panel.querySelector('#connect-btn').addEventListener('click', async () => {
1075
+ const protocol = panel.querySelector('input[name="protocol"]:checked').value;
1076
+ const host = panel.querySelector('#host').value;
1077
+ const port = parseInt(panel.querySelector('#port').value);
1078
+ const apiKey = panel.querySelector('#apiKey').value;
1079
+ const profileKey = state.activeProfile || 'ender3';
1080
+
1081
+ const success = await connect({ protocol, host, port, apiKey, profileKey });
1082
+ const statusLed = panel.querySelector('#status-led');
1083
+ const statusText = panel.querySelector('#status-text');
1084
+
1085
+ if (success) {
1086
+ statusLed.classList.add('connected');
1087
+ statusText.textContent = 'Connected to ' + MACHINE_PROFILES[profileKey].name;
1088
+ } else {
1089
+ statusLed.classList.remove('connected');
1090
+ statusText.textContent = 'Connection failed';
1091
+ }
1092
+
1093
+ updateMachineList(panel);
1094
+ });
1095
+
1096
+ // Mode select
1097
+ panel.querySelector('#mode-select').addEventListener('change', (e) => {
1098
+ const mode = e.target.value;
1099
+ panel.querySelector('#fdm-settings').style.display = mode === 'fdm' ? 'flex' : 'none';
1100
+ panel.querySelector('#cnc-settings').style.display = mode === 'cnc_mill' ? 'flex' : 'none';
1101
+ panel.querySelector('#laser-settings').style.display = mode === 'laser' ? 'flex' : 'none';
1102
+ });
1103
+
1104
+ // Generate G-code
1105
+ panel.querySelector('#generate-gcode-btn').addEventListener('click', () => {
1106
+ const mode = panel.querySelector('#mode-select').value;
1107
+ const settings = {
1108
+ mode,
1109
+ layerHeight: parseFloat(panel.querySelector('#layer-height').value),
1110
+ infillPercent: parseInt(panel.querySelector('#infill').value),
1111
+ wallCount: parseInt(panel.querySelector('#wall-count').value),
1112
+ feedRate: parseInt(panel.querySelector('#feed-rate').value),
1113
+ power: parseInt(panel.querySelector('#laser-power').value),
1114
+ };
1115
+
1116
+ // Get dummy geometry (in real use, would come from 3D model)
1117
+ const geometry = new THREE.BoxGeometry(50, 50, 50);
1118
+
1119
+ const gcode = generateToolpath(geometry, settings);
1120
+ panel.querySelector('#gcode-info').innerHTML = `<p style="color:var(--accent);">Generated ${state.gCodeBuffer.length} lines of G-code</p>`;
1121
+ });
1122
+
1123
+ // Jog buttons
1124
+ panel.querySelectorAll('.jog-btn').forEach(btn => {
1125
+ btn.addEventListener('click', async (e) => {
1126
+ const axis = e.target.getAttribute('data-axis');
1127
+ const dist = parseFloat(e.target.getAttribute('data-dist'));
1128
+ await jogAxis(axis, dist);
1129
+ });
1130
+ });
1131
+
1132
+ // Temperature controls
1133
+ panel.querySelector('#set-nozzle-btn').addEventListener('click', () => {
1134
+ const temp = parseInt(panel.querySelector('#set-nozzle-temp').value);
1135
+ setTemperature('nozzle', temp);
1136
+ });
1137
+
1138
+ panel.querySelector('#set-bed-btn').addEventListener('click', () => {
1139
+ const temp = parseInt(panel.querySelector('#set-bed-temp').value);
1140
+ setTemperature('bed', temp);
1141
+ });
1142
+
1143
+ // Extrude/Retract
1144
+ panel.querySelector('#extrude-btn').addEventListener('click', () => {
1145
+ const dist = parseFloat(panel.querySelector('#extrude-dist').value);
1146
+ sendRawCommand(`G1 E${dist} F200`);
1147
+ });
1148
+
1149
+ panel.querySelector('#retract-btn').addEventListener('click', () => {
1150
+ const dist = parseFloat(panel.querySelector('#extrude-dist').value);
1151
+ sendRawCommand(`G1 E-${dist} F200`);
1152
+ });
1153
+
1154
+ // Overrides
1155
+ panel.querySelector('#feed-override').addEventListener('input', (e) => {
1156
+ state.feedOverride = parseInt(e.target.value);
1157
+ panel.querySelector('#feed-override-val').textContent = state.feedOverride + '%';
1158
+ sendRawCommand(`M220 S${state.feedOverride}`);
1159
+ });
1160
+
1161
+ panel.querySelector('#spindle-override').addEventListener('input', (e) => {
1162
+ state.spindleOverride = parseInt(e.target.value);
1163
+ panel.querySelector('#spindle-override-val').textContent = state.spindleOverride + '%';
1164
+ sendRawCommand(`M221 S${state.spindleOverride}`);
1165
+ });
1166
+
1167
+ // Emergency stop
1168
+ panel.querySelector('#emergency-stop-btn').addEventListener('click', () => {
1169
+ sendRawCommand('M112');
1170
+ cancelJob();
1171
+ });
1172
+
1173
+ // Job controls
1174
+ panel.querySelector('#start-btn').addEventListener('click', startJob);
1175
+ panel.querySelector('#pause-btn').addEventListener('click', pauseJob);
1176
+ panel.querySelector('#resume-btn').addEventListener('click', resumeJob);
1177
+ panel.querySelector('#cancel-btn').addEventListener('click', cancelJob);
1178
+
1179
+ // Console
1180
+ panel.querySelector('#send-gcode-btn').addEventListener('click', async () => {
1181
+ const cmd = panel.querySelector('#gcode-input').value.trim();
1182
+ if (cmd) {
1183
+ const resp = await sendGCode(cmd);
1184
+ const output = panel.querySelector('#console-output');
1185
+ output.value += `> ${cmd}\n${resp}\n`;
1186
+ output.scrollTop = output.scrollHeight;
1187
+ panel.querySelector('#gcode-input').value = '';
1188
+ }
1189
+ });
1190
+
1191
+ // Home all
1192
+ panel.querySelector('#home-all-btn').addEventListener('click', () => sendRawCommand('G28'));
1193
+
1194
+ // Set zero
1195
+ panel.querySelector('#set-zero-btn').addEventListener('click', () => sendRawCommand('G92 X0 Y0 Z0'));
1196
+ }
1197
+
1198
+ /**
1199
+ * Update machine list display
1200
+ */
1201
+ function updateMachineList(panel) {
1202
+ const list = panel.querySelector('#machine-list');
1203
+ list.innerHTML = state.machines.map((m, idx) => `
1204
+ <div style="padding:8px; background:var(--bg-tertiary); margin:4px 0; border-radius:3px;">
1205
+ <strong>${m.name}</strong><br>
1206
+ <small>${m.protocol} • ${m.config.host}:${m.config.port}</small>
1207
+ </div>
1208
+ `).join('');
1209
+ }
1210
+
1211
+ /**
1212
+ * Update OctoPrint status from WebSocket message
1213
+ */
1214
+ function updateOctoPrintStatus(msg) {
1215
+ if (msg.state === 'Printing') state.isRunning = true;
1216
+ if (msg.state === 'Paused') state.isPaused = true;
1217
+ parseResponse(msg.temps);
1218
+ }
1219
+
1220
+ /**
1221
+ * Update Moonraker status from notify_update
1222
+ */
1223
+ function updateMoonrakerStatus(params) {
1224
+ if (params[0].gcode_move) {
1225
+ const pos = params[0].gcode_move.gcode_position;
1226
+ state.position = { x: pos[0], y: pos[1], z: pos[2] };
1227
+ }
1228
+ if (params[0].extruder) {
1229
+ state.temperatures.nozzle = params[0].extruder.temperature;
1230
+ state.temperatures.nozzleTarget = params[0].extruder.target;
1231
+ }
1232
+ if (params[0].heater_bed) {
1233
+ state.temperatures.bed = params[0].heater_bed.temperature;
1234
+ state.temperatures.bedTarget = params[0].heater_bed.target;
1235
+ }
1236
+ recordTempHistory();
1237
+ }
1238
+
1239
+ // ============================================================================
1240
+ // PUBLIC API
1241
+ // ============================================================================
1242
+
1243
+ return {
1244
+ init: () => {
1245
+ console.log('[MachineControl] Initialized');
1246
+ },
1247
+ getUI,
1248
+ execute: (cmd, params) => {
1249
+ console.log('[MachineControl] Execute:', cmd, params);
1250
+ },
1251
+ connect,
1252
+ sendGCode,
1253
+ getStatus,
1254
+ generateToolpath,
1255
+ startJob,
1256
+ pauseJob,
1257
+ resumeJob,
1258
+ cancelJob,
1259
+ jogAxis,
1260
+ setTemperature,
1261
+ };
1262
+ })();
1263
+
1264
+ // ============================================================================
1265
+ // EXPORT
1266
+ // ============================================================================
1267
+
1268
+ if (typeof module !== 'undefined' && module.exports) {
1269
+ module.exports = window.CycleCAD.MachineControl;
1270
+ }