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
This commit is contained in:
maziggy
2026-01-19 13:29:37 +01:00
committed by MartinNYHC
parent 564bc0bca3
commit 3a2ec8056c
5 changed files with 192 additions and 42 deletions
+3 -1
View File
@@ -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
View File
@@ -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)
@@ -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"