mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
- 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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 }>(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user