maziggy 3 mesi fa
parent
commit
39f90616f3

+ 7 - 0
CHANGELOG.md

@@ -6,6 +6,13 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.1.8b] - Not released
 
 ### Enhanced
+- **3D Model Viewer Improvements** (PR #262):
+  - Added plate selector for multi-plate 3MF files with thumbnail previews
+  - Object count display shows number of objects per plate and total
+  - Fullscreen toggle for immersive model viewing
+  - Resizable split view between plate selector and 3D viewer in fullscreen mode
+  - Pagination support for files with many plates (e.g., 50+ plates)
+  - Added i18n translations for all model viewer strings (English, German, Japanese)
 - **Virtual Printer Proxy Mode Improvements**:
   - SSDP proxy for cross-network setups: select slicer network interface for automatic printer discovery via SSDP relay
   - FTP proxy now listens on privileged port 990 (matching Bambu Studio expectations) instead of 9990

+ 1 - 1
backend/app/main.py

@@ -2667,8 +2667,8 @@ async def auth_middleware(request, call_next):
 
     # Validate JWT token
     import jwt
-    try:
 
+    try:
         from backend.app.core.auth import ALGORITHM, SECRET_KEY
 
         token = auth_header.replace("Bearer ", "")

+ 204 - 0
backend/tests/unit/test_plate_object_extraction.py

@@ -0,0 +1,204 @@
+"""Unit tests for plate object extraction from 3MF model_settings.config."""
+
+from xml.etree import ElementTree as ET
+
+import pytest
+
+
+class TestPlateObjectExtraction:
+    """Tests for extracting object IDs and names from model_settings.config XML."""
+
+    def test_extract_object_names_from_xml(self):
+        """Verify object names are extracted from model_settings.config XML."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <object id="1">
+                <metadata key="name" value="Cube"/>
+            </object>
+            <object id="2">
+                <metadata key="name" value="Sphere"/>
+            </object>
+            <object id="3">
+                <metadata key="name" value="Cylinder"/>
+            </object>
+        </config>
+        """
+        root = ET.fromstring(xml_content)
+
+        object_names_by_id = {}
+        for obj in root.findall(".//object"):
+            obj_id = obj.get("id")
+            if obj_id:
+                name_meta = obj.find("./metadata[@key='name']")
+                if name_meta is not None:
+                    object_names_by_id[obj_id] = name_meta.get("value", f"Object {obj_id}")
+                else:
+                    object_names_by_id[obj_id] = f"Object {obj_id}"
+
+        assert object_names_by_id == {
+            "1": "Cube",
+            "2": "Sphere",
+            "3": "Cylinder",
+        }
+
+    def test_extract_object_names_missing_name(self):
+        """Verify objects without names get default names."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <object id="1">
+                <metadata key="name" value="Named Object"/>
+            </object>
+            <object id="2">
+                <!-- No name metadata -->
+            </object>
+        </config>
+        """
+        root = ET.fromstring(xml_content)
+
+        object_names_by_id = {}
+        for obj in root.findall(".//object"):
+            obj_id = obj.get("id")
+            if obj_id:
+                name_meta = obj.find("./metadata[@key='name']")
+                if name_meta is not None:
+                    object_names_by_id[obj_id] = name_meta.get("value", f"Object {obj_id}")
+                else:
+                    object_names_by_id[obj_id] = f"Object {obj_id}"
+
+        assert object_names_by_id == {
+            "1": "Named Object",
+            "2": "Object 2",
+        }
+
+    def test_extract_plate_object_associations(self):
+        """Verify plate-to-object associations are extracted correctly."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="plater_id" value="1"/>
+                <model_instance>
+                    <metadata key="object_id" value="1"/>
+                </model_instance>
+                <model_instance>
+                    <metadata key="object_id" value="2"/>
+                </model_instance>
+            </plate>
+            <plate>
+                <metadata key="plater_id" value="2"/>
+                <model_instance>
+                    <metadata key="object_id" value="3"/>
+                </model_instance>
+            </plate>
+        </config>
+        """
+        root = ET.fromstring(xml_content)
+
+        plate_object_ids = {}
+        for plate in root.findall(".//plate"):
+            plate_id = None
+            for meta in plate.findall("metadata"):
+                if meta.get("key") in ("plater_id", "plate_id"):
+                    plate_id = meta.get("value")
+                    break
+
+            if plate_id:
+                object_ids = []
+                for instance in plate.findall(".//model_instance"):
+                    for meta in instance.findall("metadata"):
+                        if meta.get("key") == "object_id":
+                            object_ids.append(meta.get("value"))
+                plate_object_ids[plate_id] = object_ids
+
+        assert plate_object_ids == {
+            "1": ["1", "2"],
+            "2": ["3"],
+        }
+
+    def test_extract_plate_object_associations_empty_plate(self):
+        """Verify empty plates have empty object lists."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="plater_id" value="1"/>
+                <!-- No model_instances -->
+            </plate>
+        </config>
+        """
+        root = ET.fromstring(xml_content)
+
+        plate_object_ids = {}
+        for plate in root.findall(".//plate"):
+            plate_id = None
+            for meta in plate.findall("metadata"):
+                if meta.get("key") in ("plater_id", "plate_id"):
+                    plate_id = meta.get("value")
+                    break
+
+            if plate_id:
+                object_ids = []
+                for instance in plate.findall(".//model_instance"):
+                    for meta in instance.findall("metadata"):
+                        if meta.get("key") == "object_id":
+                            object_ids.append(meta.get("value"))
+                plate_object_ids[plate_id] = object_ids
+
+        assert plate_object_ids == {"1": []}
+
+    def test_object_count_matches_objects_length(self):
+        """Verify object_count equals len(objects)."""
+        objects = ["Cube", "Sphere", "Cylinder"]
+        object_count = len(objects)
+
+        assert object_count == 3
+
+    def test_resolve_object_names_from_ids(self):
+        """Verify object IDs are resolved to names."""
+        object_names_by_id = {
+            "1": "Cube",
+            "2": "Sphere",
+            "3": "Cylinder",
+        }
+        plate_object_ids = ["1", "3"]
+
+        resolved_names = [object_names_by_id.get(obj_id, f"Object {obj_id}") for obj_id in plate_object_ids]
+
+        assert resolved_names == ["Cube", "Cylinder"]
+
+    def test_resolve_object_names_missing_id(self):
+        """Verify missing object IDs get fallback names."""
+        object_names_by_id = {
+            "1": "Cube",
+        }
+        plate_object_ids = ["1", "99"]  # 99 doesn't exist
+
+        resolved_names = [object_names_by_id.get(obj_id, f"Object {obj_id}") for obj_id in plate_object_ids]
+
+        assert resolved_names == ["Cube", "Object 99"]
+
+    def test_plate_id_alternatives(self):
+        """Verify both 'plater_id' and 'plate_id' keys are supported."""
+        xml_with_plater_id = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="plater_id" value="1"/>
+            </plate>
+        </config>
+        """
+        xml_with_plate_id = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="plate_id" value="2"/>
+            </plate>
+        </config>
+        """
+
+        def extract_plate_id(xml_content):
+            root = ET.fromstring(xml_content)
+            for plate in root.findall(".//plate"):
+                for meta in plate.findall("metadata"):
+                    if meta.get("key") in ("plater_id", "plate_id"):
+                        return meta.get("value")
+            return None
+
+        assert extract_plate_id(xml_with_plater_id) == "1"
+        assert extract_plate_id(xml_with_plate_id) == "2"

+ 503 - 0
frontend/src/__tests__/components/ModelViewerModal.test.tsx

@@ -0,0 +1,503 @@
+/**
+ * Tests for the ModelViewerModal component.
+ * Tests fullscreen toggle, plate selector, object counts, and tab switching.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { ModelViewerModal } from '../../components/ModelViewerModal';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+// Mock ModelViewer and GcodeViewer to avoid WebGL/Three.js issues in tests
+vi.mock('../../components/ModelViewer', () => ({
+  ModelViewer: ({ className }: { className?: string }) => (
+    <div data-testid="model-viewer" className={className}>
+      Model Viewer Mock
+    </div>
+  ),
+}));
+
+vi.mock('../../components/GcodeViewer', () => ({
+  GcodeViewer: ({ className }: { className?: string }) => (
+    <div data-testid="gcode-viewer" className={className}>
+      G-code Viewer Mock
+    </div>
+  ),
+}));
+
+const mockCapabilities = {
+  has_model: true,
+  has_gcode: true,
+  has_source: false,
+  build_volume: { x: 256, y: 256, z: 256 },
+  filament_colors: ['#00ae42'],
+};
+
+const mockPlatesResponse = {
+  is_multi_plate: true,
+  plates: [
+    {
+      index: 1,
+      name: 'Plate 1',
+      has_thumbnail: true,
+      thumbnail_url: '/api/v1/archives/1/plates/1/thumbnail',
+      print_time_seconds: 3600,
+      filament_used_grams: 50.5,
+      object_count: 3,
+      objects: ['Cube', 'Sphere', 'Cylinder'],
+      filaments: [{ color: '#00ae42', type: 'PLA', name: 'Bambu PLA Basic' }],
+    },
+    {
+      index: 2,
+      name: 'Plate 2',
+      has_thumbnail: true,
+      thumbnail_url: '/api/v1/archives/1/plates/2/thumbnail',
+      print_time_seconds: 1800,
+      filament_used_grams: 25.0,
+      object_count: 2,
+      objects: ['Base', 'Cover'],
+      filaments: [{ color: '#ff0000', type: 'PLA', name: 'Red PLA' }],
+    },
+  ],
+};
+
+const mockSinglePlateResponse = {
+  is_multi_plate: false,
+  plates: [
+    {
+      index: 1,
+      name: null,
+      has_thumbnail: false,
+      thumbnail_url: null,
+      print_time_seconds: 7200,
+      filament_used_grams: 100.0,
+      object_count: 5,
+      objects: ['Model 1', 'Model 2', 'Model 3', 'Model 4', 'Model 5'],
+      filaments: [],
+    },
+  ],
+};
+
+describe('ModelViewerModal', () => {
+  const mockOnClose = vi.fn();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    server.use(
+      http.get('/api/v1/archives/:id/capabilities', () => {
+        return HttpResponse.json(mockCapabilities);
+      }),
+      http.get('/api/v1/archives/:id/plates', () => {
+        return HttpResponse.json(mockPlatesResponse);
+      })
+    );
+  });
+
+  describe('rendering', () => {
+    it('renders the modal with title', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model.3mf"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText('Test Model.3mf')).toBeInTheDocument();
+    });
+
+    it('renders Open in Slicer button', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Open in Slicer')).toBeInTheDocument();
+      });
+    });
+
+    it('shows loading spinner while fetching capabilities', () => {
+      server.use(
+        http.get('/api/v1/archives/:id/capabilities', async () => {
+          await new Promise((r) => setTimeout(r, 100));
+          return HttpResponse.json(mockCapabilities);
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      const loader = document.querySelector('.animate-spin');
+      expect(loader).toBeInTheDocument();
+    });
+  });
+
+  describe('tabs', () => {
+    it('renders 3D Model and G-code tabs', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('3D Model')).toBeInTheDocument();
+        expect(screen.getByText('G-code Preview')).toBeInTheDocument();
+      });
+    });
+
+    it('shows not available label when model is not available', async () => {
+      server.use(
+        http.get('/api/v1/archives/:id/capabilities', () => {
+          return HttpResponse.json({
+            ...mockCapabilities,
+            has_model: false,
+          });
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('(not available)')).toBeInTheDocument();
+      });
+    });
+
+    it('shows not sliced label when gcode is not available', async () => {
+      server.use(
+        http.get('/api/v1/archives/:id/capabilities', () => {
+          return HttpResponse.json({
+            ...mockCapabilities,
+            has_gcode: false,
+          });
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('(not sliced)')).toBeInTheDocument();
+      });
+    });
+
+    it('disables tab when capability is not available', async () => {
+      server.use(
+        http.get('/api/v1/archives/:id/capabilities', () => {
+          return HttpResponse.json({
+            ...mockCapabilities,
+            has_gcode: false,
+          });
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        const gcodeTab = screen.getByText('G-code Preview').closest('button');
+        expect(gcodeTab).toBeDisabled();
+      });
+    });
+  });
+
+  describe('fullscreen', () => {
+    it('renders fullscreen toggle button', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        // Look for the maximize icon button
+        const buttons = screen.getAllByRole('button');
+        const fullscreenButton = buttons.find(
+          (btn) => btn.querySelector('.lucide-maximize-2') || btn.title === 'Enter fullscreen'
+        );
+        expect(fullscreenButton).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('object count', () => {
+    it('displays object count for multi-plate files', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        // Total objects across both plates = 3 + 2 = 5
+        // The header shows "All Plates: 5 objects" in a span
+        const objectCountBadge = screen.getByText(/All Plates.*5 objects/);
+        expect(objectCountBadge).toBeInTheDocument();
+      });
+    });
+
+    it('updates object count when plate is selected', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Plate 1')).toBeInTheDocument();
+      });
+
+      // Click on Plate 1
+      fireEvent.click(screen.getByText('Plate 1'));
+
+      await waitFor(() => {
+        // Plate 1 has 3 objects - header should update to show "Plate 1: 3 objects"
+        const objectCountBadge = screen.getByText(/Plate 1.*3 objects/);
+        expect(objectCountBadge).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('plate selector', () => {
+    it('shows plates panel for multi-plate files', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Plates')).toBeInTheDocument();
+        // Use getAllByText for "All Plates" since it appears in header and panel
+        const allPlatesElements = screen.getAllByText('All Plates');
+        expect(allPlatesElements.length).toBeGreaterThan(0);
+        expect(screen.getByText('2 plates')).toBeInTheDocument();
+      });
+    });
+
+    it('shows individual plate buttons', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Plate 1')).toBeInTheDocument();
+        expect(screen.getByText('Plate 2')).toBeInTheDocument();
+      });
+    });
+
+    it('shows object count for each plate', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        // Each plate shows its object count in the grid
+        expect(screen.getByText('3 objects')).toBeInTheDocument();
+        expect(screen.getByText('2 objects')).toBeInTheDocument();
+      });
+    });
+
+    it('hides plates panel for single-plate files', async () => {
+      server.use(
+        http.get('/api/v1/archives/:id/plates', () => {
+          return HttpResponse.json(mockSinglePlateResponse);
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        // Should show object count but not plate selector
+        expect(screen.getByText(/5 objects/)).toBeInTheDocument();
+      });
+
+      // Plates panel should not be shown for single plate
+      expect(screen.queryByText('2 plates')).not.toBeInTheDocument();
+    });
+
+    it('selects All Plates by default', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        // Find the All Plates button in the grid (the one with "2 plates" sibling text)
+        const platesCountText = screen.getByText('2 plates');
+        const allPlatesButton = platesCountText.closest('button');
+        // The selected button should have the green border class
+        expect(allPlatesButton).toHaveClass('border-bambu-green');
+      });
+    });
+
+    it('allows plate selection via click', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Plate 1')).toBeInTheDocument();
+      });
+
+      // Click on Plate 1 - this should not throw
+      const plate1Button = screen.getByText('Plate 1').closest('button');
+      expect(plate1Button).toBeInTheDocument();
+      fireEvent.click(plate1Button!);
+
+      // After clicking, the header should show Plate 1 info
+      await waitFor(() => {
+        expect(screen.getByText(/Plate 1.*3 objects/)).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('close behavior', () => {
+    it('calls onClose when X button is clicked', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      const closeButton = screen.getAllByRole('button').find(
+        (btn) => btn.querySelector('.lucide-x')
+      );
+
+      if (closeButton) {
+        fireEvent.click(closeButton);
+        expect(mockOnClose).toHaveBeenCalled();
+      }
+    });
+
+    it('calls onClose when Escape key is pressed', () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      fireEvent.keyDown(window, { key: 'Escape' });
+      expect(mockOnClose).toHaveBeenCalled();
+    });
+
+    it('calls onClose when backdrop is clicked', () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      const backdrop = document.querySelector('.fixed.inset-0');
+      if (backdrop) {
+        fireEvent.click(backdrop);
+        expect(mockOnClose).toHaveBeenCalled();
+      }
+    });
+  });
+
+  describe('library file mode', () => {
+    it('renders for library file', async () => {
+      server.use(
+        http.get('/api/v1/library/files/:id/plates', () => {
+          return HttpResponse.json(mockSinglePlateResponse);
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          libraryFileId={1}
+          title="Library Model.3mf"
+          fileType="3mf"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText('Library Model.3mf')).toBeInTheDocument();
+
+      await waitFor(() => {
+        expect(screen.getByText('3D Model')).toBeInTheDocument();
+      });
+    });
+
+    it('disables Open in Slicer for non-3mf library files', async () => {
+      render(
+        <ModelViewerModal
+          libraryFileId={1}
+          title="Model.stl"
+          fileType="stl"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        const slicerButton = screen.getByText('Open in Slicer').closest('button');
+        expect(slicerButton).toBeDisabled();
+      });
+    });
+  });
+});

+ 1 - 0
test_backend.sh

@@ -1,5 +1,6 @@
 #!/bin/sh
 
 cd backend
+ruff check && ruff format --check
 ../venv/bin/python3 -m pytest tests/ -v -n 14
 cd ..