Browse Source

Add project parts tracking separate from plates

Track individual parts/objects separately from print plates in projects.
Useful for multi-part builds like Voron where 25 plates produce 150 parts.

Backend:
- Add target_parts_count field to Project model
- Calculate parts_progress_percent and remaining_parts in stats
- Auto-detect quantity from 3MF printable objects when archiving
- Sum archive quantities for completed_count (parts)
- Use archive_count for plates progress

Frontend:
- Add "Target Parts" input in project create/edit modal
- Show separate progress bars for plates vs parts
- Stats footer displays both plates and parts count
- Header badge shows parts progress when target set

Scripts:
- Add update_archive_quantities.py to migrate existing archives

Tests:
- Add 5 integration tests for parts tracking
- Add 3 unit tests for 3MF object extraction

Closes #85
maziggy 4 months ago
parent
commit
693466eafc

+ 24 - 0
CHANGELOG.md

@@ -2,6 +2,30 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
+## [0.1.6b12] - 2026-01-16
+
+### Added
+- **Project parts tracking** - Track individual parts/objects separately from print plates:
+  - New "Target Parts" field in project create/edit modal alongside "Target Plates"
+  - Separate progress bars for plates (print jobs) vs parts (objects printed)
+  - Parts count auto-detected from 3MF files when archiving prints
+  - Project cards show both plates and parts counts in footer stats
+  - Example: Voron build with 25 plates producing 150 parts total
+  - Closes [#85](https://github.com/maziggy/bambuddy/issues/85)
+- **Archive quantity auto-detection** - Automatically set parts count when archiving:
+  - Extracts printable object count from 3MF `slice_info.config`
+  - Respects skipped objects (not counted)
+  - Falls back to 1 if extraction fails
+- **Migration script for existing archives** - Update quantities on existing archives:
+  - Run `python scripts/update_archive_quantities.py` to update existing archives
+  - Use `--dry-run` flag to preview changes without applying them
+  - Parses 3MF files to extract correct object counts
+
+### Changed
+- Project stats now correctly distinguish between plates (archive count) and parts (sum of quantities)
+- Project list `completed_count` now represents parts printed, not print jobs
+- Progress calculations: plates use `archive_count/target_count`, parts use `completed_count/target_parts_count`
+
 ## [0.1.6b11] - 2026-01-13
 
 ### Added

+ 2 - 2
README.md

@@ -78,8 +78,8 @@
 
 ### 📁 Projects
 - Group related prints (e.g., "Voron Build")
-- Track progress with target counts
-- Quantity tracking for batch prints
+- Track plates (print jobs) and parts separately
+- Auto-detect parts count from 3MF files
 - Color-coded project badges
 - Bulk assign archives via multi-select toolbar
 

+ 47 - 23
backend/app/api/routes/projects.py

@@ -37,7 +37,9 @@ logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/projects", tags=["projects"])
 
 
-async def compute_project_stats(db: AsyncSession, project_id: int, target_count: int | None = None) -> ProjectStats:
+async def compute_project_stats(
+    db: AsyncSession, project_id: int, target_count: int | None = None, target_parts_count: int | None = None
+) -> ProjectStats:
     """Compute statistics for a project."""
     # Count total archives (distinct print jobs)
     total_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project_id))
@@ -49,14 +51,6 @@ async def compute_project_stats(db: AsyncSession, project_id: int, target_count:
     )
     total_items = total_items_result.scalar() or 0
 
-    # Count completed archives (number of print jobs) - includes "archived" as successful
-    completed_result = await db.execute(
-        select(func.count(PrintArchive.id)).where(
-            PrintArchive.project_id == project_id, PrintArchive.status.in_(["completed", "archived"])
-        )
-    )
-    completed_prints = completed_result.scalar() or 0
-
     # Count failed archives (number of print jobs) - includes all failure states
     failed_result = await db.execute(
         select(func.count(PrintArchive.id)).where(
@@ -94,12 +88,28 @@ async def compute_project_stats(db: AsyncSession, project_id: int, target_count:
     )
     in_progress_prints = in_progress_result.scalar() or 0
 
-    # Calculate progress
+    # Sum completed items (parts) - sum of quantities for successful prints
+    completed_items_result = await db.execute(
+        select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
+            PrintArchive.project_id == project_id,
+            PrintArchive.status.in_(["completed", "archived"]),
+        )
+    )
+    completed_items = int(completed_items_result.scalar() or 0)
+
+    # Calculate progress for plates (target_count vs total_archives)
     progress_percent = None
     remaining_prints = None
     if target_count and target_count > 0:
-        progress_percent = round((completed_prints / target_count) * 100, 1)
-        remaining_prints = max(0, target_count - completed_prints)
+        progress_percent = round((total_archives / target_count) * 100, 1)
+        remaining_prints = max(0, target_count - total_archives)
+
+    # Calculate progress for parts (target_parts_count vs completed_items)
+    parts_progress_percent = None
+    remaining_parts = None
+    if target_parts_count and target_parts_count > 0:
+        parts_progress_percent = round((completed_items / target_parts_count) * 100, 1)
+        remaining_parts = max(0, target_parts_count - completed_items)
 
     # BOM stats
     bom_result = await db.execute(
@@ -115,17 +125,19 @@ async def compute_project_stats(db: AsyncSession, project_id: int, target_count:
     return ProjectStats(
         total_archives=total_archives,
         total_items=int(total_items),
-        completed_prints=int(completed_prints),
+        completed_prints=completed_items,  # Now reflects sum of quantities for completed prints
         failed_prints=int(failed_prints),
         queued_prints=queued_prints,
         in_progress_prints=in_progress_prints,
         total_print_time_hours=round((sums.total_time or 0) / 3600, 2),
         total_filament_grams=round(sums.total_filament or 0, 2),
         progress_percent=progress_percent,
+        parts_progress_percent=parts_progress_percent,
         estimated_cost=round((sums.total_filament_cost or 0), 2),
         total_energy_kwh=round((sums.total_energy or 0), 3),
         total_energy_cost=round((sums.total_energy_cost or 0), 2),
         remaining_prints=remaining_prints,
+        remaining_parts=remaining_parts,
         bom_total_items=bom_stats.total or 0,
         bom_completed_items=int(bom_stats.completed or 0),
     )
@@ -170,27 +182,28 @@ async def list_projects(
         )
         queue_count = queue_count_result.scalar() or 0
 
-        # Count completed archives - includes "archived" as successful
+        # Sum completed parts (quantities) - includes "archived" as successful
         completed_result = await db.execute(
-            select(func.count(PrintArchive.id)).where(
+            select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
                 PrintArchive.project_id == project.id,
                 PrintArchive.status.in_(["completed", "archived"]),
             )
         )
         completed_count = int(completed_result.scalar() or 0)
 
-        # Count failed archives - includes all failure states
+        # Sum failed parts (quantities) - includes all failure states
         failed_result = await db.execute(
-            select(func.count(PrintArchive.id)).where(
+            select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
                 PrintArchive.project_id == project.id,
                 PrintArchive.status.in_(["failed", "aborted", "cancelled", "stopped"]),
             )
         )
         failed_count = int(failed_result.scalar() or 0)
 
+        # Plates progress: archive_count / target_count
         progress_percent = None
         if project.target_count and project.target_count > 0:
-            progress_percent = round((completed_count / project.target_count) * 100, 1)
+            progress_percent = round((archive_count / project.target_count) * 100, 1)
 
         # Get archive previews (up to 6 most recent)
         archives_result = await db.execute(
@@ -220,6 +233,7 @@ async def list_projects(
                 color=project.color,
                 status=project.status,
                 target_count=project.target_count,
+                target_parts_count=project.target_parts_count,
                 created_at=project.created_at,
                 archive_count=archive_count,
                 total_items=total_items,
@@ -254,6 +268,7 @@ async def create_project(
         description=data.description,
         color=data.color,
         target_count=data.target_count,
+        target_parts_count=data.target_parts_count,
         notes=data.notes,
         tags=data.tags,
         due_date=data.due_date,
@@ -265,7 +280,7 @@ async def create_project(
     await db.flush()
     await db.refresh(project)
 
-    stats = await compute_project_stats(db, project.id, project.target_count)
+    stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
 
     return ProjectResponse(
         id=project.id,
@@ -274,6 +289,7 @@ async def create_project(
         color=project.color,
         status=project.status,
         target_count=project.target_count,
+        target_parts_count=project.target_parts_count,
         notes=project.notes,
         attachments=project.attachments,
         tags=project.tags,
@@ -351,6 +367,7 @@ async def create_project_from_template(
         description=template.description,
         color=template.color,
         target_count=template.target_count,
+        target_parts_count=template.target_parts_count,
         notes=template.notes,
         tags=template.tags,
         priority=template.priority,
@@ -382,7 +399,7 @@ async def create_project_from_template(
     await db.flush()
     await db.refresh(project)
 
-    stats = await compute_project_stats(db, project.id, project.target_count)
+    stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
 
     return ProjectResponse(
         id=project.id,
@@ -391,6 +408,7 @@ async def create_project_from_template(
         color=project.color,
         status=project.status,
         target_count=project.target_count,
+        target_parts_count=project.target_parts_count,
         notes=project.notes,
         attachments=project.attachments,
         tags=project.tags,
@@ -463,7 +481,7 @@ async def get_project(
     # Get children
     children = await get_child_previews(db, project.id)
 
-    stats = await compute_project_stats(db, project.id, project.target_count)
+    stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
 
     return ProjectResponse(
         id=project.id,
@@ -472,6 +490,7 @@ async def get_project(
         color=project.color,
         status=project.status,
         target_count=project.target_count,
+        target_parts_count=project.target_parts_count,
         notes=project.notes,
         attachments=project.attachments,
         tags=project.tags,
@@ -515,6 +534,8 @@ async def update_project(
         project.status = data.status
     if data.target_count is not None:
         project.target_count = data.target_count
+    if data.target_parts_count is not None:
+        project.target_parts_count = data.target_parts_count
     if data.notes is not None:
         project.notes = data.notes
     if data.tags is not None:
@@ -551,7 +572,7 @@ async def update_project(
     # Get children
     children = await get_child_previews(db, project.id)
 
-    stats = await compute_project_stats(db, project.id, project.target_count)
+    stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
 
     return ProjectResponse(
         id=project.id,
@@ -560,6 +581,7 @@ async def update_project(
         color=project.color,
         status=project.status,
         target_count=project.target_count,
+        target_parts_count=project.target_parts_count,
         notes=project.notes,
         attachments=project.attachments,
         tags=project.tags,
@@ -1143,6 +1165,7 @@ async def create_template_from_project(
         description=source.description,
         color=source.color,
         target_count=source.target_count,
+        target_parts_count=source.target_parts_count,
         notes=source.notes,
         tags=source.tags,
         priority=source.priority,
@@ -1174,7 +1197,7 @@ async def create_template_from_project(
     await db.flush()
     await db.refresh(template)
 
-    stats = await compute_project_stats(db, template.id, template.target_count)
+    stats = await compute_project_stats(db, template.id, template.target_count, template.target_parts_count)
 
     return ProjectResponse(
         id=template.id,
@@ -1183,6 +1206,7 @@ async def create_template_from_project(
         color=template.color,
         status=template.status,
         target_count=template.target_count,
+        target_parts_count=template.target_parts_count,
         notes=template.notes,
         attachments=template.attachments,
         tags=template.tags,

+ 6 - 0
backend/app/core/database.py

@@ -399,6 +399,12 @@ async def run_migrations(conn):
     except Exception:
         pass
 
+    # Migration: Add target_parts_count column to projects for tracking total parts needed
+    try:
+        await conn.execute(text("ALTER TABLE projects ADD COLUMN target_parts_count INTEGER"))
+    except Exception:
+        pass
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 6 - 1
backend/app/models/project.py

@@ -16,7 +16,12 @@ class Project(Base):
     description: Mapped[str | None] = mapped_column(Text, nullable=True)
     color: Mapped[str | None] = mapped_column(String(20), nullable=True)  # Hex color for UI
     status: Mapped[str] = mapped_column(String(20), default="active")  # active, completed, archived
-    target_count: Mapped[int | None] = mapped_column(Integer, nullable=True)  # Optional target number of prints
+    target_count: Mapped[int | None] = mapped_column(
+        Integer, nullable=True
+    )  # Optional target number of prints (plates)
+    target_parts_count: Mapped[int | None] = mapped_column(
+        Integer, nullable=True
+    )  # Optional target number of parts/objects
 
     # Phase 2: Rich text notes (HTML from WYSIWYG editor)
     notes: Mapped[str | None] = mapped_column(Text, nullable=True)

+ 8 - 2
backend/app/schemas/project.py

@@ -10,6 +10,7 @@ class ProjectCreate(BaseModel):
     description: str | None = None
     color: str | None = None
     target_count: int | None = None
+    target_parts_count: int | None = None
     notes: str | None = None
     tags: str | None = None
     due_date: datetime | None = None
@@ -26,6 +27,7 @@ class ProjectUpdate(BaseModel):
     color: str | None = None
     status: str | None = None  # active, completed, archived
     target_count: int | None = None
+    target_parts_count: int | None = None
     notes: str | None = None
     tags: str | None = None
     due_date: datetime | None = None
@@ -45,12 +47,14 @@ class ProjectStats(BaseModel):
     in_progress_prints: int = 0
     total_print_time_hours: float = 0.0
     total_filament_grams: float = 0.0
-    progress_percent: float | None = None  # Based on target_count
+    progress_percent: float | None = None  # Based on target_count (plates)
+    parts_progress_percent: float | None = None  # Based on target_parts_count
     # Cost tracking (Phase 6)
     estimated_cost: float = 0.0  # Based on filament cost
     total_energy_kwh: float = 0.0
     total_energy_cost: float = 0.0
-    remaining_prints: int | None = None  # target_count - completed_prints
+    remaining_prints: int | None = None  # target_count - total_archives
+    remaining_parts: int | None = None  # target_parts_count - completed_prints
     # BOM stats (Phase 7)
     bom_total_items: int = 0
     bom_completed_items: int = 0
@@ -75,6 +79,7 @@ class ProjectResponse(BaseModel):
     color: str | None
     status: str
     target_count: int | None
+    target_parts_count: int | None = None
     notes: str | None = None
     attachments: list | None = None
     tags: str | None = None
@@ -114,6 +119,7 @@ class ProjectListResponse(BaseModel):
     color: str | None
     status: str
     target_count: int | None
+    target_parts_count: int | None = None
     created_at: datetime
     # Quick stats
     archive_count: int = 0  # Number of print jobs

+ 9 - 0
backend/app/services/archive.py

@@ -834,6 +834,14 @@ class ArchiveService:
                 default_cost_per_kg = 25.0
                 cost = round((filament_grams / 1000) * default_cost_per_kg, 2)
 
+        # Calculate quantity from printable objects count
+        # printable_objects is a dict of {identify_id: name} for non-skipped objects
+        quantity = 1  # Default to 1
+        printable_objects = metadata.get("printable_objects")
+        if printable_objects and isinstance(printable_objects, dict):
+            quantity = len(printable_objects)
+            logger.debug(f"Auto-detected {quantity} parts from 3MF printable objects")
+
         # Create archive record
         archive = PrintArchive(
             printer_id=printer_id,
@@ -858,6 +866,7 @@ class ArchiveService:
             started_at=started_at,
             completed_at=completed_at,
             cost=cost,
+            quantity=quantity,
             extra_data=metadata,
         )
 

+ 149 - 0
backend/tests/integration/test_projects_api.py

@@ -113,6 +113,155 @@ class TestProjectsAPI:
         assert response.status_code == 404
 
 
+class TestProjectPartsTracking:
+    """Tests for project parts tracking feature."""
+
+    @pytest.fixture
+    async def project_factory(self, db_session):
+        """Factory to create test projects."""
+
+        async def _create_project(**kwargs):
+            from backend.app.models.project import Project
+
+            defaults = {
+                "name": "Parts Test Project",
+                "description": "Test project",
+                "color": "#FF0000",
+            }
+            defaults.update(kwargs)
+
+            project = Project(**defaults)
+            db_session.add(project)
+            await db_session.commit()
+            await db_session.refresh(project)
+            return project
+
+        return _create_project
+
+    @pytest.fixture
+    async def archive_factory(self, db_session):
+        """Factory to create test archives."""
+
+        async def _create_archive(**kwargs):
+            from backend.app.models.archive import PrintArchive
+
+            defaults = {
+                "filename": "test.3mf",
+                "file_path": "test/test.3mf",
+                "file_size": 1000,
+                "print_name": "Test Print",
+                "status": "completed",
+                "quantity": 1,
+            }
+            defaults.update(kwargs)
+
+            archive = PrintArchive(**defaults)
+            db_session.add(archive)
+            await db_session.commit()
+            await db_session.refresh(archive)
+            return archive
+
+        return _create_archive
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_project_with_target_parts_count(self, async_client: AsyncClient):
+        """Verify project can be created with target_parts_count."""
+        data = {
+            "name": "Parts Project",
+            "target_count": 10,  # 10 plates
+            "target_parts_count": 50,  # 50 parts total
+        }
+        response = await async_client.post("/api/v1/projects/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["target_count"] == 10
+        assert result["target_parts_count"] == 50
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_project_target_parts_count(self, async_client: AsyncClient, project_factory, db_session):
+        """Verify target_parts_count can be updated."""
+        project = await project_factory()
+        response = await async_client.patch(
+            f"/api/v1/projects/{project.id}",
+            json={"target_parts_count": 100},
+        )
+        assert response.status_code == 200
+        assert response.json()["target_parts_count"] == 100
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_project_parts_progress_calculation(
+        self, async_client: AsyncClient, project_factory, archive_factory, db_session
+    ):
+        """Verify parts progress is calculated from archive quantities."""
+        # Create project with target of 20 parts
+        project = await project_factory(target_parts_count=20)
+
+        # Create archives with different quantities
+        await archive_factory(project_id=project.id, quantity=3, status="completed")  # 3 parts
+        await archive_factory(project_id=project.id, quantity=5, status="completed")  # 5 parts
+        await archive_factory(project_id=project.id, quantity=2, status="completed")  # 2 parts
+        # Total: 10 parts completed out of 20 = 50%
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}")
+        assert response.status_code == 200
+        data = response.json()
+
+        # Check stats
+        assert data["stats"]["completed_prints"] == 10  # Sum of quantities
+        assert data["stats"]["parts_progress_percent"] == 50.0  # 10/20 = 50%
+        assert data["stats"]["remaining_parts"] == 10  # 20 - 10 = 10
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_project_list_shows_parts_count(
+        self, async_client: AsyncClient, project_factory, archive_factory, db_session
+    ):
+        """Verify project list returns correct completed_count (parts sum)."""
+        project = await project_factory(name="List Parts Project", target_parts_count=100)
+
+        # Create archives with quantities
+        await archive_factory(project_id=project.id, quantity=4, status="completed")
+        await archive_factory(project_id=project.id, quantity=6, status="completed")
+        # Total: 10 parts, 2 plates
+
+        response = await async_client.get("/api/v1/projects/")
+        assert response.status_code == 200
+        data = response.json()
+
+        # Find our project
+        our_project = next((p for p in data if p["name"] == "List Parts Project"), None)
+        assert our_project is not None
+        assert our_project["archive_count"] == 2  # 2 plates
+        assert our_project["completed_count"] == 10  # 10 parts (sum of quantities)
+        assert our_project["target_parts_count"] == 100
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_plates_vs_parts_progress(
+        self, async_client: AsyncClient, project_factory, archive_factory, db_session
+    ):
+        """Verify plates and parts progress are calculated separately."""
+        # Project needs 5 plates producing 25 parts total (5 parts per plate)
+        project = await project_factory(target_count=5, target_parts_count=25)
+
+        # Complete 2 plates, each with 5 parts
+        await archive_factory(project_id=project.id, quantity=5, status="completed")
+        await archive_factory(project_id=project.id, quantity=5, status="completed")
+        # Plates: 2/5 = 40%, Parts: 10/25 = 40%
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}")
+        assert response.status_code == 200
+        data = response.json()
+
+        assert data["stats"]["total_archives"] == 2  # 2 plates
+        assert data["stats"]["completed_prints"] == 10  # 10 parts
+        assert data["stats"]["progress_percent"] == 40.0  # plates: 2/5
+        assert data["stats"]["parts_progress_percent"] == 40.0  # parts: 10/25
+
+
 class TestProjectArchivesAPI:
     """Tests for project-archive relationships."""
 

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

@@ -214,3 +214,81 @@ class TestArchiveThumbnails:
         ]
         for path in expected_thumbnail_paths:
             assert "png" in path.lower()
+
+
+class TestPrintableObjectsExtraction:
+    """Tests for extracting printable objects count from 3MF files."""
+
+    def test_extract_printable_objects_from_slice_info(self):
+        """Test parsing printable objects from slice_info.config XML."""
+        from xml.etree import ElementTree as ET
+
+        # Example slice_info.config content with 4 objects
+        slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate plate_idx="1">
+                <metadata key="prediction" value="3600" />
+                <metadata key="weight" value="50.5" />
+                <object identify_id="1" name="Part_A" skipped="false" />
+                <object identify_id="2" name="Part_B" skipped="false" />
+                <object identify_id="3" name="Part_C" skipped="false" />
+                <object identify_id="4" name="Part_D" skipped="true" />
+            </plate>
+        </config>
+        """
+        root = ET.fromstring(slice_info_xml)
+        plate = root.find(".//plate")
+
+        # Count non-skipped objects (should be 3, not 4)
+        count = 0
+        for obj in plate.findall("object"):
+            skipped = obj.get("skipped", "false")
+            if skipped.lower() != "true":
+                count += 1
+
+        assert count == 3  # 3 objects (Part_D is skipped)
+
+    def test_extract_printable_objects_empty_plate(self):
+        """Test handling plate with no objects."""
+        from xml.etree import ElementTree as ET
+
+        slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate plate_idx="1">
+                <metadata key="prediction" value="0" />
+            </plate>
+        </config>
+        """
+        root = ET.fromstring(slice_info_xml)
+        plate = root.find(".//plate")
+
+        count = 0
+        for obj in plate.findall("object"):
+            skipped = obj.get("skipped", "false")
+            if skipped.lower() != "true":
+                count += 1
+
+        assert count == 0
+
+    def test_extract_printable_objects_all_skipped(self):
+        """Test handling plate where all objects are skipped."""
+        from xml.etree import ElementTree as ET
+
+        slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate plate_idx="1">
+                <object identify_id="1" name="Part_A" skipped="true" />
+                <object identify_id="2" name="Part_B" skipped="true" />
+            </plate>
+        </config>
+        """
+        root = ET.fromstring(slice_info_xml)
+        plate = root.find(".//plate")
+
+        count = 0
+        for obj in plate.findall("object"):
+            skipped = obj.get("skipped", "false")
+            if skipped.lower() != "true":
+                count += 1
+
+        assert count == 0  # All objects skipped

+ 14 - 8
frontend/src/api/client.ts

@@ -341,17 +341,19 @@ export interface SimilarArchive {
 export interface ProjectStats {
   total_archives: number;
   total_items: number;  // Sum of quantities (total items printed)
-  completed_prints: number;
+  completed_prints: number;  // Sum of quantities for completed prints (parts)
   failed_prints: number;
   queued_prints: number;
   in_progress_prints: number;
   total_print_time_hours: number;
   total_filament_grams: number;
-  progress_percent: number | null;
+  progress_percent: number | null;  // Plates progress (total_archives / target_count)
+  parts_progress_percent: number | null;  // Parts progress (completed_prints / target_parts_count)
   estimated_cost: number;
   total_energy_kwh: number;
   total_energy_cost: number;
-  remaining_prints: number | null;
+  remaining_prints: number | null;  // Remaining plates
+  remaining_parts: number | null;  // Remaining parts
   bom_total_items: number;
   bom_completed_items: number;
 }
@@ -370,7 +372,8 @@ export interface Project {
   description: string | null;
   color: string | null;
   status: string;  // active, completed, archived
-  target_count: number | null;
+  target_count: number | null;  // Target number of plates/print jobs
+  target_parts_count: number | null;  // Target number of parts/objects
   notes: string | null;
   attachments: ProjectAttachment[] | null;
   tags: string | null;
@@ -409,14 +412,15 @@ export interface ProjectListItem {
   description: string | null;
   color: string | null;
   status: string;
-  target_count: number | null;
+  target_count: number | null;  // Target number of plates/print jobs
+  target_parts_count: number | null;  // Target number of parts/objects
   created_at: string;
-  archive_count: number;  // Number of print jobs
+  archive_count: number;  // Number of print jobs (plates)
   total_items: number;  // Sum of quantities (total items printed, including failed)
-  completed_count: number;  // Sum of quantities for completed prints only
+  completed_count: number;  // Sum of quantities for completed prints only (parts)
   failed_count: number;  // Sum of quantities for failed prints
   queue_count: number;
-  progress_percent: number | null;
+  progress_percent: number | null;  // Plates progress
   archives: ArchivePreview[];
 }
 
@@ -425,6 +429,7 @@ export interface ProjectCreate {
   description?: string;
   color?: string;
   target_count?: number;
+  target_parts_count?: number;
   notes?: string;
   tags?: string;
   due_date?: string;
@@ -439,6 +444,7 @@ export interface ProjectUpdate {
   color?: string;
   status?: string;
   target_count?: number;
+  target_parts_count?: number;
   notes?: string;
   tags?: string;
   due_date?: string;

+ 69 - 33
frontend/src/pages/ProjectDetailPage.tsx

@@ -436,7 +436,10 @@ export function ProjectDetailPage() {
   }
 
   const stats = project.stats;
-  const progressPercent = stats?.progress_percent ?? 0;
+  // Plates progress: total_archives / target_count
+  const platesProgressPercent = stats?.progress_percent ?? 0;
+  // Parts progress: completed_prints / target_parts_count
+  const partsProgressPercent = stats?.parts_progress_percent ?? 0;
 
   return (
     <div className="p-4 md:p-8 space-y-8">
@@ -478,35 +481,70 @@ export function ProjectDetailPage() {
         </Button>
       </div>
 
-      {/* Progress bar (if target set) */}
-      {project.target_count && (
+      {/* Progress bars (if targets set) */}
+      {(project.target_count || project.target_parts_count) && (
         <Card>
-          <CardContent className="p-4">
-            <div className="flex items-center justify-between mb-2">
-              <span className="text-sm text-bambu-gray">Progress</span>
-              <span className="text-sm font-medium text-white">
-                {stats?.completed_prints || 0} / {project.target_count} completed
-              </span>
-            </div>
-            <div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
-              <div
-                className="h-full transition-all duration-500"
-                style={{
-                  width: `${Math.min(progressPercent, 100)}%`,
-                  backgroundColor: progressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
-                }}
-              />
-            </div>
-            <div className="flex justify-between mt-1">
-              <span className="text-xs text-bambu-gray/70">
-                {progressPercent.toFixed(0)}% complete
-              </span>
-              {project.target_count - (stats?.completed_prints || 0) > 0 && (
-                <span className="text-xs text-bambu-gray/70">
-                  {project.target_count - (stats?.completed_prints || 0)} remaining
-                </span>
-              )}
-            </div>
+          <CardContent className="p-4 space-y-4">
+            {/* Plates progress */}
+            {project.target_count && (
+              <div>
+                <div className="flex items-center justify-between mb-2">
+                  <span className="text-sm text-bambu-gray">Plates Progress</span>
+                  <span className="text-sm font-medium text-white">
+                    {stats?.total_archives || 0} / {project.target_count} print jobs
+                  </span>
+                </div>
+                <div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
+                  <div
+                    className="h-full transition-all duration-500"
+                    style={{
+                      width: `${Math.min(platesProgressPercent, 100)}%`,
+                      backgroundColor: platesProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
+                    }}
+                  />
+                </div>
+                <div className="flex justify-between mt-1">
+                  <span className="text-xs text-bambu-gray/70">
+                    {platesProgressPercent.toFixed(0)}% complete
+                  </span>
+                  {stats?.remaining_prints != null && stats.remaining_prints > 0 && (
+                    <span className="text-xs text-bambu-gray/70">
+                      {stats.remaining_prints} remaining
+                    </span>
+                  )}
+                </div>
+              </div>
+            )}
+            {/* Parts progress */}
+            {project.target_parts_count && (
+              <div>
+                <div className="flex items-center justify-between mb-2">
+                  <span className="text-sm text-bambu-gray">Parts Progress</span>
+                  <span className="text-sm font-medium text-white">
+                    {stats?.completed_prints || 0} / {project.target_parts_count} parts
+                  </span>
+                </div>
+                <div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
+                  <div
+                    className="h-full transition-all duration-500"
+                    style={{
+                      width: `${Math.min(partsProgressPercent, 100)}%`,
+                      backgroundColor: partsProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
+                    }}
+                  />
+                </div>
+                <div className="flex justify-between mt-1">
+                  <span className="text-xs text-bambu-gray/70">
+                    {partsProgressPercent.toFixed(0)}% complete
+                  </span>
+                  {stats?.remaining_parts != null && stats.remaining_parts > 0 && (
+                    <span className="text-xs text-bambu-gray/70">
+                      {stats.remaining_parts} remaining
+                    </span>
+                  )}
+                </div>
+              </div>
+            )}
           </CardContent>
         </Card>
       )}
@@ -522,13 +560,11 @@ export function ProjectDetailPage() {
                 </div>
                 <div>
                   <p className="text-sm text-bambu-gray">Print Jobs</p>
-                  <p className="text-xl font-semibold text-white">{stats.completed_prints} <span className="text-sm font-normal text-bambu-gray">successful</span></p>
+                  <p className="text-xl font-semibold text-white">{stats.total_archives} <span className="text-sm font-normal text-bambu-gray">total</span></p>
                   {stats.failed_prints > 0 && (
                     <p className="text-sm text-red-400">{stats.failed_prints} failed</p>
                   )}
-                  {stats.total_archives - stats.completed_prints - stats.failed_prints > 0 && (
-                    <p className="text-sm text-yellow-400">{stats.total_archives - stats.completed_prints - stats.failed_prints} in progress</p>
-                  )}
+                  <p className="text-sm text-bambu-gray">{stats.completed_prints} parts printed</p>
                 </div>
               </div>
             </CardContent>

+ 117 - 49
frontend/src/pages/ProjectsPage.tsx

@@ -10,6 +10,7 @@ import {
   Archive,
   ListTodo,
   Package,
+  Layers,
   Clock,
   CheckCircle2,
   AlertTriangle,
@@ -46,6 +47,7 @@ export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectMod
   const [description, setDescription] = useState(project?.description || '');
   const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]);
   const [targetCount, setTargetCount] = useState(project?.target_count?.toString() || '');
+  const [targetPartsCount, setTargetPartsCount] = useState(project?.target_parts_count?.toString() || '');
   const [status, setStatus] = useState(project?.status || 'active');
   const [tags, setTags] = useState((project as ProjectListItem & { tags?: string })?.tags || '');
   const [dueDate, setDueDate] = useState((project as ProjectListItem & { due_date?: string })?.due_date?.split('T')[0] || '');
@@ -58,6 +60,7 @@ export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectMod
       description: description.trim() || undefined,
       color,
       target_count: targetCount ? parseInt(targetCount, 10) : undefined,
+      target_parts_count: targetPartsCount ? parseInt(targetPartsCount, 10) : undefined,
       tags: tags.trim() || undefined,
       due_date: dueDate || undefined,
       priority,
@@ -121,18 +124,36 @@ export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectMod
             </div>
           </div>
 
-          <div>
-            <label className="block text-sm font-medium text-white mb-1">
-              Target Print Count (optional)
-            </label>
-            <input
-              type="number"
-              value={targetCount}
-              onChange={(e) => setTargetCount(e.target.value)}
-              className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
-              placeholder="e.g., 50 parts to print"
-              min="1"
-            />
+          {/* Target Counts - Plates and Parts side by side */}
+          <div className="grid grid-cols-2 gap-4">
+            <div>
+              <label className="block text-sm font-medium text-white mb-1">
+                Target Plates
+              </label>
+              <input
+                type="number"
+                value={targetCount}
+                onChange={(e) => setTargetCount(e.target.value)}
+                className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+                placeholder="e.g., 25"
+                min="1"
+              />
+              <p className="text-xs text-bambu-gray mt-1">Number of print jobs</p>
+            </div>
+            <div>
+              <label className="block text-sm font-medium text-white mb-1">
+                Target Parts
+              </label>
+              <input
+                type="number"
+                value={targetPartsCount}
+                onChange={(e) => setTargetPartsCount(e.target.value)}
+                className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+                placeholder="e.g., 150"
+                min="1"
+              />
+              <p className="text-xs text-bambu-gray mt-1">Total objects needed</p>
+            </div>
           </div>
 
           {/* Tags */}
@@ -224,7 +245,14 @@ interface ProjectCardProps {
 }
 
 function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
-  const progressPercent = project.progress_percent ?? 0;
+  // Plates progress: archive_count / target_count
+  const platesProgressPercent = project.target_count
+    ? Math.round((project.archive_count / project.target_count) * 100)
+    : 0;
+  // Parts progress: completed_count / target_parts_count
+  const partsProgressPercent = project.target_parts_count
+    ? Math.round((project.completed_count / project.target_parts_count) * 100)
+    : 0;
   const isCompleted = project.status === 'completed';
   const isArchived = project.status === 'archived';
   const [showActions, setShowActions] = useState(false);
@@ -262,17 +290,25 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
             <div className="min-w-0 flex-1">
               <div className="flex items-center gap-2 flex-wrap">
                 <h3 className="font-semibold text-white truncate">{project.name}</h3>
-                {project.target_count ? (
+                {project.target_parts_count ? (
+                  <span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${
+                    partsProgressPercent >= 100
+                      ? 'bg-bambu-green/20 text-bambu-green'
+                      : 'bg-bambu-dark text-bambu-gray'
+                  }`}>
+                    {project.completed_count}/{project.target_parts_count} parts
+                  </span>
+                ) : project.target_count ? (
                   <span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${
-                    progressPercent >= 100
+                    platesProgressPercent >= 100
                       ? 'bg-bambu-green/20 text-bambu-green'
                       : 'bg-bambu-dark text-bambu-gray'
                   }`}>
-                    {project.completed_count}/{project.target_count} completed
+                    {project.archive_count}/{project.target_count} plates
                   </span>
                 ) : project.completed_count > 0 ? (
                   <span className="text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium bg-bambu-dark text-bambu-gray">
-                    {project.completed_count} completed
+                    {project.completed_count} parts
                   </span>
                 ) : null}
                 {isCompleted && (
@@ -372,33 +408,61 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
 
         {/* Progress section - show for all projects */}
         <div className="mb-4">
-          {project.target_count ? (
-            <>
-              <div className="flex items-center justify-between text-xs mb-2">
-                <span className="text-bambu-gray">Progress</span>
-                <span className={progressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
-                  {project.completed_count} / {project.target_count}
-                </span>
-              </div>
-              <div className="h-2.5 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm">
-                <div
-                  className="h-full transition-all duration-500 ease-out rounded-full relative"
-                  style={{
-                    width: `${Math.min(progressPercent, 100)}%`,
-                    background: progressPercent >= 100
-                      ? 'linear-gradient(90deg, #22c55e, #4ade80)'
-                      : `linear-gradient(90deg, ${project.color || '#6b7280'}, ${project.color || '#6b7280'}cc)`,
-                    boxShadow: `0 0 8px ${progressPercent >= 100 ? '#22c55e' : project.color || '#6b7280'}60`
-                  }}
-                />
-              </div>
-              <div className="flex justify-between text-xs text-bambu-gray/60 mt-1">
-                <span>
-                  {project.failed_count > 0 && `${project.failed_count} failed`}
-                </span>
-                <span>{progressPercent.toFixed(0)}% complete</span>
-              </div>
-            </>
+          {(project.target_count || project.target_parts_count) ? (
+            <div className="space-y-3">
+              {/* Plates progress */}
+              {project.target_count && (
+                <div>
+                  <div className="flex items-center justify-between text-xs mb-1">
+                    <span className="text-bambu-gray">Plates</span>
+                    <span className={platesProgressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
+                      {project.archive_count} / {project.target_count}
+                    </span>
+                  </div>
+                  <div className="h-2 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm">
+                    <div
+                      className="h-full transition-all duration-500 ease-out rounded-full relative"
+                      style={{
+                        width: `${Math.min(platesProgressPercent, 100)}%`,
+                        background: platesProgressPercent >= 100
+                          ? 'linear-gradient(90deg, #22c55e, #4ade80)'
+                          : `linear-gradient(90deg, ${project.color || '#6b7280'}, ${project.color || '#6b7280'}cc)`,
+                        boxShadow: `0 0 8px ${platesProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280'}60`
+                      }}
+                    />
+                  </div>
+                </div>
+              )}
+              {/* Parts progress */}
+              {project.target_parts_count && (
+                <div>
+                  <div className="flex items-center justify-between text-xs mb-1">
+                    <span className="text-bambu-gray">Parts</span>
+                    <span className={partsProgressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
+                      {project.completed_count} / {project.target_parts_count}
+                    </span>
+                  </div>
+                  <div className="h-2 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm">
+                    <div
+                      className="h-full transition-all duration-500 ease-out rounded-full relative"
+                      style={{
+                        width: `${Math.min(partsProgressPercent, 100)}%`,
+                        background: partsProgressPercent >= 100
+                          ? 'linear-gradient(90deg, #22c55e, #4ade80)'
+                          : `linear-gradient(90deg, ${project.color || '#6b7280'}, ${project.color || '#6b7280'}cc)`,
+                        boxShadow: `0 0 8px ${partsProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280'}60`
+                      }}
+                    />
+                  </div>
+                </div>
+              )}
+              {/* Failed count */}
+              {project.failed_count > 0 && (
+                <div className="text-xs text-red-400">
+                  {project.failed_count} failed
+                </div>
+              )}
+            </div>
           ) : project.completed_count > 0 || project.failed_count > 0 ? (
             <div className="flex items-center gap-4 text-xs">
               {project.completed_count > 0 && (
@@ -467,18 +531,22 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
         {/* Stats footer */}
         <div className="flex items-center justify-between pt-3 border-t border-bambu-dark-tertiary">
           <div className="flex items-center gap-4 text-xs text-bambu-gray">
-            <div className="flex items-center gap-1.5" title="Completed prints">
-              <CheckCircle2 className="w-3.5 h-3.5 text-bambu-green" />
-              <span>{project.completed_count}</span>
+            <div className="flex items-center gap-1.5" title="Print jobs (plates)">
+              <Layers className="w-3.5 h-3.5 text-blue-400" />
+              <span>{project.archive_count} plates</span>
+            </div>
+            <div className="flex items-center gap-1.5" title="Parts printed">
+              <Package className="w-3.5 h-3.5 text-bambu-green" />
+              <span>{project.completed_count} parts</span>
             </div>
             {project.failed_count > 0 && (
-              <div className="flex items-center gap-1.5 text-red-400" title="Failed prints">
+              <div className="flex items-center gap-1.5 text-red-400" title="Failed parts">
                 <AlertTriangle className="w-3.5 h-3.5" />
                 <span>{project.failed_count}</span>
               </div>
             )}
             {project.queue_count > 0 && (
-              <div className="flex items-center gap-1.5 text-blue-400" title="In queue">
+              <div className="flex items-center gap-1.5 text-yellow-400" title="In queue">
                 <ListTodo className="w-3.5 h-3.5" />
                 <span>{project.queue_count}</span>
               </div>

+ 155 - 0
scripts/update_archive_quantities.py

@@ -0,0 +1,155 @@
+#!/usr/bin/env python3
+"""Update archive quantities from 3MF files.
+
+This script updates the quantity field on existing archives by parsing
+their 3MF files to count the number of printable objects.
+
+Run this once after upgrading to add proper parts tracking to your projects.
+
+Usage:
+    # From the bambuddy directory:
+    python scripts/update_archive_quantities.py
+
+    # Or with docker:
+    docker exec -it bambuddy python scripts/update_archive_quantities.py
+
+    # Dry run (show what would be updated without changing anything):
+    python scripts/update_archive_quantities.py --dry-run
+"""
+
+import argparse
+import asyncio
+import sys
+import zipfile
+from pathlib import Path
+from xml.etree import ElementTree as ET
+
+# Add parent directory to path for imports
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from sqlalchemy import select
+
+from backend.app.core.config import settings
+from backend.app.core.database import async_session
+from backend.app.models.archive import PrintArchive
+
+
+def extract_object_count_from_3mf(file_path: Path) -> int | None:
+    """Extract the number of printable objects from a 3MF file.
+
+    Returns the count of non-skipped objects, or None if parsing fails.
+    """
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            if "Metadata/slice_info.config" not in zf.namelist():
+                return None
+
+            content = zf.read("Metadata/slice_info.config").decode()
+            root = ET.fromstring(content)
+
+            # Find the plate (use first plate)
+            plate = root.find(".//plate")
+            if plate is None:
+                return None
+
+            # Count non-skipped objects
+            count = 0
+            for obj in plate.findall("object"):
+                skipped = obj.get("skipped", "false")
+                if skipped.lower() != "true":
+                    count += 1
+
+            return count if count > 0 else None
+
+    except Exception as e:
+        print(f"  Error parsing {file_path.name}: {e}")
+        return None
+
+
+async def update_archive_quantities(dry_run: bool = False):
+    """Update quantity field on archives based on 3MF object count."""
+
+    print("=" * 60)
+    print("Archive Quantity Updater")
+    print("=" * 60)
+    print()
+
+    if dry_run:
+        print("DRY RUN MODE - No changes will be made")
+        print()
+
+    async with async_session() as db:
+        # Get all archives with quantity=1 (the default)
+        result = await db.execute(select(PrintArchive).where(PrintArchive.quantity == 1))
+        archives = result.scalars().all()
+
+        print(f"Found {len(archives)} archives with quantity=1")
+        print()
+
+        updated = 0
+        skipped = 0
+        errors = 0
+
+        for archive in archives:
+            # Skip if no file path
+            if not archive.file_path:
+                skipped += 1
+                continue
+
+            file_path = settings.base_dir / archive.file_path
+
+            # Skip if file doesn't exist
+            if not file_path.exists():
+                print(f"  [{archive.id}] File not found: {archive.file_path}")
+                skipped += 1
+                continue
+
+            # Extract object count
+            object_count = extract_object_count_from_3mf(file_path)
+
+            if object_count is None:
+                skipped += 1
+                continue
+
+            if object_count == 1:
+                # No change needed
+                skipped += 1
+                continue
+
+            # Update the archive
+            print(f"  [{archive.id}] {archive.print_name}: 1 -> {object_count} parts")
+
+            if not dry_run:
+                archive.quantity = object_count
+                updated += 1
+            else:
+                updated += 1
+
+        if not dry_run:
+            await db.commit()
+
+        print()
+        print("-" * 60)
+        print(f"Updated: {updated}")
+        print(f"Skipped: {skipped} (no change needed or file not found)")
+        print(f"Errors:  {errors}")
+        print()
+
+        if dry_run and updated > 0:
+            print("Run without --dry-run to apply these changes.")
+
+
+def main():
+    parser = argparse.ArgumentParser(description="Update archive quantities from 3MF files")
+    parser.add_argument(
+        "--dry-run",
+        action="store_true",
+        help="Show what would be updated without making changes",
+    )
+    args = parser.parse_args()
+
+    asyncio.run(update_archive_quantities(dry_run=args.dry_run))
+
+
+if __name__ == "__main__":
+    main()

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Br9mMbRU.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-COShNpeg.js"></script>
+    <script type="module" crossorigin src="/assets/index-Br9mMbRU.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DAZKHkJ3.css">
   </head>
   <body>

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