Просмотр исходного кода

Wired motion and extruder controls

Martin Ziegler 5 месяцев назад
Родитель
Сommit
3917f0a3d1

+ 26 - 0
backend/app/api/routes/printer_control.py

@@ -105,6 +105,10 @@ class SpeedRequest(BaseModel):
     mode: int = Field(..., ge=1, le=4, description="Speed mode: 1=silent, 2=standard, 3=sport, 4=ludicrous")
 
 
+class ExtruderRequest(BaseModel):
+    extruder: int = Field(..., ge=0, le=1, description="Extruder index (0=right, 1=left for H2D)")
+
+
 class FanRequest(BaseModel):
     speed: int = Field(..., ge=0, le=100, description="Fan speed percentage (0-100)")
 
@@ -324,6 +328,28 @@ async def set_print_speed(
     )
 
 
+# =============================================================================
+# Extruder Control Endpoint
+# =============================================================================
+
+@router.post("/{printer_id}/control/extruder", response_model=ControlResponse)
+async def select_extruder(
+    printer_id: int,
+    request: ExtruderRequest,
+    db: AsyncSession = Depends(get_db),
+):
+    """Select the active extruder for dual-nozzle printers."""
+    await get_printer_or_404(printer_id, db)
+    client = get_mqtt_client_or_503(printer_id)
+
+    extruder_names = {0: "Right", 1: "Left"}
+    success = client.select_extruder(request.extruder)
+    return ControlResponse(
+        success=success,
+        message=f"Selected {extruder_names[request.extruder]} extruder" if success else "Failed to select extruder"
+    )
+
+
 # =============================================================================
 # Fan Control Endpoints
 # =============================================================================

+ 1 - 0
backend/app/api/routes/printers.py

@@ -244,6 +244,7 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
         airduct_mode=state.airduct_mode,
         speed_level=state.speed_level,
         chamber_light=state.chamber_light,
+        active_extruder=state.active_extruder,
     )
 
 

+ 2 - 0
backend/app/schemas/printer.py

@@ -118,3 +118,5 @@ class PrinterStatus(BaseModel):
     speed_level: int = 2
     # Chamber light on/off
     chamber_light: bool = False
+    # Active extruder for dual nozzle (0=right, 1=left)
+    active_extruder: int = 0

+ 66 - 0
backend/app/services/bambu_mqtt.py

@@ -109,6 +109,8 @@ class PrinterState:
     speed_level: int = 2
     # Chamber light on/off
     chamber_light: bool = False
+    # Active extruder for dual nozzle (0=right, 1=left) - from device.extruder.info[X].hnow
+    active_extruder: int = 0
 
 
 # Stage name mapping from BambuStudio DeviceManager.cpp
@@ -278,6 +280,10 @@ class BambuMQTTClient:
             # Track last message time - receiving a message proves we're connected
             self._last_message_time = time.time()
             self.state.connected = True
