Procházet zdrojové kódy

- Enhanced skip objects feature with visual improvements and better UX
- Skip Objects Modal:
- Object ID markers overlaid on preview image (grid layout)
- Large prominent ID badges to match printer display
- Info banner explaining to match IDs with printer screen
- Layer warning when skip unavailable (layer <= 1)
- Red badge on button showing skipped count
- Preview image now shown when paused (not just running)
- Close with ESC key or click outside modal
- Full light/dark theme support

- Backend:
- Extract object positions from 3MF (include_positions=True)
- API returns x/y coordinates for objects (when available)
- Parse s_obj from printer MQTT for skipped state persistence
- Cover URL available in PAUSE/PAUSED states

- Tests:
- Added test for objects with position data

maziggy před 4 měsíci
rodič
revize
2dcd1a2ef2

+ 14 - 0
CHANGELOG.md

@@ -10,6 +10,18 @@ All notable changes to Bambuddy will be documented in this file.
   - Pause/Resume toggle for pausing and resuming prints
   - Pause/Resume toggle for pausing and resuming prints
   - Confirmation modals for all actions to prevent accidental clicks
   - Confirmation modals for all actions to prevent accidental clicks
   - Toast notifications for action feedback
   - Toast notifications for action feedback
+- **Skip objects** - Skip individual objects during a print:
+  - Skip button in print status section (top right) when printing with 2+ objects
+  - Modal shows preview image with object ID markers overlaid
+  - Large ID badges to easily match with printer display
+  - Click to skip any object - it will not be printed
+  - Skipped objects shown with strikethrough styling and red badge on button
+  - Skip only available after layer 1 (printer limitation) with warning message
+  - Objects automatically loaded when print starts from 3MF metadata
+  - Parses skipped objects from printer MQTT for state persistence
+  - Light and dark theme support
+  - Close with ESC key or click outside
+  - Requires "Exclude Objects" option enabled in slicer
 - **AMS slot RFID re-read** - Re-read RFID data for individual AMS slots:
 - **AMS slot RFID re-read** - Re-read RFID data for individual AMS slots:
   - Menu button (⋮) appears on hover over AMS slots
   - Menu button (⋮) appears on hover over AMS slots
   - "Re-read RFID" option triggers filament info refresh
   - "Re-read RFID" option triggers filament info refresh
@@ -19,10 +31,12 @@ All notable changes to Bambuddy will be documented in this file.
 
 
 ### Changed
 ### Changed
 - **Temperature cards layout** - Refactored printer card layout with slimmer temperature displays to make room for control buttons
 - **Temperature cards layout** - Refactored printer card layout with slimmer temperature displays to make room for control buttons
+- **Cover image availability** - Print cover image now shown in PAUSE/PAUSED states (not just RUNNING) for skip objects modal
 
 
 ### Tests
 ### Tests
 - Added integration tests for printer control endpoints (stop, pause, resume)
 - Added integration tests for printer control endpoints (stop, pause, resume)
 - Added integration tests for AMS slot refresh endpoint
 - Added integration tests for AMS slot refresh endpoint
+- Added integration tests for skip objects endpoints (get objects, skip objects, objects with positions)
 
 
 ## [0.1.6b4] - 2026-01-01
 ## [0.1.6b4] - 2026-01-01
 
 

+ 1 - 0
README.md

@@ -56,6 +56,7 @@
 - Real-time printer status via WebSocket
 - Real-time printer status via WebSocket
 - Live camera streaming (MJPEG) & snapshots
 - Live camera streaming (MJPEG) & snapshots
 - Printer control (stop, pause, resume)
 - Printer control (stop, pause, resume)
+- Skip objects during print
 - AMS slot RFID re-read
 - AMS slot RFID re-read
 - HMS error monitoring with history
 - HMS error monitoring with history
 - Print success rates & trends
 - Print success rates & trends

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

@@ -359,6 +359,7 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
         ams_status_sub=state.ams_status_sub,
         ams_status_sub=state.ams_status_sub,
         mc_print_sub_stage=state.mc_print_sub_stage,
         mc_print_sub_stage=state.mc_print_sub_stage,
         last_ams_update=state.last_ams_update,
         last_ams_update=state.last_ams_update,
+        printable_objects_count=len(state.printable_objects),
     )
     )
 
 
 
 
@@ -1075,6 +1076,101 @@ async def resume_print(printer_id: int, db: AsyncSession = Depends(get_db)):
     return {"success": True, "message": "Print resume command sent"}
     return {"success": True, "message": "Print resume command sent"}
 
 
 
 
+@router.get("/{printer_id}/print/objects")
+async def get_printable_objects(printer_id: int, db: AsyncSession = Depends(get_db)):
+    """Get the list of printable objects for the current print.
+
+    Returns a list of objects with id, name, position (if available), and skip status.
+    Objects that have already been skipped are marked in the skipped_objects list.
+    """
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    # Return objects with their skip status and position data
+    objects = []
+    for obj_id, obj_data in client.state.printable_objects.items():
+        # Handle both old format (string name) and new format (dict with name, x, y)
+        if isinstance(obj_data, dict):
+            obj_entry = {
+                "id": obj_id,
+                "name": obj_data.get("name", f"Object {obj_id}"),
+                "x": obj_data.get("x"),
+                "y": obj_data.get("y"),
+                "skipped": obj_id in client.state.skipped_objects,
+            }
+        else:
+            # Legacy format: obj_data is just the name string
+            obj_entry = {
+                "id": obj_id,
+                "name": obj_data,
+                "x": None,
+                "y": None,
+                "skipped": obj_id in client.state.skipped_objects,
+            }
+        objects.append(obj_entry)
+
+    return {
+        "objects": objects,
+        "total": len(objects),
+        "skipped_count": len(client.state.skipped_objects),
+        "is_printing": client.state.state in ("RUNNING", "PAUSE"),
+    }
+
+
+@router.post("/{printer_id}/print/skip-objects")
+async def skip_objects(
+    printer_id: int,
+    object_ids: list[int],
+    db: AsyncSession = Depends(get_db),
+):
+    """Skip specific objects during the current print.
+
+    Args:
+        object_ids: List of object identify_id values to skip
+    """
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    if not object_ids:
+        raise HTTPException(400, "No object IDs provided")
+
+    # Validate object IDs exist in printable_objects
+    invalid_ids = [oid for oid in object_ids if oid not in client.state.printable_objects]
+    if invalid_ids:
+        raise HTTPException(400, f"Invalid object IDs: {invalid_ids}")
+
+    success = client.skip_objects(object_ids)
+    if not success:
+        raise HTTPException(500, "Failed to skip objects")
+
+    # Get names of skipped objects for response (handle both old and new format)
+    skipped_names = []
+    for oid in object_ids:
+        obj_data = client.state.printable_objects.get(oid, str(oid))
+        if isinstance(obj_data, dict):
+            skipped_names.append(obj_data.get("name", str(oid)))
+        else:
+            skipped_names.append(obj_data)
+
+    return {
+        "success": True,
+        "message": f"Skipped {len(object_ids)} object(s): {', '.join(skipped_names)}",
+        "skipped_objects": object_ids,
+    }
+
+
 # =============================================================================
 # =============================================================================
 # AMS Control Endpoints
 # AMS Control Endpoints
 # =============================================================================
 # =============================================================================

+ 44 - 0
backend/app/main.py

@@ -342,6 +342,27 @@ async def _send_print_start_notification(
         logger.warning(f"Notification on_print_start failed: {e}")
         logger.warning(f"Notification on_print_start failed: {e}")
 
 
 
 
+def _load_objects_from_archive(archive, printer_id: int, logger) -> None:
+    """Extract printable objects from an archive's 3MF file and store in printer state."""
+    try:
+        from backend.app.services.archive import extract_printable_objects_from_3mf
+
+        file_path = app_settings.base_dir / archive.file_path
+        if file_path.exists() and str(file_path).endswith(".3mf"):
+            with open(file_path, "rb") as f:
+                threemf_data = f.read()
+            # Extract with positions for UI overlay
+            printable_objects = extract_printable_objects_from_3mf(threemf_data, include_positions=True)
+            if printable_objects:
+                client = printer_manager.get_client(printer_id)
+                if client:
+                    client.state.printable_objects = printable_objects
+                    client.state.skipped_objects = []
+                    logger.info(f"Loaded {len(printable_objects)} printable objects for printer {printer_id}")
+    except Exception as e:
+        logger.debug(f"Failed to extract printable objects from archive: {e}")
+
+
 async def on_print_start(printer_id: int, data: dict):
 async def on_print_start(printer_id: int, data: dict):
     """Handle print start - archive the 3MF file immediately."""
     """Handle print start - archive the 3MF file immediately."""
     import logging
     import logging
@@ -470,6 +491,9 @@ async def on_print_start(printer_id: int, data: dict):
                     archive_data = {"print_time_seconds": archive.print_time_seconds}
                     archive_data = {"print_time_seconds": archive.print_time_seconds}
                     await _send_print_start_notification(printer_id, data, archive_data, logger)
                     await _send_print_start_notification(printer_id, data, archive_data, logger)
 
 
+                # Extract printable objects from the archived 3MF file
+                _load_objects_from_archive(archive, printer_id, logger)
+
             return  # Skip creating a new archive
             return  # Skip creating a new archive
 
 
         # Check if there's already a "printing" archive for this printer/file
         # Check if there's already a "printing" archive for this printer/file
@@ -508,6 +532,8 @@ async def on_print_start(printer_id: int, data: dict):
             if not notification_sent:
             if not notification_sent:
                 archive_data = {"print_time_seconds": existing_archive.print_time_seconds}
                 archive_data = {"print_time_seconds": existing_archive.print_time_seconds}
                 await _send_print_start_notification(printer_id, data, archive_data, logger)
                 await _send_print_start_notification(printer_id, data, archive_data, logger)
+            # Extract printable objects from the archived 3MF file
+            _load_objects_from_archive(existing_archive, printer_id, logger)
             return
             return
 
 
         # Build list of possible 3MF filenames to try
         # Build list of possible 3MF filenames to try
@@ -662,6 +688,24 @@ async def on_print_start(printer_id: int, data: dict):
                     archive_data = {"print_time_seconds": archive.print_time_seconds}
                     archive_data = {"print_time_seconds": archive.print_time_seconds}
                     await _send_print_start_notification(printer_id, data, archive_data, logger)
                     await _send_print_start_notification(printer_id, data, archive_data, logger)
                     notification_sent = True
                     notification_sent = True
+
+                # Extract printable objects for skip object functionality
+                try:
+                    from backend.app.services.archive import extract_printable_objects_from_3mf
+
+                    with open(temp_path, "rb") as f:
+                        threemf_data = f.read()
+                    # Extract with positions for UI overlay
+                    printable_objects = extract_printable_objects_from_3mf(threemf_data, include_positions=True)
+                    if printable_objects:
+                        # Store objects in printer state
+                        client = printer_manager.get_client(printer_id)
+                        if client:
+                            client.state.printable_objects = printable_objects
+                            client.state.skipped_objects = []  # Reset skipped objects for new print
+                            logger.info(f"Loaded {len(printable_objects)} printable objects for printer {printer_id}")
+                except Exception as e:
+                    logger.debug(f"Failed to extract printable objects: {e}")
         finally:
         finally:
             if temp_path and temp_path.exists():
             if temp_path and temp_path.exists():
                 temp_path.unlink()
                 temp_path.unlink()

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

@@ -151,3 +151,5 @@ class PrinterStatus(BaseModel):
     mc_print_sub_stage: int = 0
     mc_print_sub_stage: int = 0
     # Timestamp of last AMS data update (for RFID refresh detection)
     # Timestamp of last AMS data update (for RFID refresh detection)
     last_ams_update: float = 0.0
     last_ams_update: float = 0.0
+    # Number of printable objects in current print (for skip objects feature)
+    printable_objects_count: int = 0

+ 97 - 3
backend/app/services/archive.py

@@ -49,14 +49,21 @@ class ThreeMFParser:
         return self.metadata
         return self.metadata
 
 
     def _parse_slice_info(self, zf: zipfile.ZipFile):
     def _parse_slice_info(self, zf: zipfile.ZipFile):
-        """Parse slice_info.config for print settings."""
+        """Parse slice_info.config for print settings and printable objects."""
         try:
         try:
             if "Metadata/slice_info.config" in zf.namelist():
             if "Metadata/slice_info.config" in zf.namelist():
                 content = zf.read("Metadata/slice_info.config").decode()
                 content = zf.read("Metadata/slice_info.config").decode()
                 root = ET.fromstring(content)
                 root = ET.fromstring(content)
 
 
-                # Get first plate's metadata
-                plate = root.find(".//plate")
+                # Get the correct plate's metadata (use plate_number if specified)
+                if self.plate_number:
+                    plate = root.find(f".//plate[@plate_idx='{self.plate_number}']")
+                    if plate is None:
+                        # Fallback to first plate if specific plate not found
+                        plate = root.find(".//plate")
+                else:
+                    plate = root.find(".//plate")
+
                 if plate is not None:
                 if plate is not None:
                     # Get prediction and weight from metadata elements
                     # Get prediction and weight from metadata elements
                     for meta in plate.findall("metadata"):
                     for meta in plate.findall("metadata"):
@@ -67,6 +74,24 @@ class ThreeMFParser:
                         elif key == "weight" and value:
                         elif key == "weight" and value:
                             self.metadata["filament_used_grams"] = float(value)
                             self.metadata["filament_used_grams"] = float(value)
 
 
+                    # Extract printable objects for skip object functionality
+                    # Objects are stored as <object identify_id="123" name="Part1" skipped="false" />
+                    printable_objects = {}
+                    for obj in plate.findall("object"):
+                        identify_id = obj.get("identify_id")
+                        name = obj.get("name")
+                        skipped = obj.get("skipped", "false")
+
+                        # Only include objects that are not pre-skipped
+                        if identify_id and name and skipped.lower() != "true":
+                            try:
+                                printable_objects[int(identify_id)] = name
+                            except ValueError:
+                                pass
+
+                    if printable_objects:
+                        self.metadata["printable_objects"] = printable_objects
+
                 # Get filament info from filaments ACTUALLY USED in the print
                 # Get filament info from filaments ACTUALLY USED in the print
                 # slice_info has <filament id="1" type="PLA" color="#FFFFFF" used_g="100" />
                 # slice_info has <filament id="1" type="PLA" color="#FFFFFF" used_g="100" />
                 # Only include filaments where used_g > 0
                 # Only include filaments where used_g > 0
@@ -315,6 +340,75 @@ class ThreeMFParser:
                 break
                 break
 
 
 
 
+def extract_printable_objects_from_3mf(
+    data: bytes, plate_number: int | None = None, include_positions: bool = False
+) -> dict[int, str] | dict[int, dict]:
+    """Extract printable objects from 3MF file bytes.
+
+    This is a lightweight function used during print start to get the list
+    of objects that can be skipped.
+
+    Args:
+        data: Raw bytes of the 3MF file
+        plate_number: Which plate was printed (1-based), or None for first plate
+        include_positions: If True, return dict with name and position info
+
+    Returns:
+        If include_positions=False: Dictionary mapping identify_id (int) to object name (str)
+        If include_positions=True: Dictionary mapping identify_id to {name, x, y} dict
+    """
+    from io import BytesIO
+
+    printable_objects: dict = {}
+
+    try:
+        with zipfile.ZipFile(BytesIO(data), "r") as zf:
+            if "Metadata/slice_info.config" not in zf.namelist():
+                return printable_objects
+
+            content = zf.read("Metadata/slice_info.config").decode()
+            root = ET.fromstring(content)
+
+            # Find the correct plate
+            if plate_number:
+                plate = root.find(f".//plate[@plate_idx='{plate_number}']")
+                if plate is None:
+                    plate = root.find(".//plate")
+            else:
+                plate = root.find(".//plate")
+
+            if plate is None:
+                return printable_objects
+
+            # Extract objects
+            for obj in plate.findall("object"):
+                identify_id = obj.get("identify_id")
+                name = obj.get("name")
+                skipped = obj.get("skipped", "false")
+
+                if identify_id and name and skipped.lower() != "true":
+                    try:
+                        obj_id = int(identify_id)
+                        if include_positions:
+                            # Try to get position from various possible attributes
+                            x = obj.get("x") or obj.get("pos_x") or obj.get("center_x")
+                            y = obj.get("y") or obj.get("pos_y") or obj.get("center_y")
+                            printable_objects[obj_id] = {
+                                "name": name,
+                                "x": float(x) if x else None,
+                                "y": float(y) if y else None,
+                            }
+                        else:
+                            printable_objects[obj_id] = name
+                    except ValueError:
+                        pass
+
+    except Exception:
+        pass
+
+    return printable_objects
+
+
 class ProjectPageParser:
 class ProjectPageParser:
     """Parser for extracting project page data from Bambu Lab 3MF files."""
     """Parser for extracting project page data from Bambu Lab 3MF files."""
 
 

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

@@ -146,6 +146,10 @@ class PrinterState:
     ams_extruder_map: dict = field(default_factory=dict)
     ams_extruder_map: dict = field(default_factory=dict)
     # Timestamp of last AMS data update (for RFID refresh detection)
     # Timestamp of last AMS data update (for RFID refresh detection)
     last_ams_update: float = 0.0
     last_ams_update: float = 0.0
+    # Printable objects for skip object functionality: {identify_id: object_name}
+    printable_objects: dict = field(default_factory=dict)
+    # Objects that have been skipped during the current print
+    skipped_objects: list = field(default_factory=list)
 
 
 
 
 # Stage name mapping from BambuStudio DeviceManager.cpp
 # Stage name mapping from BambuStudio DeviceManager.cpp
@@ -1415,6 +1419,17 @@ class BambuMQTTClient:
                 logger.info(f"[{self.serial_number}] speed_level changed: {self.state.speed_level} -> {new_speed}")
                 logger.info(f"[{self.serial_number}] speed_level changed: {self.state.speed_level} -> {new_speed}")
             self.state.speed_level = new_speed
             self.state.speed_level = new_speed
 
 
+        # Parse skipped objects from printer status (s_obj field)
+        # This allows us to restore skipped objects state after reconnection
+        if "s_obj" in data:
+            s_obj = data["s_obj"]
+            if isinstance(s_obj, list):
+                # Update skipped objects from printer's list
+                new_skipped = [int(oid) for oid in s_obj if isinstance(oid, (int, str))]
+                if new_skipped != self.state.skipped_objects:
+                    logger.info(f"[{self.serial_number}] skipped_objects updated from printer: {new_skipped}")
+                    self.state.skipped_objects = new_skipped
+
         # Parse chamber light status from lights_report
         # Parse chamber light status from lights_report
         if "lights_report" in data:
         if "lights_report" in data:
             lights = data["lights_report"]
             lights = data["lights_report"]
@@ -2385,6 +2400,51 @@ class BambuMQTTClient:
         logger.info(f"[{self.serial_number}] Sent resume print command")
         logger.info(f"[{self.serial_number}] Sent resume print command")
         return True
         return True
 
 
+    def skip_objects(self, object_ids: list[int]) -> bool:
+        """Skip specific objects during a print.
+
+        This command tells the printer to skip printing the specified objects.
+        The object IDs come from the slice_info.config file in the 3MF.
+
+        Args:
+            object_ids: List of identify_id values from slice_info.config
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        if not self._client or not self.state.connected:
+            logger.warning(f"[{self.serial_number}] Cannot skip objects: not connected")
+            return False
+
+        if self.state.state != "RUNNING" and self.state.state != "PAUSE":
+            logger.warning(
+                f"[{self.serial_number}] Cannot skip objects: printer not printing (state={self.state.state})"
+            )
+            return False
+
+        if not object_ids:
+            logger.warning(f"[{self.serial_number}] Cannot skip objects: no object IDs provided")
+            return False
+
+        # Validate all IDs are integers
+        try:
+            obj_list = [int(oid) for oid in object_ids]
+        except (ValueError, TypeError) as e:
+            logger.warning(f"[{self.serial_number}] Invalid object IDs: {e}")
+            return False
+
+        self._sequence_id += 1
+        command = {"print": {"sequence_id": str(self._sequence_id), "command": "skip_objects", "obj_list": obj_list}}
+        self._client.publish(self.topic_publish, json.dumps(command), qos=1)
+        logger.info(f"[{self.serial_number}] Sent skip_objects command: {obj_list}")
+
+        # Track skipped objects in state
+        for oid in obj_list:
+            if oid not in self.state.skipped_objects:
+                self.state.skipped_objects.append(oid)
+
+        return True
+
     def send_gcode(self, gcode: str) -> bool:
     def send_gcode(self, gcode: str) -> bool:
         """Send G-code command(s) to the printer.
         """Send G-code command(s) to the printer.
 
 

+ 4 - 1
backend/app/services/printer_manager.py

@@ -475,9 +475,12 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
         # Calibration stage tracking
         # Calibration stage tracking
         "stg_cur": state.stg_cur,
         "stg_cur": state.stg_cur,
         "stg_cur_name": get_derived_status_name(state),
         "stg_cur_name": get_derived_status_name(state),
+        # Printable objects count for skip objects feature
+        "printable_objects_count": len(state.printable_objects),
     }
     }
     # Add cover URL if there's an active print and printer_id is provided
     # Add cover URL if there's an active print and printer_id is provided
-    if printer_id and state.state == "RUNNING" and state.gcode_file:
+    # Include PAUSE/PAUSED states so skip objects modal can show cover
+    if printer_id and state.state in ("RUNNING", "PAUSE", "PAUSED") and state.gcode_file:
         result["cover_url"] = f"/api/v1/printers/{printer_id}/cover"
         result["cover_url"] = f"/api/v1/printers/{printer_id}/cover"
     else:
     else:
         result["cover_url"] = None
         result["cover_url"] = None

+ 226 - 0
backend/tests/integration/test_printers_api.py

@@ -485,3 +485,229 @@ class TestAMSRefreshAPI:
 
 
             assert response.status_code == 400
             assert response.status_code == 400
             assert "unload" in response.json()["detail"].lower()
             assert "unload" in response.json()["detail"].lower()
+
+
+class TestSkipObjectsAPI:
+    """Integration tests for skip objects endpoints."""
+
+    # ========================================================================
+    # Get printable objects endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_objects_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent printer."""
+        response = await async_client.get("/api/v1/printers/99999/print/objects")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_objects_not_connected(self, async_client: AsyncClient, printer_factory):
+        """Verify error when printer is not connected."""
+        printer = await printer_factory(name="Disconnected Printer")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
+
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_objects_empty(self, async_client: AsyncClient, printer_factory):
+        """Verify empty objects list when no print is active."""
+        printer = await printer_factory(name="Idle Printer")
+
+        mock_client = MagicMock()
+        mock_client.state.printable_objects = {}
+        mock_client.state.skipped_objects = []
+        mock_client.state.state = "IDLE"
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["objects"] == []
+            assert result["total"] == 0
+            assert result["skipped_count"] == 0
+            assert result["is_printing"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_objects_with_data(self, async_client: AsyncClient, printer_factory):
+        """Verify objects list when print is active."""
+        printer = await printer_factory(name="Printing Printer")
+
+        mock_client = MagicMock()
+        mock_client.state.printable_objects = {100: "Part A", 200: "Part B", 300: "Part C"}
+        mock_client.state.skipped_objects = [200]
+        mock_client.state.state = "RUNNING"
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["total"] == 3
+            assert result["skipped_count"] == 1
+            assert result["is_printing"] is True
+
+            # Check objects have correct structure
+            objects_by_id = {obj["id"]: obj for obj in result["objects"]}
+            assert objects_by_id[100]["name"] == "Part A"
+            assert objects_by_id[100]["skipped"] is False
+            assert objects_by_id[200]["name"] == "Part B"
+            assert objects_by_id[200]["skipped"] is True
+            assert objects_by_id[300]["name"] == "Part C"
+            assert objects_by_id[300]["skipped"] is False
+
+    # ========================================================================
+    # Skip objects endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_objects_with_positions(self, async_client: AsyncClient, printer_factory):
+        """Verify objects list includes position data when available."""
+        printer = await printer_factory(name="Printing Printer")
+
+        # New format with position data
+        mock_client = MagicMock()
+        mock_client.state.printable_objects = {
+            100: {"name": "Part A", "x": 50.0, "y": 100.0},
+            200: {"name": "Part B", "x": 150.0, "y": 100.0},
+        }
+        mock_client.state.skipped_objects = []
+        mock_client.state.state = "RUNNING"
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["total"] == 2
+
+            # Check objects have position data
+            objects_by_id = {obj["id"]: obj for obj in result["objects"]}
+            assert objects_by_id[100]["name"] == "Part A"
+            assert objects_by_id[100]["x"] == 50.0
+            assert objects_by_id[100]["y"] == 100.0
+            assert objects_by_id[200]["name"] == "Part B"
+            assert objects_by_id[200]["x"] == 150.0
+            assert objects_by_id[200]["y"] == 100.0
+
+    # ========================================================================
+    # Skip objects endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_skip_objects_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent printer."""
+        response = await async_client.post("/api/v1/printers/99999/print/skip-objects", json=[100])
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_skip_objects_not_connected(self, async_client: AsyncClient, printer_factory):
+        """Verify error when printer is not connected."""
+        printer = await printer_factory(name="Disconnected Printer")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100])
+
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_skip_objects_empty_list(self, async_client: AsyncClient, printer_factory):
+        """Verify error when no object IDs provided."""
+        printer = await printer_factory(name="Printing Printer")
+
+        mock_client = MagicMock()
+        mock_client.state.printable_objects = {100: "Part A"}
+        mock_client.state.skipped_objects = []
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[])
+
+            assert response.status_code == 400
+            assert "no object" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_skip_objects_invalid_id(self, async_client: AsyncClient, printer_factory):
+        """Verify error when object ID doesn't exist."""
+        printer = await printer_factory(name="Printing Printer")
+
+        mock_client = MagicMock()
+        mock_client.state.printable_objects = {100: "Part A"}
+        mock_client.state.skipped_objects = []
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[999])
+
+            assert response.status_code == 400
+            assert "invalid" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_skip_objects_success(self, async_client: AsyncClient, printer_factory):
+        """Verify successful skip objects request."""
+        printer = await printer_factory(name="Printing Printer")
+
+        mock_client = MagicMock()
+        mock_client.state.printable_objects = {100: "Part A", 200: "Part B"}
+        mock_client.state.skipped_objects = []
+        mock_client.skip_objects.return_value = True
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100])
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["success"] is True
+            assert 100 in result["skipped_objects"]
+            mock_client.skip_objects.assert_called_once_with([100])
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_skip_objects_multiple(self, async_client: AsyncClient, printer_factory):
+        """Verify skipping multiple objects at once."""
+        printer = await printer_factory(name="Printing Printer")
+
+        mock_client = MagicMock()
+        mock_client.state.printable_objects = {100: "Part A", 200: "Part B", 300: "Part C"}
+        mock_client.state.skipped_objects = []
+        mock_client.skip_objects.return_value = True
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100, 200])
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["success"] is True
+            assert 100 in result["skipped_objects"]
+            assert 200 in result["skipped_objects"]
+            mock_client.skip_objects.assert_called_once_with([100, 200])

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

@@ -163,6 +163,8 @@ export interface PrinterStatus {
   mc_print_sub_stage: number;
   mc_print_sub_stage: number;
   // Timestamp of last AMS data update (for RFID refresh detection)
   // Timestamp of last AMS data update (for RFID refresh detection)
   last_ams_update: number;
   last_ams_update: number;
+  // Number of printable objects in current print (for skip objects feature)
+  printable_objects_count: number;
 }
 }
 
 
 export interface PrinterCreate {
 export interface PrinterCreate {
@@ -1289,6 +1291,24 @@ export const api = {
       method: 'POST',
       method: 'POST',
     }),
     }),
 
 
+  // Skip Objects
+  getPrintableObjects: (printerId: number) =>
+    request<{
+      objects: Array<{ id: number; name: string; x: number | null; y: number | null; skipped: boolean }>;
+      total: number;
+      skipped_count: number;
+      is_printing: boolean;
+    }>(`/printers/${printerId}/print/objects`),
+
+  skipObjects: (printerId: number, objectIds: number[]) =>
+    request<{ success: boolean; message: string; skipped_objects: number[] }>(
+      `/printers/${printerId}/print/skip-objects`,
+      {
+        method: 'POST',
+        body: JSON.stringify(objectIds),
+      }
+    ),
+
   // AMS Control
   // AMS Control
   refreshAmsSlot: (printerId: number, amsId: number, slotId: number) =>
   refreshAmsSlot: (printerId: number, amsId: number, slotId: number) =>
     request<{ success: boolean; message: string }>(
     request<{ success: boolean; message: string }>(

+ 11 - 0
frontend/src/hooks/useWebSocket.ts

@@ -188,7 +188,18 @@ export function useWebSocket() {
         }
         }
         break;
         break;
 
 
+      case 'print_start':
+        // Refetch printer status immediately when print starts to get printable_objects_count
+        if (message.printer_id !== undefined) {
+          queryClient.invalidateQueries({ queryKey: ['printerStatus', message.printer_id] });
+        }
+        break;
+
       case 'print_complete':
       case 'print_complete':
+        // Refetch printer status when print completes to clear printable_objects_count
+        if (message.printer_id !== undefined) {
+          queryClient.invalidateQueries({ queryKey: ['printerStatus', message.printer_id] });
+        }
         debouncedInvalidate('archives');
         debouncedInvalidate('archives');
         debouncedInvalidate('archiveStats');
         debouncedInvalidate('archiveStats');
         break;
         break;

+ 260 - 1
frontend/src/pages/PrintersPage.tsx

@@ -13,6 +13,7 @@ import {
   Box,
   Box,
   HardDrive,
   HardDrive,
   AlertTriangle,
   AlertTriangle,
+  AlertCircle,
   Terminal,
   Terminal,
   Power,
   Power,
   PowerOff,
   PowerOff,
@@ -31,7 +32,22 @@ import {
   Square,
   Square,
   Pause,
   Pause,
   Play,
   Play,
+  X,
+  Monitor,
 } from 'lucide-react';
 } from 'lucide-react';
+
+// Custom Skip Objects icon - arrow jumping over boxes
+const SkipObjectsIcon = ({ className }: { className?: string }) => (
+  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
+    {/* Three boxes at the bottom */}
+    <rect x="2" y="15" width="5" height="5" rx="0.5" />
+    <rect x="9.5" y="15" width="5" height="5" rx="0.5" fill="currentColor" opacity="0.3" />
+    <rect x="17" y="15" width="5" height="5" rx="0.5" />
+    {/* Curved arrow jumping over first box */}
+    <path d="M4 12 C4 6, 14 6, 14 12" />
+    <polyline points="12,10 14,12 12,14" />
+  </svg>
+);
 import { useNavigate } from 'react-router-dom';
 import { useNavigate } from 'react-router-dom';
 import { api, discoveryApi } from '../api/client';
 import { api, discoveryApi } from '../api/client';
 import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter } from '../api/client';
 import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter } from '../api/client';
@@ -680,6 +696,7 @@ function PrinterCard({
   const [showStopConfirm, setShowStopConfirm] = useState(false);
   const [showStopConfirm, setShowStopConfirm] = useState(false);
   const [showPauseConfirm, setShowPauseConfirm] = useState(false);
   const [showPauseConfirm, setShowPauseConfirm] = useState(false);
   const [showResumeConfirm, setShowResumeConfirm] = useState(false);
   const [showResumeConfirm, setShowResumeConfirm] = useState(false);
+  const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
   const [amsHistoryModal, setAmsHistoryModal] = useState<{
   const [amsHistoryModal, setAmsHistoryModal] = useState<{
     amsId: number;
     amsId: number;
     amsLabel: string;
     amsLabel: string;
@@ -864,6 +881,26 @@ function PrinterCard({
     onError: (error: Error) => showToast(error.message || 'Failed to resume print', 'error'),
     onError: (error: Error) => showToast(error.message || 'Failed to resume print', 'error'),
   });
   });
 
 
+  // Query for printable objects (for skip functionality)
+  // Fetch when printing with 2+ objects OR when modal is open
+  const isPrintingWithObjects = (status?.state === 'RUNNING' || status?.state === 'PAUSE' || status?.state === 'PAUSED') && (status?.printable_objects_count ?? 0) >= 2;
+  const { data: objectsData, refetch: refetchObjects } = useQuery({
+    queryKey: ['printableObjects', printer.id],
+    queryFn: () => api.getPrintableObjects(printer.id),
+    enabled: showSkipObjectsModal || isPrintingWithObjects,
+    refetchInterval: showSkipObjectsModal ? 5000 : (isPrintingWithObjects ? 30000 : false), // 5s when modal open, 30s otherwise
+  });
+
+  // Skip objects mutation
+  const skipObjectsMutation = useMutation({
+    mutationFn: (objectIds: number[]) => api.skipObjects(printer.id, objectIds),
+    onSuccess: (data) => {
+      showToast(data.message || 'Objects skipped');
+      refetchObjects();
+    },
+    onError: (error: Error) => showToast(error.message || 'Failed to skip objects', 'error'),
+  });
+
   // State for tracking which AMS slot is being refreshed
   // State for tracking which AMS slot is being refreshed
   const [refreshingSlot, setRefreshingSlot] = useState<{ amsId: number; slotId: number } | null>(null);
   const [refreshingSlot, setRefreshingSlot] = useState<{ amsId: number; slotId: number } | null>(null);
   // Track if we've seen the printer enter "busy" state (ams_status_main !== 0)
   // Track if we've seen the printer enter "busy" state (ams_status_main !== 0)
@@ -1243,7 +1280,32 @@ function PrinterCard({
               /* Expanded: Full status section */
               /* Expanded: Full status section */
               <>
               <>
                 {/* Current Print or Idle Placeholder */}
                 {/* Current Print or Idle Placeholder */}
-                <div className="mb-4 p-3 bg-bambu-dark rounded-lg">
+                <div className="mb-4 p-3 bg-bambu-dark rounded-lg relative">
+                  {/* Skip Objects button - top right corner, always visible */}
+                  <button
+                    onClick={() => setShowSkipObjectsModal(true)}
+                    disabled={!(status.state === 'RUNNING' || status.state === 'PAUSE' || status.state === 'PAUSED') || (status.printable_objects_count ?? 0) < 2}
+                    className={`absolute top-2 right-2 p-1.5 rounded transition-colors z-10 ${
+                      (status.state === 'RUNNING' || status.state === 'PAUSE' || status.state === 'PAUSED') && (status.printable_objects_count ?? 0) >= 2
+                        ? 'text-bambu-gray hover:text-white hover:bg-white/10'
+                        : 'text-bambu-gray/30 cursor-not-allowed'
+                    }`}
+                    title={
+                      !(status.state === 'RUNNING' || status.state === 'PAUSE' || status.state === 'PAUSED')
+                        ? "Skip objects (only while printing)"
+                        : (status.printable_objects_count ?? 0) >= 2
+                          ? "Skip objects"
+                          : "Skip objects (requires 2+ objects)"
+                    }
+                  >
+                    <SkipObjectsIcon className="w-4 h-4" />
+                    {/* Badge showing skipped count */}
+                    {objectsData && objectsData.skipped_count > 0 && (
+                      <span className="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 flex items-center justify-center text-[10px] font-bold bg-red-500 text-white rounded-full">
+                        {objectsData.skipped_count}
+                      </span>
+                    )}
+                  </button>
                   <div className="flex gap-3">
                   <div className="flex gap-3">
                     {/* Cover Image */}
                     {/* Cover Image */}
                     <CoverImage
                     <CoverImage
@@ -2075,6 +2137,203 @@ function PrinterCard({
         />
         />
       )}
       )}
 
 
+      {/* Skip Objects Popup */}
+      {showSkipObjectsModal && (
+        <div
+          className="fixed inset-0 z-50 flex items-center justify-center"
+          onClick={() => setShowSkipObjectsModal(false)}
+          onKeyDown={(e) => e.key === 'Escape' && setShowSkipObjectsModal(false)}
+          tabIndex={-1}
+          ref={(el) => el?.focus()}
+        >
+          {/* Backdrop */}
+          <div className="absolute inset-0 bg-black/50 z-0" />
+          {/* Modal */}
+          <div
+            className="relative z-10 bg-white dark:bg-bambu-dark border border-gray-200 dark:border-bambu-dark-tertiary rounded-xl shadow-2xl w-[560px] overflow-hidden"
+            onClick={(e) => e.stopPropagation()}
+          >
+          {/* Header */}
+          <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark">
+            <div className="flex items-center gap-2">
+              <SkipObjectsIcon className="w-4 h-4 text-bambu-green" />
+              <span className="text-sm font-medium text-gray-900 dark:text-white">Skip Objects</span>
+            </div>
+            <button
+              onClick={() => setShowSkipObjectsModal(false)}
+              className="p-1 text-gray-500 dark:text-bambu-gray hover:text-gray-900 dark:hover:text-white rounded transition-colors"
+            >
+              <X className="w-4 h-4" />
+            </button>
+          </div>
+
+          {!objectsData ? (
+            <div className="flex items-center justify-center py-12">
+              <Loader2 className="w-5 h-5 animate-spin text-bambu-gray" />
+            </div>
+          ) : objectsData.objects.length === 0 ? (
+            <div className="text-center py-8 px-4 text-bambu-gray">
+              <p className="text-sm">No objects found</p>
+              <p className="text-xs mt-1 opacity-70">Objects are loaded when a print starts</p>
+            </div>
+          ) : (
+            <div className="flex flex-col">
+              {/* Info Banner */}
+              <div className="flex items-center gap-3 px-4 py-2.5 bg-blue-50 dark:bg-blue-500/10 border-b border-gray-200 dark:border-bambu-dark-tertiary">
+                <div className="flex-shrink-0 w-8 h-8 rounded-lg bg-blue-100 dark:bg-blue-500/20 flex items-center justify-center">
+                  <Monitor className="w-4 h-4 text-blue-500 dark:text-blue-400" />
+                </div>
+                <div className="flex-1 min-w-0">
+                  <p className="text-xs text-blue-600 dark:text-blue-300">Match IDs with your printer display</p>
+                  <p className="text-[10px] text-blue-500/70 dark:text-blue-300/60">The printer screen shows object IDs on the build plate</p>
+                </div>
+                <div className="flex-shrink-0 text-xs text-gray-500 dark:text-bambu-gray">
+                  {objectsData.skipped_count}/{objectsData.total} skipped
+                </div>
+              </div>
+
+              {/* Layer Warning */}
+              {(status?.layer_num ?? 0) <= 1 && (
+                <div className="flex items-center gap-2 px-4 py-2 bg-amber-50 dark:bg-amber-500/10 border-b border-gray-200 dark:border-bambu-dark-tertiary">
+                  <AlertCircle className="w-4 h-4 text-amber-500 dark:text-amber-400 flex-shrink-0" />
+                  <p className="text-xs text-amber-600 dark:text-amber-400">
+                    Wait for layer 2+ to skip objects (currently layer {status?.layer_num ?? 0})
+                  </p>
+                </div>
+              )}
+
+              {/* Content: Image + List side by side */}
+              <div className="flex">
+                {/* Left: Preview Image with object markers */}
+                <div className="w-52 flex-shrink-0 p-4 border-r border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark-secondary">
+                  <div className="relative">
+                    {status?.cover_url ? (
+                      <img
+                        src={status.cover_url}
+                        alt="Print preview"
+                        className="w-full aspect-square object-contain rounded-lg bg-gray-100 dark:bg-bambu-dark"
+                      />
+                    ) : (
+                      <div className="w-full aspect-square rounded-lg bg-gray-100 dark:bg-bambu-dark flex items-center justify-center">
+                        <Box className="w-8 h-8 text-gray-300 dark:text-bambu-gray/30" />
+                      </div>
+                    )}
+                    {/* Object ID markers overlay - positioned based on object data */}
+                    {objectsData.objects.length > 0 && (
+                      <div className="absolute inset-0 pointer-events-none">
+                        {objectsData.objects.map((obj, idx) => {
+                          // Build plate is typically 256x256mm for X1C
+                          const buildPlateSize = 256;
+                          let x: number, y: number;
+
+                          // Use position data if available, otherwise fall back to grid
+                          if (obj.x != null && obj.y != null) {
+                            // Convert mm position to percentage (0-100)
+                            // Clamp to valid range and add padding
+                            x = Math.max(10, Math.min(90, (obj.x / buildPlateSize) * 100));
+                            y = Math.max(10, Math.min(90, (obj.y / buildPlateSize) * 100));
+                          } else {
+                            // Fallback: arrange in a grid pattern over the build plate area
+                            const cols = Math.ceil(Math.sqrt(objectsData.objects.length));
+                            const row = Math.floor(idx / cols);
+                            const col = idx % cols;
+                            const rows = Math.ceil(objectsData.objects.length / cols);
+                            x = 15 + (col * (70 / cols)) + (35 / cols);
+                            y = 15 + (row * (70 / rows)) + (35 / rows);
+                          }
+
+                          return (
+                            <div
+                              key={obj.id}
+                              className={`absolute flex items-center justify-center w-6 h-6 rounded-full text-[10px] font-bold shadow-lg ${
+                                obj.skipped
+                                  ? 'bg-red-500 text-white line-through'
+                                  : 'bg-bambu-green text-black'
+                              }`}
+                              style={{
+                                left: `${x}%`,
+                                top: `${y}%`,
+                                transform: 'translate(-50%, -50%)'
+                              }}
+                              title={obj.name}
+                            >
+                              {obj.id}
+                            </div>
+                          );
+                        })}
+                      </div>
+                    )}
+                    {/* Object count overlay */}
+                    <div className="absolute bottom-2 right-2 px-2 py-1 bg-white/90 dark:bg-black/80 rounded text-[10px] text-gray-700 dark:text-white shadow-sm">
+                      {objectsData.objects.filter(o => !o.skipped).length} active
+                    </div>
+                  </div>
+                </div>
+
+                {/* Right: Object List with prominent IDs */}
+                <div className="flex-1 min-w-0">
+                  {objectsData.objects.map((obj) => (
+                    <div
+                      key={obj.id}
+                      className={`
+                        flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-bambu-dark-tertiary/50 last:border-0
+                        ${obj.skipped ? 'bg-red-50 dark:bg-red-500/10' : 'hover:bg-gray-50 dark:hover:bg-bambu-dark/50'}
+                      `}
+                    >
+                      {/* Large prominent ID badge */}
+                      <div className={`
+                        w-12 h-12 flex-shrink-0 rounded-lg flex flex-col items-center justify-center
+                        ${obj.skipped
+                          ? 'bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/40'
+                          : 'bg-green-100 dark:bg-bambu-green/20 border border-green-300 dark:border-bambu-green/40'}
+                      `}>
+                        <span className={`text-lg font-mono font-bold ${obj.skipped ? 'text-red-500 dark:text-red-400' : 'text-green-600 dark:text-bambu-green'}`}>
+                          {obj.id}
+                        </span>
+                        <span className={`text-[8px] uppercase tracking-wider ${obj.skipped ? 'text-red-400/60' : 'text-green-500/60 dark:text-bambu-green/60'}`}>
+                          ID
+                        </span>
+                      </div>
+
+                      {/* Object name and status */}
+                      <div className="flex-1 min-w-0">
+                        <span className={`block text-sm truncate ${obj.skipped ? 'text-red-500 dark:text-red-400 line-through' : 'text-gray-900 dark:text-white'}`}>
+                          {obj.name}
+                        </span>
+                        {obj.skipped && (
+                          <span className="text-[10px] text-red-400/60">Will be skipped</span>
+                        )}
+                      </div>
+
+                      {/* Skip button */}
+                      {!obj.skipped ? (
+                        <button
+                          onClick={() => skipObjectsMutation.mutate([obj.id])}
+                          disabled={skipObjectsMutation.isPending || (status?.layer_num ?? 0) <= 1}
+                          className={`px-4 py-2 text-xs font-medium rounded-lg transition-colors ${
+                            (status?.layer_num ?? 0) <= 1
+                              ? 'bg-gray-100 dark:bg-bambu-dark text-gray-400 dark:text-bambu-gray/50 cursor-not-allowed'
+                              : 'bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-500/30 border border-red-300 dark:border-red-500/30'
+                          }`}
+                          title={(status?.layer_num ?? 0) <= 1 ? 'Wait for layer 2+' : 'Skip this object'}
+                        >
+                          Skip
+                        </button>
+                      ) : (
+                        <span className="px-4 py-2 text-xs text-red-500 dark:text-red-400/70 bg-red-100 dark:bg-red-500/10 rounded-lg">
+                          Skipped
+                        </span>
+                      )}
+                    </div>
+                  ))}
+                </div>
+              </div>
+            </div>
+          )}
+          </div>
+        </div>
+      )}
+
       {/* HMS Error Modal */}
       {/* HMS Error Modal */}
       {showHMSModal && (
       {showHMSModal && (
         <HMSErrorModal
         <HMSErrorModal

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-BJk0xs4c.css


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-CPQbpodw.css


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-Cciht2VR.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-3UvyJ1HQ.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BJk0xs4c.css">
+    <script type="module" crossorigin src="/assets/index-Cciht2VR.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CPQbpodw.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů