- 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
This commit is contained in:
maziggy
2026-01-01 18:01:41 +01:00
parent fe250b9b7f
commit 2dcd1a2ef2
16 changed files with 1237 additions and 401 deletions
+14
View File
@@ -10,6 +10,18 @@ All notable changes to Bambuddy will be documented in this file.
- Pause/Resume toggle for pausing and resuming prints
- Confirmation modals for all actions to prevent accidental clicks
- 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:
- Menu button (⋮) appears on hover over AMS slots
- "Re-read RFID" option triggers filament info refresh
@@ -19,10 +31,12 @@ All notable changes to Bambuddy will be documented in this file.
### Changed
- **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
- Added integration tests for printer control endpoints (stop, pause, resume)
- 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
+1
View File
@@ -56,6 +56,7 @@
- Real-time printer status via WebSocket
- Live camera streaming (MJPEG) & snapshots
- Printer control (stop, pause, resume)
- Skip objects during print
- AMS slot RFID re-read
- HMS error monitoring with history
- Print success rates & trends
+96
View File
@@ -359,6 +359,7 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
ams_status_sub=state.ams_status_sub,
mc_print_sub_stage=state.mc_print_sub_stage,
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"}
@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
# =============================================================================
+44
View File
@@ -342,6 +342,27 @@ async def _send_print_start_notification(
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):
"""Handle print start - archive the 3MF file immediately."""
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}
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
# 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:
archive_data = {"print_time_seconds": existing_archive.print_time_seconds}
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
# 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}
await _send_print_start_notification(printer_id, data, archive_data, logger)
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:
if temp_path and temp_path.exists():
temp_path.unlink()
+2
View File
@@ -151,3 +151,5 @@ class PrinterStatus(BaseModel):
mc_print_sub_stage: int = 0
# Timestamp of last AMS data update (for RFID refresh detection)
last_ams_update: float = 0.0
# Number of printable objects in current print (for skip objects feature)
printable_objects_count: int = 0
+97 -3
View File
@@ -49,14 +49,21 @@ class ThreeMFParser:
return self.metadata
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:
if "Metadata/slice_info.config" in zf.namelist():
content = zf.read("Metadata/slice_info.config").decode()
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:
# Get prediction and weight from metadata elements
for meta in plate.findall("metadata"):
@@ -67,6 +74,24 @@ class ThreeMFParser:
elif key == "weight" and 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
# slice_info has <filament id="1" type="PLA" color="#FFFFFF" used_g="100" />
# Only include filaments where used_g > 0
@@ -315,6 +340,75 @@ class ThreeMFParser:
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:
"""Parser for extracting project page data from Bambu Lab 3MF files."""
+60
View File
@@ -146,6 +146,10 @@ class PrinterState:
ams_extruder_map: dict = field(default_factory=dict)
# Timestamp of last AMS data update (for RFID refresh detection)
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
@@ -1415,6 +1419,17 @@ class BambuMQTTClient:
logger.info(f"[{self.serial_number}] speed_level changed: {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
if "lights_report" in data:
lights = data["lights_report"]
@@ -2385,6 +2400,51 @@ class BambuMQTTClient:
logger.info(f"[{self.serial_number}] Sent resume print command")
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:
"""Send G-code command(s) to the printer.
+4 -1
View File
@@ -475,9 +475,12 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
# Calibration stage tracking
"stg_cur": state.stg_cur,
"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
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"
else:
result["cover_url"] = None
@@ -485,3 +485,229 @@ class TestAMSRefreshAPI:
assert response.status_code == 400
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
View File
@@ -163,6 +163,8 @@ export interface PrinterStatus {
mc_print_sub_stage: number;
// Timestamp of last AMS data update (for RFID refresh detection)
last_ams_update: number;
// Number of printable objects in current print (for skip objects feature)
printable_objects_count: number;
}
export interface PrinterCreate {
@@ -1289,6 +1291,24 @@ export const api = {
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
refreshAmsSlot: (printerId: number, amsId: number, slotId: number) =>
request<{ success: boolean; message: string }>(
+11
View File
@@ -188,7 +188,18 @@ export function useWebSocket() {
}
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':
// 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('archiveStats');
break;
+260 -1
View File
@@ -13,6 +13,7 @@ import {
Box,
HardDrive,
AlertTriangle,
AlertCircle,
Terminal,
Power,
PowerOff,
@@ -31,7 +32,22 @@ import {
Square,
Pause,
Play,
X,
Monitor,
} 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 { api, discoveryApi } from '../api/client';
import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter } from '../api/client';
@@ -680,6 +696,7 @@ function PrinterCard({
const [showStopConfirm, setShowStopConfirm] = useState(false);
const [showPauseConfirm, setShowPauseConfirm] = useState(false);
const [showResumeConfirm, setShowResumeConfirm] = useState(false);
const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
const [amsHistoryModal, setAmsHistoryModal] = useState<{
amsId: number;
amsLabel: string;
@@ -864,6 +881,26 @@ function PrinterCard({
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
const [refreshingSlot, setRefreshingSlot] = useState<{ amsId: number; slotId: number } | null>(null);
// Track if we've seen the printer enter "busy" state (ams_status_main !== 0)
@@ -1243,7 +1280,32 @@ function PrinterCard({
/* Expanded: Full status section */
<>
{/* 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">
{/* Cover Image */}
<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 */}
{showHMSModal && (
<HMSErrorModal
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<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>
<body>
<div id="root"></div>