+            # TEMP: Dump full payload once to find extruder state field
+            if not hasattr(self, '_payload_dumped'):
+                self._payload_dumped = True
+                logger.info(f"[{self.serial_number}] FULL MQTT PAYLOAD DUMP:\n{json.dumps(payload, indent=2)}")
             # Log message if logging is enabled
             if self._logging_enabled:
                 self._message_log.append(MQTTLogEntry(
@@ -647,6 +653,33 @@ class BambuMQTTClient:
         if nozzle_fields and not hasattr(self, '_nozzle_fields_logged'):
             logger.info(f"[{self.serial_number}] Nozzle/hardware fields in MQTT data: {nozzle_fields}")
             self._nozzle_fields_logged = True
+        # Parse active extruder from device.extruder.state bit 8
+        # bit 8 = 0 → RIGHT extruder (active_extruder=0)
+        # bit 8 = 1 → LEFT extruder (active_extruder=1)
+        if "device" in data and isinstance(data.get("device"), dict):
+            device = data["device"]
+            if "extruder" in device and "state" in device["extruder"]:
+                state_val = device["extruder"]["state"]
+                # Extract bit 8 for extruder position
+                new_extruder = (state_val >> 8) & 0x1
+                if new_extruder != self.state.active_extruder:
+                    logger.info(f"[{self.serial_number}] ACTIVE EXTRUDER CHANGED (state bit 8): {self.state.active_extruder} -> {new_extruder} (0=right, 1=left) [state={state_val}]")
+                    self.state.active_extruder = new_extruder
+
+        # Log device.extruder structure for active extruder
+        if "device" in data and isinstance(data.get("device"), dict):
+            device = data["device"]
+            if "extruder" in device:
+                ext_data = device["extruder"]
+                # Log 'state' field - OrcaSlicer uses bits 12-14 for switch state
+                if "state" in ext_data:
+                    state_val = ext_data["state"]
+                    # Extract bits 12-14 (3 bits) for switch state
+                    switch_state = (state_val >> 12) & 0x7
+                    logger.info(f"[{self.serial_number}] device.extruder.state={state_val} (switch_state bits 12-14: {switch_state})")
+                # Log 'cur' field if present (might indicate current/active extruder)
+                if "cur" in ext_data:
+                    logger.info(f"[{self.serial_number}] device.extruder.cur: {ext_data['cur']}")
         if "bed_temper" in data:
             temps["bed"] = float(data["bed_temper"])
         if "bed_target_temper" in data:
@@ -2058,6 +2091,39 @@ class BambuMQTTClient:
         logger.info(f"[{self.serial_number}] Set chamber lights {'on' if on else 'off'} (seq={self._sequence_id})")
         return True
 
+    def select_extruder(self, extruder: int) -> bool:
+        """Select the active extruder for dual-nozzle printers (H2D).
+
+        Args:
+            extruder: Extruder index (0=right, 1=left for H2D)
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        if extruder not in (0, 1):
+            logger.warning(f"[{self.serial_number}] Invalid extruder: {extruder}")
+            return False
+
+        if not self._client or not self.state.connected:
+            logger.warning(f"[{self.serial_number}] Cannot switch extruder: not connected")
+            return False
+
+        # H2D extruder switching via select_extruder command
+        # Command format captured from OrcaSlicer:
+        # {"print": {"command": "select_extruder", "extruder_index": 0, "sequence_id": "..."}}
+        # extruder_index: 0 = RIGHT, 1 = LEFT
+        self._sequence_id += 1
+        command = {
+            "print": {
+                "command": "select_extruder",
+                "extruder_index": extruder,
+                "sequence_id": str(self._sequence_id)
+            }
+        }
+        self._client.publish(self.topic_publish, json.dumps(command), qos=1)
+        logger.info(f"[{self.serial_number}] Sent select_extruder command: extruder_index={extruder} (0=right, 1=left)")
+        return True
+
     def home_axes(self, axes: str = "XYZ") -> bool:
         """Home the specified axes.
 

BIN
frontend/public/icons/dual-extruder-left.png


BIN
frontend/public/icons/dual-extruder-right.png


+ 7 - 0
frontend/src/api/client.ts

@@ -121,6 +121,8 @@ export interface PrinterStatus {
   speed_level: number;
   // Chamber light on/off
   chamber_light: boolean;
+  // Active extruder for dual nozzle (0=right, 1=left)
+  active_extruder: number;
 }
 
 export interface PrinterCreate {
@@ -1179,6 +1181,11 @@ export const api = {
       method: 'POST',
       body: JSON.stringify({ on }),
     }),
+  selectExtruder: (printerId: number, extruder: number) =>
+    request<ControlResponse>(`/printers/${printerId}/control/extruder`, {
+      method: 'POST',
+      body: JSON.stringify({ extruder }),
+    }),
   homeAxes: (printerId: number, axes = 'XYZ', confirmToken?: string) =>
     request<ControlResult>(`/printers/${printerId}/control/home`, {
       method: 'POST',

+ 8 - 8
frontend/src/components/control/BedControls.tsx

@@ -50,35 +50,35 @@ export function BedControls({ printerId, status, disabled = false }: BedControls
     <>
       <div className="flex items-center gap-2">
         <button
-          onClick={() => handleMove(10)}
+          onClick={() => handleMove(-10)}
           disabled={isDisabled}
           className="px-3.5 py-2 rounded-md bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary text-sm text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
-          title="Z+10"
+          title="Bed up 10mm"
         >
           ↑10
         </button>
         <button
-          onClick={() => handleMove(1)}
+          onClick={() => handleMove(-1)}
           disabled={isDisabled}
           className="px-3.5 py-2 rounded-md bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary text-sm text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
-          title="Z+1"
+          title="Bed up 1mm"
         >
           ↑1
         </button>
         <span className="px-2 py-2 text-sm text-bambu-gray">Bed</span>
         <button
-          onClick={() => handleMove(-1)}
+          onClick={() => handleMove(1)}
           disabled={isDisabled}
           className="px-3.5 py-2 rounded-md bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary text-sm text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
-          title="Z-1"
+          title="Bed down 1mm"
         >
           ↓1
         </button>
         <button
-          onClick={() => handleMove(-10)}
+          onClick={() => handleMove(10)}
           disabled={isDisabled}
           className="px-3.5 py-2 rounded-md bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary text-sm text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
-          title="Z-10"
+          title="Bed down 10mm"
         >
           ↓10
         </button>

+ 42 - 14
frontend/src/components/control/ExtruderControls.tsx

@@ -1,5 +1,5 @@
 import { useState } from 'react';
-import { useMutation } from '@tanstack/react-query';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
 import { api, isConfirmationRequired } from '../../api/client';
 import type { PrinterStatus } from '../../api/client';
 import { ChevronUp, ChevronDown } from 'lucide-react';
@@ -13,23 +13,43 @@ interface ExtruderControlsProps {
 }
 
 export function ExtruderControls({ printerId, status, nozzleCount, disabled = false }: ExtruderControlsProps) {
+  const queryClient = useQueryClient();
   const isConnected = (status?.connected ?? false) && !disabled;
   const isPrinting = status?.state === 'RUNNING' || status?.state === 'PAUSE';
   const isDualNozzle = nozzleCount > 1;
 
-  const [selectedNozzle, setSelectedNozzle] = useState<'left' | 'right'>('left');
+  // Active extruder from live status: 0=right, 1=left
+  const activeExtruder = status?.active_extruder ?? 0;
+
   const [confirmModal, setConfirmModal] = useState<{
     token: string;
     warning: string;
     distance: number;
   } | null>(null);
 
+  const selectExtruderMutation = useMutation({
+    mutationFn: (extruder: number) => {
+      console.log('selectExtruder called with:', extruder);
+      return api.selectExtruder(printerId, extruder);
+    },
+    onSuccess: (data) => {
+      console.log('selectExtruder success:', data);
+      // Invalidate printer statuses to refresh the active extruder display
+      // Add a small delay to allow the printer to process the switch and MQTT to update
+      setTimeout(() => {
+        queryClient.invalidateQueries({ queryKey: ['printerStatuses'] });
+      }, 2000);
+    },
+    onError: (error) => {
+      console.error('selectExtruder error:', error);
+    },
+  });
+
   const extrudeMutation = useMutation({
     mutationFn: ({ distance, token }: { distance: number; token?: string }) => {
       // G-code for extrusion: relative mode, extrude, back to absolute
-      // T0/T1 selects the tool for dual nozzle
-      const toolSelect = isDualNozzle ? `T${selectedNozzle === 'left' ? 0 : 1}\n` : '';
-      const gcode = `${toolSelect}G91\nG1 E${distance} F300\nG90`;
+      // Uses currently active extruder
+      const gcode = `G91\nG1 E${distance} F300\nG90`;
       return api.sendGcode(printerId, gcode, token);
     },
     onSuccess: (result, variables) => {
@@ -55,18 +75,26 @@ export function ExtruderControls({ printerId, status, nozzleCount, disabled = fa
   };
 
   const isDisabled = !isConnected || isPrinting || extrudeMutation.isPending;
+  const isSwitching = selectExtruderMutation.isPending;
+
+  // Get extruder image based on active state
+  const getExtruderImage = () => {
+    if (!isDualNozzle) return "/icons/single-extruder1.png";
+    // activeExtruder: 0=right, 1=left
+    return activeExtruder === 1 ? "/icons/dual-extruder-left.png" : "/icons/dual-extruder-right.png";
+  };
 
   return (
     <>
       <div className="flex flex-col items-center gap-1.5 justify-center">
         {/* Left/Right Toggle - only for dual nozzle */}
         {isDualNozzle && (
-          <div className={`flex rounded-md overflow-hidden border border-bambu-dark-tertiary mb-1 flex-shrink-0 ${isDisabled ? 'opacity-50' : ''}`}>
+          <div className={`flex rounded-md overflow-hidden border border-bambu-dark-tertiary mb-1 flex-shrink-0 ${isDisabled || isSwitching ? 'opacity-50' : ''}`}>
             <button
-              onClick={() => setSelectedNozzle('left')}
-              disabled={isDisabled}
+              onClick={() => selectExtruderMutation.mutate(1)}
+              disabled={isDisabled || isSwitching}
               className={`px-3 py-1.5 text-sm border-r border-bambu-dark-tertiary transition-colors disabled:cursor-not-allowed ${
-                selectedNozzle === 'left'
+                activeExtruder === 1
                   ? 'bg-bambu-green text-white'
                   : 'bg-bambu-dark-secondary text-bambu-gray hover:bg-bambu-dark-tertiary disabled:hover:bg-bambu-dark-secondary'
               }`}
@@ -74,10 +102,10 @@ export function ExtruderControls({ printerId, status, nozzleCount, disabled = fa
               Left
             </button>
             <button
-              onClick={() => setSelectedNozzle('right')}
-              disabled={isDisabled}
+              onClick={() => selectExtruderMutation.mutate(0)}
+              disabled={isDisabled || isSwitching}
               className={`px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed ${
-                selectedNozzle === 'right'
+                activeExtruder === 0
                   ? 'bg-bambu-green text-white'
                   : 'bg-bambu-dark-secondary text-bambu-gray hover:bg-bambu-dark-tertiary disabled:hover:bg-bambu-dark-secondary'
               }`}
@@ -100,8 +128,8 @@ export function ExtruderControls({ printerId, status, nozzleCount, disabled = fa
         {/* Extruder Image */}
         <div className="h-[120px] flex items-center justify-center">
           <img
-            src={isDualNozzle ? "/icons/dual-extruder.png" : "/icons/single-extruder1.png"}
-            alt={isDualNozzle ? "Dual Extruder" : "Single Extruder"}
+            src={getExtruderImage()}
+            alt={isDualNozzle ? `${activeExtruder === 1 ? 'Left' : 'Right'} Extruder Active` : "Single Extruder"}
             className="h-full object-contain"
             onError={(e) => {
               (e.target as HTMLImageElement).style.display = 'none';

+ 129 - 60
frontend/src/components/control/JogPad.tsx

@@ -1,7 +1,7 @@
 import { useMutation } from '@tanstack/react-query';
 import { api, isConfirmationRequired } from '../../api/client';
 import type { PrinterStatus } from '../../api/client';
-import { useState } from 'react';
+import { useState, useId } from 'react';
 import { ConfirmModal } from '../ConfirmModal';
 
 interface JogPadProps {
@@ -10,8 +10,57 @@ interface JogPadProps {
   disabled?: boolean;
 }
 
+// Image map coordinates for 220x220 jog pad
+// The jog pad has concentric rings: outer (10mm), inner (1mm), center (home)
+const SIZE = 220;
+const CENTER = SIZE / 2; // 110
+
+// Ring radii (approximate based on typical jog pad design)
+const OUTER_RADIUS = 108;  // Outer edge
+const OUTER_INNER = 72;    // Inner edge of outer ring (10mm zone)
+const INNER_INNER = 35;    // Inner edge of inner ring (1mm zone)
+const HOME_RADIUS = 28;    // Home button radius
+
+// Generate polygon points for a ring segment (pie slice)
+function ringSegment(
+  cx: number, cy: number,
+  innerR: number, outerR: number,
+  startAngle: number, endAngle: number,
+  steps: number = 8
+): string {
+  const points: string[] = [];
+
+  // Outer arc (clockwise)
+  for (let i = 0; i <= steps; i++) {
+    const angle = startAngle + (endAngle - startAngle) * (i / steps);
+    const x = Math.round(cx + outerR * Math.cos(angle));
+    const y = Math.round(cy + outerR * Math.sin(angle));
+    points.push(`${x},${y}`);
+  }
+
+  // Inner arc (counter-clockwise)
+  for (let i = steps; i >= 0; i--) {
+    const angle = startAngle + (endAngle - startAngle) * (i / steps);
+    const x = Math.round(cx + innerR * Math.cos(angle));
+    const y = Math.round(cy + innerR * Math.sin(angle));
+    points.push(`${x},${y}`);
+  }
+
+  return points.join(',');
+}
+
+// Angle definitions (in radians, 0 = right, going clockwise)
+// Each direction covers 90 degrees (π/2), offset by 45 degrees (π/4)
+const ANGLES = {
+  up:    { start: -Math.PI * 3/4, end: -Math.PI / 4 },     // Top: -135° to -45°
+  right: { start: -Math.PI / 4, end: Math.PI / 4 },         // Right: -45° to 45°
+  down:  { start: Math.PI / 4, end: Math.PI * 3/4 },        // Bottom: 45° to 135°
+  left:  { start: Math.PI * 3/4, end: Math.PI * 5/4 },      // Left: 135° to 225° (or -135°)
+};
+
 export function JogPad({ printerId, status, disabled = false }: JogPadProps) {
   const isConnected = (status?.connected ?? false) && !disabled;
+  const mapId = useId();
 
   const [confirmModal, setConfirmModal] = useState<{
     action: string;
@@ -56,10 +105,12 @@ export function JogPad({ printerId, status, disabled = false }: JogPadProps) {
   });
 
   const handleHome = () => {
+    if (isDisabled) return;
     homeMutation.mutate({ axes: 'XY' });
   };
 
   const handleMove = (axis: string, distance: number) => {
+    if (isDisabled) return;
     moveMutation.mutate({ axis, distance });
   };
 
@@ -73,76 +124,94 @@ export function JogPad({ printerId, status, disabled = false }: JogPadProps) {
   const isLoading = homeMutation.isPending || moveMutation.isPending;
   const isDisabled = !isConnected || isLoading;
 
+  // Generate coordinates for circle (home button)
+  const homeCoords = Array.from({ length: 16 }, (_, i) => {
+    const angle = (i / 16) * Math.PI * 2;
+    const x = Math.round(CENTER + HOME_RADIUS * Math.cos(angle));
+    const y = Math.round(CENTER + HOME_RADIUS * Math.sin(angle));
+    return `${x},${y}`;
+  }).join(',');
+
   return (
     <>
       <div className="relative w-[220px] h-[220px] mb-3.5">
-        {/* Use the actual jogpad.svg from mockup */}
         <img
           src="/icons/jogpad.svg"
           alt="Jog Pad"
+          useMap={`#${mapId}`}
           className="w-full h-full jogpad-theme"
         />
 
-        {/* Invisible clickable areas overlaid on the SVG */}
-        {/* Outer ring - 10mm moves */}
-        <button
-          onClick={() => handleMove('Y', 10)}
-          disabled={isDisabled}
-          className="absolute top-[8px] left-1/2 -translate-x-1/2 w-[40px] h-[30px] opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
-          title="Y+10"
-        />
-        <button
-          onClick={() => handleMove('Y', -10)}
-          disabled={isDisabled}
-          className="absolute bottom-[8px] left-1/2 -translate-x-1/2 w-[40px] h-[30px] opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
-          title="Y-10"
-        />
-        <button
-          onClick={() => handleMove('X', -10)}
-          disabled={isDisabled}
-          className="absolute left-[8px] top-1/2 -translate-y-1/2 w-[30px] h-[40px] opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
-          title="X-10"
-        />
-        <button
-          onClick={() => handleMove('X', 10)}
-          disabled={isDisabled}
-          className="absolute right-[8px] top-1/2 -translate-y-1/2 w-[30px] h-[40px] opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
-          title="X+10"
-        />
+        <map name={mapId}>
+          {/* Outer ring - 10mm moves */}
+          <area
+            shape="poly"
+            coords={ringSegment(CENTER, CENTER, OUTER_INNER, OUTER_RADIUS, ANGLES.up.start, ANGLES.up.end)}
+            onClick={() => handleMove('Y', 10)}
+            title="Y+10mm"
+            style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
+          />
+          <area
+            shape="poly"
+            coords={ringSegment(CENTER, CENTER, OUTER_INNER, OUTER_RADIUS, ANGLES.down.start, ANGLES.down.end)}
+            onClick={() => handleMove('Y', -10)}
+            title="Y-10mm"
+            style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
+          />
+          <area
+            shape="poly"
+            coords={ringSegment(CENTER, CENTER, OUTER_INNER, OUTER_RADIUS, ANGLES.left.start, ANGLES.left.end)}
+            onClick={() => handleMove('X', -10)}
+            title="X-10mm"
+            style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
+          />
+          <area
+            shape="poly"
+            coords={ringSegment(CENTER, CENTER, OUTER_INNER, OUTER_RADIUS, ANGLES.right.start, ANGLES.right.end)}
+            onClick={() => handleMove('X', 10)}
+            title="X+10mm"
+            style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
+          />
 
-        {/* Inner ring - 1mm moves */}
-        <button
-          onClick={() => handleMove('Y', 1)}
-          disabled={isDisabled}
-          className="absolute top-[42px] left-1/2 -translate-x-1/2 w-[35px] h-[25px] opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
-          title="Y+1"
-        />
-        <button
-          onClick={() => handleMove('Y', -1)}
-          disabled={isDisabled}
-          className="absolute bottom-[42px] left-1/2 -translate-x-1/2 w-[35px] h-[25px] opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
-          title="Y-1"
-        />
-        <button
-          onClick={() => handleMove('X', -1)}
-          disabled={isDisabled}
-          className="absolute left-[42px] top-1/2 -translate-y-1/2 w-[25px] h-[35px] opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
-          title="X-1"
-        />
-        <button
-          onClick={() => handleMove('X', 1)}
-          disabled={isDisabled}
-          className="absolute right-[42px] top-1/2 -translate-y-1/2 w-[25px] h-[35px] opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
-          title="X+1"
-        />
+          {/* Inner ring - 1mm moves */}
+          <area
+            shape="poly"
+            coords={ringSegment(CENTER, CENTER, INNER_INNER, OUTER_INNER, ANGLES.up.start, ANGLES.up.end)}
+            onClick={() => handleMove('Y', 1)}
+            title="Y+1mm"
+            style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
+          />
+          <area
+            shape="poly"
+            coords={ringSegment(CENTER, CENTER, INNER_INNER, OUTER_INNER, ANGLES.down.start, ANGLES.down.end)}
+            onClick={() => handleMove('Y', -1)}
+            title="Y-1mm"
+            style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
+          />
+          <area
+            shape="poly"
+            coords={ringSegment(CENTER, CENTER, INNER_INNER, OUTER_INNER, ANGLES.left.start, ANGLES.left.end)}
+            onClick={() => handleMove('X', -1)}
+            title="X-1mm"
+            style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
+          />
+          <area
+            shape="poly"
+            coords={ringSegment(CENTER, CENTER, INNER_INNER, OUTER_INNER, ANGLES.right.start, ANGLES.right.end)}
+            onClick={() => handleMove('X', 1)}
+            title="X+1mm"
+            style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
+          />
 
-        {/* Home button in center - clickable overlay */}
-        <button
-          onClick={handleHome}
-          disabled={isDisabled}
-          className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[50px] h-[50px] rounded-full opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
-          title="Home XY"
-        />
+          {/* Center - Home button */}
+          <area
+            shape="poly"
+            coords={homeCoords}
+            onClick={handleHome}
+            title="Home XY"
+            style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
+          />
+        </map>
       </div>
 
       {/* Confirmation Modal */}

+ 1 - 1
frontend/src/components/control/SpeedModal.tsx

@@ -63,7 +63,7 @@ export function SpeedModal({ printer, status, onClose }: SpeedModalProps) {
       onClick={onClose}
     >
       <div
-        className="relative w-full max-w-md bg-bambu-dark-secondary rounded-2xl shadow-2xl border border-bambu-dark-tertiary overflow-hidden"
+        className="relative w-full max-w-lg bg-bambu-dark-secondary rounded-2xl shadow-2xl border border-bambu-dark-tertiary overflow-hidden"
         onClick={(e) => e.stopPropagation()}
       >
         {/* Header */}

+ 1 - 1
frontend/src/index.css

@@ -120,5 +120,5 @@ body {
 }
 
 .dark .jogpad-theme {
-  filter: brightness(0.7) contrast(1.1);
+  filter: brightness(0.35) contrast(1.2);
 }

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BAV1KG6Z.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DyjgQh1m.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-KY24LvJj.js


BIN
static/icons/dual-extruder-left.png


BIN
static/icons/dual-extruder-right.png


+ 2 - 2
static/index.html

@@ -7,8 +7,8 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-BGXbFbC2.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DyjgQh1m.css">
+    <script type="module" crossorigin src="/assets/index-KY24LvJj.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BAV1KG6Z.css">
   </head>
   <body>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов