Browse Source

Fix multi-plate 3MF metadata extraction (Issue #92)

When exporting individual plates from a multi-plate 3MF in Bambu Studio,
all uploaded archives showed plate 1's name and thumbnail regardless of
which plate was actually exported.

Root cause: The 3MF parser used an incorrect XPath lookup (plate_idx
attribute) and didn't extract the plate index from slice_info.config
metadata.

Changes:
- Extract plate index from <metadata key="index" value="N"/> in
  slice_info.config
- Remove incorrect plate[@plate_idx='N'] XPath lookup that doesn't
  work for single-plate exports
- Set self.plate_number from extracted index so _extract_thumbnail()
  uses the correct plate thumbnail (e.g., plate_5.png instead of
  plate_1.png)
- Append " - Plate N" to print_name when plate index > 1 to
  distinguish multi-plate exports
- Add 7 unit tests for plate index extraction and print_name
  enhancement
maziggy 4 months ago
parent
commit
3a2ec8056c

+ 3 - 1
backend/app/api/routes/smart_plugs.py

@@ -219,7 +219,9 @@ async def list_ha_entities(db: AsyncSession = Depends(get_db)):
     ha_token = await get_setting(db, "ha_token") or ""
 
     if not ha_url or not ha_token:
-        raise HTTPException(400, "Home Assistant not configured. Please set HA URL and token in Settings.")
+        raise HTTPException(
+            400, "Home Assistant not configured. Please set HA URL and token in Settings → Network → Home Assistant."
+        )
 
     entities = await homeassistant_service.list_entities(ha_url, ha_token)
     return [HAEntity(**e) for e in entities]

+ 26 - 12
backend/app/services/archive.py

@@ -31,11 +31,19 @@ class ThreeMFParser:
         """Extract metadata from 3MF file."""
         try:
             with zipfile.ZipFile(self.file_path, "r") as zf:
-                self._parse_slice_info(zf)
+                self._parse_slice_info(zf)  # Now sets self.plate_number from slice_info
                 self._parse_project_settings(zf)
                 self._parse_gcode_header(zf)
                 self._parse_3dmodel(zf)
-                self._extract_thumbnail(zf)
+                self._extract_thumbnail(zf)  # Uses correct plate_number for thumbnail
+
+                # Enhance print_name with plate info if this is a multi-plate export
+                plate_index = self.metadata.get("_plate_index")
+                if plate_index and plate_index > 1:
+                    # Append plate number to distinguish from other plates
+                    existing_name = self.metadata.get("print_name", "")
+                    if existing_name and f"Plate {plate_index}" not in existing_name:
+                        self.metadata["print_name"] = f"{existing_name} - Plate {plate_index}"
 
                 # ALWAYS prefer slice_info values - they contain ONLY filaments actually used in print
                 # project_settings contains ALL configured filaments (AMS slots), not just used ones
@@ -47,6 +55,7 @@ class ThreeMFParser:
                 # Clean up internal keys
                 self.metadata.pop("_slice_filament_type", None)
                 self.metadata.pop("_slice_filament_color", None)
+                self.metadata.pop("_plate_index", None)
         except Exception:
             pass
         return self.metadata
@@ -58,21 +67,26 @@ class ThreeMFParser:
                 content = zf.read("Metadata/slice_info.config").decode()
                 root = ET.fromstring(content)
 
-                # 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")
+                # Find the plate element (single-plate exports only have one plate)
+                plate = root.find(".//plate")
 
                 if plate is not None:
-                    # Get prediction and weight from metadata elements
+                    # Extract metadata from plate element
                     for meta in plate.findall("metadata"):
                         key = meta.get("key")
                         value = meta.get("value")
-                        if key == "prediction" and value:
+                        if key == "index" and value:
+                            # Extract plate index - this tells us which plate was exported
+                            try:
+                                extracted_index = int(value)
+                                # Set plate_number if not already set from filename
+                                if not self.plate_number:
+                                    self.plate_number = extracted_index
+                                # Store in metadata for print_name generation
+                                self.metadata["_plate_index"] = extracted_index
+                            except ValueError:
+                                pass
+                        elif key == "prediction" and value:
                             self.metadata["print_time_seconds"] = int(value)
                         elif key == "weight" and value:
                             self.metadata["filament_used_grams"] = float(value)

+ 134 - 0
backend/tests/unit/services/test_archive_service.py

@@ -292,3 +292,137 @@ class TestPrintableObjectsExtraction:
                 count += 1
 
         assert count == 0  # All objects skipped
+
+
+class TestThreeMFPlateIndexExtraction:
+    """Tests for extracting plate index from multi-plate 3MF exports (Issue #92)."""
+
+    def test_extract_plate_index_from_slice_info(self):
+        """Test parsing plate index from slice_info.config metadata."""
+        from xml.etree import ElementTree as ET
+
+        # Single-plate export from plate 5 of a multi-plate project
+        slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="index" value="5" />
+                <metadata key="prediction" value="3600" />
+                <metadata key="weight" value="50.5" />
+                <object identify_id="1" name="Part_A" skipped="false" />
+            </plate>
+        </config>
+        """
+        root = ET.fromstring(slice_info_xml)
+        plate = root.find(".//plate")
+
+        plate_index = None
+        for meta in plate.findall("metadata"):
+            if meta.get("key") == "index":
+                plate_index = int(meta.get("value"))
+                break
+
+        assert plate_index == 5
+
+    def test_extract_plate_index_plate_1(self):
+        """Test parsing plate index when it's plate 1."""
+        from xml.etree import ElementTree as ET
+
+        slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="index" value="1" />
+                <metadata key="prediction" value="1800" />
+            </plate>
+        </config>
+        """
+        root = ET.fromstring(slice_info_xml)
+        plate = root.find(".//plate")
+
+        plate_index = None
+        for meta in plate.findall("metadata"):
+            if meta.get("key") == "index":
+                plate_index = int(meta.get("value"))
+                break
+
+        assert plate_index == 1
+
+    def test_thumbnail_path_uses_plate_number(self):
+        """Test that thumbnail path correctly uses the extracted plate number."""
+        plate_number = 5
+        thumbnail_paths = []
+
+        if plate_number:
+            thumbnail_paths.append(f"Metadata/plate_{plate_number}.png")
+
+        thumbnail_paths.extend(
+            [
+                "Metadata/plate_1.png",
+                "Metadata/thumbnail.png",
+            ]
+        )
+
+        # First priority should be plate_5.png
+        assert thumbnail_paths[0] == "Metadata/plate_5.png"
+
+    def test_print_name_enhanced_for_plate_greater_than_1(self):
+        """Test that print_name is enhanced with plate info for plate > 1."""
+        plate_index = 5
+        print_name = "Benchy"
+
+        # Logic from archive.py
+        if plate_index and plate_index > 1:
+            if print_name and f"Plate {plate_index}" not in print_name:
+                print_name = f"{print_name} - Plate {plate_index}"
+
+        assert print_name == "Benchy - Plate 5"
+
+    def test_print_name_not_enhanced_for_plate_1(self):
+        """Test that print_name is NOT enhanced for plate 1."""
+        plate_index = 1
+        print_name = "Benchy"
+
+        # Logic from archive.py
+        if plate_index and plate_index > 1:
+            if print_name and f"Plate {plate_index}" not in print_name:
+                print_name = f"{print_name} - Plate {plate_index}"
+
+        assert print_name == "Benchy"  # Unchanged for plate 1
+
+    def test_print_name_not_duplicated(self):
+        """Test that plate info is not added if already present in print_name."""
+        plate_index = 5
+        print_name = "Benchy - Plate 5"
+
+        # Logic from archive.py
+        if plate_index and plate_index > 1:
+            if print_name and f"Plate {plate_index}" not in print_name:
+                print_name = f"{print_name} - Plate {plate_index}"
+
+        assert print_name == "Benchy - Plate 5"  # Not duplicated
+
+    def test_high_plate_number_extraction(self):
+        """Test extracting high plate numbers (e.g., plate 28)."""
+        from xml.etree import ElementTree as ET
+
+        slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="index" value="28" />
+                <metadata key="prediction" value="7200" />
+            </plate>
+        </config>
+        """
+        root = ET.fromstring(slice_info_xml)
+        plate = root.find(".//plate")
+
+        plate_index = None
+        for meta in plate.findall("metadata"):
+            if meta.get("key") == "index":
+                plate_index = int(meta.get("value"))
+                break
+
+        assert plate_index == 28
+
+        # Verify thumbnail would use correct plate
+        thumbnail_path = f"Metadata/plate_{plate_index}.png"
+        assert thumbnail_path == "Metadata/plate_28.png"

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CVMDsKi7.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CDWq7hKx.js"></script>
+    <script type="module" crossorigin src="/assets/index-CVMDsKi7.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Dzh7xD3q.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff