Browse Source

Add plate object count metadata and viewer display

MisterBeardy 3 months ago
parent
commit
87c18e5fcf

+ 29 - 1
backend/app/api/routes/archives.py

@@ -2323,13 +2323,25 @@ async def get_archive_plates(
 
             plate_indices.sort()
 
-            # Parse model_settings.config for plate names
+            # Parse model_settings.config for plate names + object assignments
             # Plate names are stored with plater_id and plater_name keys
             plate_names = {}  # plater_id -> name
+            plate_object_ids: dict[int, list[str]] = {}
+            object_names_by_id: dict[str, str] = {}
             if "Metadata/model_settings.config" in namelist:
                 try:
                     model_content = zf.read("Metadata/model_settings.config").decode()
                     model_root = ET.fromstring(model_content)
+                    # Build object ID -> name map
+                    for obj_elem in model_root.findall(".//object"):
+                        obj_id = obj_elem.get("id")
+                        if not obj_id:
+                            continue
+                        name_meta = obj_elem.find("metadata[@key='name']")
+                        obj_name = name_meta.get("value") if name_meta is not None else None
+                        if obj_name:
+                            object_names_by_id[obj_id] = obj_name
+
                     for plate_elem in model_root.findall(".//plate"):
                         plater_id = None
                         plater_name = None
@@ -2345,6 +2357,17 @@ async def get_archive_plates(
                                 plater_name = value.strip()
                         if plater_id is not None and plater_name:
                             plate_names[plater_id] = plater_name
+
+                        if plater_id is not None:
+                            for instance_elem in plate_elem.findall("model_instance"):
+                                for inst_meta in instance_elem.findall("metadata"):
+                                    if inst_meta.get("key") == "object_id":
+                                        obj_id = inst_meta.get("value")
+                                        if not obj_id:
+                                            continue
+                                        plate_object_ids.setdefault(plater_id, [])
+                                        if obj_id not in plate_object_ids[plater_id]:
+                                            plate_object_ids[plater_id].append(obj_id)
                 except Exception:
                     pass  # model_settings.config parsing is optional
 
@@ -2454,6 +2477,10 @@ async def get_archive_plates(
                 objects = meta.get("objects", [])
                 if not objects:
                     objects = plate_json_objects.get(idx, [])
+                if not objects and plate_object_ids.get(idx):
+                    objects = [
+                        object_names_by_id.get(obj_id, f"Object {obj_id}") for obj_id in plate_object_ids.get(idx, [])
+                    ]
 
                 plate_name = meta.get("name")
                 if not plate_name:
@@ -2466,6 +2493,7 @@ async def get_archive_plates(
                         "index": idx,
                         "name": plate_name,
                         "objects": objects,
+                        "object_count": len(objects),
                         "has_thumbnail": has_thumbnail,
                         "thumbnail_url": f"/api/v1/archives/{archive_id}/plate-thumbnail/{idx}"
                         if has_thumbnail

+ 28 - 1
backend/app/api/routes/library.py

@@ -1377,12 +1377,23 @@ async def get_library_file_plates(
 
             plate_indices.sort()
 
-            # Parse model_settings.config for plate names
+            # Parse model_settings.config for plate names + object assignments
             plate_names = {}
+            plate_object_ids: dict[int, list[str]] = {}
+            object_names_by_id: dict[str, str] = {}
             if "Metadata/model_settings.config" in namelist:
                 try:
                     model_content = zf.read("Metadata/model_settings.config").decode()
                     model_root = ET.fromstring(model_content)
+                    for obj_elem in model_root.findall(".//object"):
+                        obj_id = obj_elem.get("id")
+                        if not obj_id:
+                            continue
+                        name_meta = obj_elem.find("metadata[@key='name']")
+                        obj_name = name_meta.get("value") if name_meta is not None else None
+                        if obj_name:
+                            object_names_by_id[obj_id] = obj_name
+
                     for plate_elem in model_root.findall(".//plate"):
                         plater_id = None
                         plater_name = None
@@ -1398,6 +1409,17 @@ async def get_library_file_plates(
                                 plater_name = value.strip()
                         if plater_id is not None and plater_name:
                             plate_names[plater_id] = plater_name
+
+                        if plater_id is not None:
+                            for instance_elem in plate_elem.findall("model_instance"):
+                                for inst_meta in instance_elem.findall("metadata"):
+                                    if inst_meta.get("key") == "object_id":
+                                        obj_id = inst_meta.get("value")
+                                        if not obj_id:
+                                            continue
+                                        plate_object_ids.setdefault(plater_id, [])
+                                        if obj_id not in plate_object_ids[plater_id]:
+                                            plate_object_ids[plater_id].append(obj_id)
                 except Exception:
                     pass
 
@@ -1501,6 +1523,10 @@ async def get_library_file_plates(
                 objects = meta.get("objects", [])
                 if not objects:
                     objects = plate_json_objects.get(idx, [])
+                if not objects and plate_object_ids.get(idx):
+                    objects = [
+                        object_names_by_id.get(obj_id, f"Object {obj_id}") for obj_id in plate_object_ids.get(idx, [])
+                    ]
 
                 plate_name = meta.get("name")
                 if not plate_name:
@@ -1513,6 +1539,7 @@ async def get_library_file_plates(
                         "index": idx,
                         "name": plate_name,
                         "objects": objects,
+                        "object_count": len(objects),
                         "has_thumbnail": has_thumbnail,
                         "thumbnail_url": f"/api/v1/library/files/{file_id}/plate-thumbnail/{idx}"
                         if has_thumbnail

+ 1 - 0
backend/app/api/routes/printers.py

@@ -1065,6 +1065,7 @@ async def get_printer_file_plates(
                         "index": idx,
                         "name": plate_name,
                         "objects": objects,
+                        "object_count": len(objects),
                         "has_thumbnail": has_thumbnail,
                         "thumbnail_url": f"/api/v1/printers/{printer_id}/files/plate-thumbnail/{idx}?path={path}",
                         "print_time_seconds": meta.get("prediction"),

+ 48 - 20
frontend/src/components/ModelViewer.tsx

@@ -46,6 +46,7 @@ interface Parsed3MFData {
   objects: Map<string, ObjectData>;
   buildItems: BuildItem[];
   plateBounds: Map<number, { minX: number; minY: number; maxX: number; maxY: number }>;
+  plateOffsets: Map<number, { offsetX: number; offsetY: number }>;
 }
 
 // Parse 3MF transform - keep in 3MF coordinate space (Z-up)
@@ -140,6 +141,7 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<Parsed3MFData> {
   const objects = new Map<string, ObjectData>();
   const buildItems: BuildItem[] = [];
   const plateBounds = new Map<number, { minX: number; minY: number; maxX: number; maxY: number }>();
+  const plateOffsets = new Map<number, { offsetX: number; offsetY: number }>();
   const parser = new DOMParser();
 
   // Helper to load and parse a model file from the zip
@@ -214,6 +216,8 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<Parsed3MFData> {
         const plateEl = plateElements[i];
         let plateId: number | null = null;
         const metadataElements = plateEl.getElementsByTagName('metadata');
+        let plateOffsetX = 0;
+        let plateOffsetY = 0;
         for (let j = 0; j < metadataElements.length; j++) {
           const metaEl = metadataElements[j];
           const key = metaEl.getAttribute('key');
@@ -225,9 +229,24 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<Parsed3MFData> {
                 plateId = parsed;
               }
             }
+          } else if (key === 'pos_x') {
+            const value = metaEl.getAttribute('value');
+            const parsed = value ? Number.parseFloat(value) : Number.NaN;
+            if (Number.isFinite(parsed)) {
+              plateOffsetX = parsed;
+            }
+          } else if (key === 'pos_y') {
+            const value = metaEl.getAttribute('value');
+            const parsed = value ? Number.parseFloat(value) : Number.NaN;
+            if (Number.isFinite(parsed)) {
+              plateOffsetY = parsed;
+            }
           }
         }
         if (plateId == null) continue;
+        if (plateOffsetX !== 0 || plateOffsetY !== 0) {
+          plateOffsets.set(plateId, { offsetX: plateOffsetX, offsetY: plateOffsetY });
+        }
 
         const modelInstances = plateEl.getElementsByTagName('model_instance');
         for (let j = 0; j < modelInstances.length; j++) {
@@ -296,11 +315,11 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<Parsed3MFData> {
         }
       }
     }
-    return { objects, buildItems, plateBounds };
+    return { objects, buildItems, plateBounds, plateOffsets };
   }
 
   const mainDoc = await loadModelFile(mainModelPath);
-  if (!mainDoc) return { objects, buildItems, plateBounds };
+  if (!mainDoc) return { objects, buildItems, plateBounds, plateOffsets };
 
   // Parse objects - Bambu Studio uses components to reference external files
   const objectElements = mainDoc.getElementsByTagName('object');
@@ -415,7 +434,7 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<Parsed3MFData> {
     }
   }
 
-  return { objects, buildItems, plateBounds };
+  return { objects, buildItems, plateBounds, plateOffsets };
 }
 
 function createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry {
@@ -697,19 +716,25 @@ export function ModelViewer({
       setLoading(false);
     }
 
-    // Handle resize
+    // Handle resize (window + container)
     const handleResize = () => {
       if (!container) return;
       const w = container.clientWidth;
       const h = container.clientHeight;
+      if (w === 0 || h === 0) return;
       camera.aspect = w / h;
       camera.updateProjectionMatrix();
       renderer.setSize(w, h);
     };
     window.addEventListener('resize', handleResize);
+    const resizeObserver = new ResizeObserver(() => {
+      handleResize();
+    });
+    resizeObserver.observe(container);
 
     return () => {
       window.removeEventListener('resize', handleResize);
+      resizeObserver.disconnect();
       cancelAnimationFrame(animationId);
       controls.dispose();
       renderer.dispose();
@@ -750,33 +775,36 @@ export function ModelViewer({
     // Always place models on the build plate (Y=0)
     group.position.y = -box.min.y;
 
-    // For a selected plate, center the plate contents on the build plate
-    const shouldRecenter = isStlModel || parsedData!.buildItems.length === 0;
-    const centerOffsetX = shouldRecenter ? -center.x : 0;
-    const centerOffsetZ = shouldRecenter ? -center.z : 0;
+    const selectedPlateBounds = (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0)
+      ? parsedData!.plateBounds.get(selectedPlateId)
+      : undefined;
+    const selectedPlateOffset = (!isStlModel && selectedPlateId != null)
+      ? parsedData!.plateOffsets.get(selectedPlateId)
+      : undefined;
+    const shouldCenterOnPlate = isStlModel
+      || parsedData!.buildItems.length === 0
+      || (selectedPlateId != null && !selectedPlateBounds && !selectedPlateOffset);
+    const centerOffsetX = shouldCenterOnPlate ? -center.x : 0;
+    const centerOffsetZ = shouldCenterOnPlate ? -center.z : 0;
 
     let plateOffsetX = 0;
     let plateOffsetZ = 0;
-    if (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0) {
+    if (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0 && selectedPlateBounds) {
       const plateBox = new THREE.Box3().setFromObject(group);
-      const bounds = parsedData!.plateBounds.get(selectedPlateId);
-      if (bounds) {
-        plateOffsetX = plateBox.min.x - bounds.minX;
-        plateOffsetZ = plateBox.min.z - bounds.minY;
-      } else {
-        const epsilon = 1e-6;
-        plateOffsetX = Math.floor((plateBox.min.x + epsilon) / buildVolume.x) * buildVolume.x;
-        plateOffsetZ = Math.floor((plateBox.min.z + epsilon) / buildVolume.y) * buildVolume.y;
-      }
+      plateOffsetX = plateBox.min.x - selectedPlateBounds.minX;
+      plateOffsetZ = plateBox.min.z - selectedPlateBounds.minY;
     }
 
     const plateCenterX = buildVolume.x / 2;
     const plateCenterZ = buildVolume.y / 2;
 
-    if (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0) {
+    if (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0 && selectedPlateBounds) {
       group.position.x = centerOffsetX - plateOffsetX;
       group.position.z = centerOffsetZ - plateOffsetZ;
-    } else if (isStlModel) {
+    } else if (!isStlModel && selectedPlateId != null && selectedPlateOffset) {
+      group.position.x = centerOffsetX + (plateCenterX - selectedPlateOffset.offsetX);
+      group.position.z = centerOffsetZ + (plateCenterZ - selectedPlateOffset.offsetY);
+    } else if (shouldCenterOnPlate) {
       group.position.x = centerOffsetX + plateCenterX;
       group.position.z = centerOffsetZ + plateCenterZ;
     } else {

+ 363 - 78
frontend/src/components/ModelViewerModal.tsx

@@ -1,5 +1,5 @@
-import { useState, useEffect } from 'react';
-import { X, ExternalLink, Box, Code2, Loader2, Layers, Check } from 'lucide-react';
+import { useState, useEffect, useRef } from 'react';
+import { X, ExternalLink, Box, Code2, Loader2, Layers, Check, Maximize2, Minimize2 } from 'lucide-react';
 import { ModelViewer } from './ModelViewer';
 import { GcodeViewer } from './GcodeViewer';
 import { Button } from './Button';
@@ -33,6 +33,17 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
   const [platesData, setPlatesData] = useState<ArchivePlatesResponse | LibraryFilePlatesResponse | null>(null);
   const [platesLoading, setPlatesLoading] = useState(false);
   const [selectedPlateId, setSelectedPlateId] = useState<number | null>(null);
+  const [platePage, setPlatePage] = useState(0);
+  const [isFullscreen, setIsFullscreen] = useState(false);
+  const [platePanelHeight, setPlatePanelHeight] = useState<number | null>(null);
+  const [isDraggingDivider, setIsDraggingDivider] = useState(false);
+  const [hasCustomSplit, setHasCustomSplit] = useState(false);
+  const splitContainerRef = useRef<HTMLDivElement>(null);
+  const platesPanelRef = useRef<HTMLDivElement>(null);
+  const dividerHeight = 10;
+  const minPlateHeight = 160;
+  const minViewerPx = 240;
+  const minViewerRatio = 0.35;
 
   // Close on Escape key
   useEffect(() => {
@@ -91,6 +102,7 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
   useEffect(() => {
     setPlatesLoading(true);
     setSelectedPlateId(null);
+    setPlatePage(0);
 
     if (isLibrary) {
       const normalizedType = (fileType || '').toLowerCase();
@@ -120,9 +132,131 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
 
   const plates = platesData?.plates ?? [];
   const hasMultiplePlates = (platesData?.is_multi_plate ?? false) && plates.length > 1;
+  const splitFullscreen = isFullscreen && hasMultiplePlates;
   const selectedPlate: PlateMetadata | null = selectedPlateId == null
     ? null
     : plates.find((plate) => plate.index === selectedPlateId) ?? null;
+  const getPlateObjectCount = (plate: PlateMetadata): number => plate.object_count ?? plate.objects?.length ?? 0;
+  const totalObjectCount = plates.reduce((sum, plate) => sum + getPlateObjectCount(plate), 0);
+  const selectedObjectCount = selectedPlate ? getPlateObjectCount(selectedPlate) : totalObjectCount;
+  const objectCountLabel = selectedPlate ? `Plate ${selectedPlate.index}` : 'All plates';
+  const hasObjectCount = plates.length > 0;
+  const platesGridRef = useRef<HTMLDivElement>(null);
+  const platesViewportRef = useRef<HTMLDivElement>(null);
+  const [platesPerPage, setPlatesPerPage] = useState(10);
+  const [plateColumns, setPlateColumns] = useState(3);
+  const [plateRows, setPlateRows] = useState(3);
+  const shouldPaginatePlates = plates.length > platesPerPage;
+  const totalPlatePages = Math.max(1, Math.ceil(plates.length / platesPerPage));
+  const pagedPlates = shouldPaginatePlates
+    ? plates.slice(platePage * platesPerPage, (platePage + 1) * platesPerPage)
+    : plates;
+
+  useEffect(() => {
+    if (!splitFullscreen) {
+      setPlatesPerPage(10);
+      setPlateColumns(3);
+      return;
+    }
+    const grid = platesGridRef.current;
+    const viewport = platesViewportRef.current;
+    if (!grid || !viewport) return;
+    let rafId = 0;
+    const updateLayout = () => {
+      const availableWidth = viewport.clientWidth;
+      const minButtonWidth = 210;
+      const computedCols = Math.floor(availableWidth / minButtonWidth);
+      const nextCols = Math.max(3, Math.min(5, computedCols || 3));
+      setPlateColumns((prev) => (prev === nextCols ? prev : nextCols));
+
+      const computed = window.getComputedStyle(grid);
+      const rowGap = Number.parseFloat(computed.rowGap || '0');
+      const firstItem = grid.querySelector<HTMLElement>('button');
+      const rowHeight = firstItem?.getBoundingClientRect().height ?? 44;
+      const availableHeight = viewport.clientHeight;
+      const rows = Math.max(1, Math.floor((availableHeight + rowGap) / (rowHeight + rowGap)));
+      setPlateRows((prev) => (prev === rows ? prev : rows));
+      const maxSlots = rows * nextCols;
+      const nextPerPage = Math.max(1, maxSlots - 1);
+      setPlatesPerPage((prev) => (prev === nextPerPage ? prev : nextPerPage));
+    };
+    const scheduleUpdate = () => {
+      if (rafId) cancelAnimationFrame(rafId);
+      rafId = requestAnimationFrame(updateLayout);
+    };
+    scheduleUpdate();
+    const resizeObserver = new ResizeObserver(scheduleUpdate);
+    resizeObserver.observe(viewport);
+    resizeObserver.observe(grid);
+    return () => {
+      if (rafId) cancelAnimationFrame(rafId);
+      resizeObserver.disconnect();
+    };
+  }, [splitFullscreen, plates.length]);
+
+  useEffect(() => {
+    if (!shouldPaginatePlates) {
+      setPlatePage(0);
+      return;
+    }
+    setPlatePage((prev) => Math.min(prev, totalPlatePages - 1));
+  }, [plates.length, shouldPaginatePlates, totalPlatePages]);
+
+  useEffect(() => {
+    if (!shouldPaginatePlates || selectedPlateId == null) return;
+    const selectedIndex = plates.findIndex((plate) => plate.index === selectedPlateId);
+    if (selectedIndex < 0) return;
+    const nextPage = Math.floor(selectedIndex / platesPerPage);
+    setPlatePage((prev) => (prev === nextPage ? prev : nextPage));
+  }, [plates, platesPerPage, selectedPlateId, shouldPaginatePlates]);
+
+  useEffect(() => {
+    if (!splitFullscreen) {
+      setPlatePanelHeight(null);
+      setHasCustomSplit(false);
+      return;
+    }
+    if (hasCustomSplit) return;
+    const container = splitContainerRef.current;
+    const panel = platesPanelRef.current;
+    if (!container || !panel) return;
+    const containerHeight = container.clientHeight;
+    if (!containerHeight) return;
+    const minViewerHeight = Math.max(minViewerPx, containerHeight * minViewerRatio);
+    const maxPlateHeight = Math.max(minPlateHeight, containerHeight - dividerHeight - minViewerHeight);
+    const desiredHeight = Math.min(panel.scrollHeight, maxPlateHeight);
+    setPlatePanelHeight(Math.max(minPlateHeight, desiredHeight));
+  }, [splitFullscreen, hasCustomSplit, plates.length, platePage, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]);
+
+  useEffect(() => {
+    if (!isDraggingDivider) return;
+    const handleMouseMove = (event: MouseEvent) => {
+      const container = splitContainerRef.current;
+      if (!container) return;
+      const rect = container.getBoundingClientRect();
+      const containerHeight = rect.height;
+      if (!containerHeight) return;
+      const minViewerHeight = Math.max(minViewerPx, containerHeight * minViewerRatio);
+      const maxPlateHeight = Math.max(minPlateHeight, containerHeight - dividerHeight - minViewerHeight);
+      const nextHeight = Math.min(maxPlateHeight, Math.max(minPlateHeight, event.clientY - rect.top));
+      setPlatePanelHeight(nextHeight);
+    };
+    const handleMouseUp = () => {
+      setIsDraggingDivider(false);
+      setHasCustomSplit(true);
+    };
+    document.addEventListener('mousemove', handleMouseMove);
+    document.addEventListener('mouseup', handleMouseUp);
+    document.body.style.cursor = 'row-resize';
+    document.body.style.userSelect = 'none';
+
+    return () => {
+      document.removeEventListener('mousemove', handleMouseMove);
+      document.removeEventListener('mouseup', handleMouseUp);
+      document.body.style.cursor = '';
+      document.body.style.userSelect = '';
+    };
+  }, [isDraggingDivider, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]);
 
   const canOpenInSlicer = isLibrary ? (fileType || '').toLowerCase() === '3mf' : true;
 
@@ -141,21 +275,38 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
 
   return (
     <div
-      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-8"
+      className={`fixed inset-0 bg-black/70 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-8'}`}
       onClick={onClose}
     >
       <div
-        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-4xl h-[80vh] flex flex-col"
+        className={`bg-bambu-dark-secondary border border-bambu-dark-tertiary w-full flex flex-col ${
+          isFullscreen ? 'h-full max-w-none rounded-none' : 'h-[80vh] max-w-4xl rounded-xl'
+        }`}
         onClick={(e) => e.stopPropagation()}
       >
         {/* Header */}
         <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
-          <h2 className="text-lg font-semibold text-white truncate flex-1 mr-4">{title}</h2>
+          <div className="flex items-center gap-3 min-w-0 flex-1 mr-4">
+            <h2 className="text-lg font-semibold text-white truncate">{title}</h2>
+            {hasObjectCount && (
+              <span className="text-xs text-bambu-gray bg-bambu-dark-tertiary/70 px-2 py-1 rounded whitespace-nowrap">
+                {objectCountLabel}: {selectedObjectCount} object{selectedObjectCount !== 1 ? 's' : ''}
+              </span>
+            )}
+          </div>
           <div className="flex items-center gap-2">
             <Button variant="secondary" size="sm" onClick={handleOpenInSlicer} disabled={!canOpenInSlicer}>
               <ExternalLink className="w-4 h-4" />
               Open in Slicer
             </Button>
+            <Button
+              variant="secondary"
+              size="sm"
+              onClick={() => setIsFullscreen((prev) => !prev)}
+              title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
+            >
+              {isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
+            </Button>
             <Button variant="ghost" size="sm" onClick={onClose}>
               <X className="w-5 h-5" />
             </Button>
@@ -205,92 +356,226 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
               <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
             </div>
           ) : activeTab === '3d' && capabilities ? (
-            <div className="w-full h-full flex flex-col gap-3">
+            <div
+              ref={splitContainerRef}
+              className={`w-full h-full flex flex-col ${splitFullscreen ? 'gap-0 min-h-0' : 'gap-3'}`}
+            >
               {hasMultiplePlates && (
-                <div className="rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3">
+                <div
+                  ref={platesPanelRef}
+                  style={splitFullscreen && platePanelHeight != null ? { height: platePanelHeight } : undefined}
+                  className={`rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3 ${splitFullscreen ? 'flex flex-col shrink-0' : ''}`}
+                >
                   <div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
                     <Layers className="w-4 h-4" />
                     Plates
                     {platesLoading && <Loader2 className="w-3 h-3 animate-spin" />}
                   </div>
-                  <div className="grid grid-cols-2 md:grid-cols-3 gap-2">
-                    <button
-                      type="button"
-                      onClick={() => setSelectedPlateId(null)}
-                      className={`flex items-center gap-2 rounded-lg border p-2 text-left transition-colors ${
-                        selectedPlateId == null
-                          ? 'border-bambu-green bg-bambu-green/10'
-                          : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
-                      }`}
-                    >
-                      <div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
-                        <Layers className="w-5 h-5 text-bambu-gray" />
-                      </div>
-                      <div className="min-w-0 flex-1">
-                        <p className="text-sm text-white font-medium truncate">All Plates</p>
-                        <p className="text-xs text-bambu-gray truncate">
-                          {plates.length} plate{plates.length !== 1 ? 's' : ''}
-                        </p>
-                      </div>
-                      {selectedPlateId == null && (
-                        <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
-                      )}
-                    </button>
-                    {plates.map((plate) => (
-                      <button
-                        key={plate.index}
-                        type="button"
-                        onClick={() => setSelectedPlateId(plate.index)}
-                        className={`flex items-center gap-2 rounded-lg border p-2 text-left transition-colors ${
-                          selectedPlateId === plate.index
-                            ? 'border-bambu-green bg-bambu-green/10'
-                            : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
-                        }`}
+                  <div className={splitFullscreen ? 'flex flex-col min-h-0 flex-1' : undefined}>
+                      <div
+                        ref={platesViewportRef}
+                        className={splitFullscreen ? 'min-h-0 overflow-hidden pr-1 flex-1' : undefined}
+                      >
+                      <div
+                        ref={platesGridRef}
+                        className={splitFullscreen ? 'grid gap-2' : 'grid grid-cols-2 md:grid-cols-3 gap-2'}
+                        style={splitFullscreen ? { gridTemplateColumns: `repeat(${plateColumns}, minmax(0, 1fr))` } : undefined}
                       >
-                        {plate.has_thumbnail && plate.thumbnail_url ? (
-                          <img
-                            src={plate.thumbnail_url}
-                            alt={`Plate ${plate.index}`}
-                            className="w-10 h-10 rounded object-cover bg-bambu-dark-tertiary"
-                          />
-                        ) : (
-                          <div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
-                            <Layers className="w-5 h-5 text-bambu-gray" />
+                        <button
+                          type="button"
+                          onClick={() => setSelectedPlateId(null)}
+                          className={`flex items-center rounded-lg border text-left transition-colors ${
+                            splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
+                          } ${
+                            selectedPlateId == null
+                              ? 'border-bambu-green bg-bambu-green/10'
+                              : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
+                          }`}
+                        >
+                          <div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
+                            splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
+                          }`}>
+                            <Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
+                          </div>
+                          <div className="min-w-0 flex-1">
+                            <p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>All Plates</p>
+                            <p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
+                              {plates.length} plate{plates.length !== 1 ? 's' : ''}
+                            </p>
+                          </div>
+                          {selectedPlateId == null && (
+                            <Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
+                          )}
+                        </button>
+                        {pagedPlates.map((plate) => (
+                          <button
+                            key={plate.index}
+                            type="button"
+                            onClick={() => setSelectedPlateId(plate.index)}
+                            className={`flex items-center rounded-lg border text-left transition-colors ${
+                              splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
+                            } ${
+                              selectedPlateId === plate.index
+                                ? 'border-bambu-green bg-bambu-green/10'
+                                : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
+                            }`}
+                          >
+                            {plate.has_thumbnail && plate.thumbnail_url ? (
+                              <img
+                                src={plate.thumbnail_url}
+                                alt={`Plate ${plate.index}`}
+                                className={`${splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'} rounded object-cover bg-bambu-dark-tertiary`}
+                              />
+                            ) : (
+                              <div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
+                                splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
+                              }`}>
+                                <Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
+                              </div>
+                            )}
+                            <div className="min-w-0 flex-1">
+                              <p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>
+                                {plate.name || `Plate ${plate.index}`}
+                              </p>
+                              <p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
+                                {(() => {
+                                  const objectCount = plate.object_count ?? plate.objects?.length ?? 0;
+                                  return `${objectCount} object${objectCount !== 1 ? 's' : ''}`;
+                                })()}
+                              </p>
+                            </div>
+                            {selectedPlateId === plate.index && (
+                              <Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
+                            )}
+                          </button>
+                        ))}
+                      </div>
+                    </div>
+                    {(selectedPlate || shouldPaginatePlates) && (
+                      <div className="mt-auto pt-3 flex items-center gap-4 text-xs text-bambu-gray overflow-x-auto">
+                        {selectedPlate && (
+                          <div className="flex items-center gap-3 whitespace-nowrap">
+                            <span>Plate {selectedPlate.index}</span>
+                            {selectedPlate.print_time_seconds != null && (
+                              <span>ETA {Math.round(selectedPlate.print_time_seconds / 60)} min</span>
+                            )}
+                            {selectedPlate.filament_used_grams != null && (
+                              <span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>
+                            )}
+                            {selectedPlate.filaments.length > 0 && (
+                              <span>{selectedPlate.filaments.length} filament{selectedPlate.filaments.length !== 1 ? 's' : ''}</span>
+                            )}
                           </div>
                         )}
-                        <div className="min-w-0 flex-1">
-                          <p className="text-sm text-white font-medium truncate">
-                            {plate.name || `Plate ${plate.index}`}
-                          </p>
-                          <p className="text-xs text-bambu-gray truncate">
-                            {plate.objects.length > 0
-                              ? plate.objects.slice(0, 2).join(', ') + (plate.objects.length > 2 ? '…' : '')
-                              : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
-                          </p>
-                        </div>
-                        {selectedPlateId === plate.index && (
-                          <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
+                        {shouldPaginatePlates && (
+                          <div className={`flex items-center gap-2 whitespace-nowrap ${selectedPlate ? 'ml-auto' : ''}`}>
+                            <span>Page {platePage + 1} of {totalPlatePages}</span>
+                            <div className="flex items-center gap-1">
+                              <button
+                                type="button"
+                                onClick={() => setPlatePage((prev) => Math.max(prev - 1, 0))}
+                                disabled={platePage === 0}
+                                className={`px-2 py-1 rounded border text-xs ${
+                                  platePage === 0
+                                    ? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
+                                    : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                                }`}
+                              >
+                                Prev
+                              </button>
+                              {(() => {
+                                const maxVisible = 5;
+                                let start = Math.max(0, platePage - Math.floor(maxVisible / 2));
+                                let end = Math.min(totalPlatePages, start + maxVisible);
+                                if (end - start < maxVisible) {
+                                  start = Math.max(0, end - maxVisible);
+                                }
+                                const pages = Array.from({ length: end - start }, (_, i) => start + i);
+
+                                return (
+                                  <>
+                                    {start > 0 && (
+                                      <button
+                                        type="button"
+                                        onClick={() => setPlatePage(0)}
+                                        className={`px-2 py-1 rounded border text-xs ${
+                                          platePage === 0
+                                            ? 'border-bambu-green text-bambu-green'
+                                            : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                                        }`}
+                                      >
+                                        1
+                                      </button>
+                                    )}
+                                    {start > 1 && <span className="px-1">…</span>}
+                                    {pages.map((pageNumber) => (
+                                      <button
+                                        key={pageNumber}
+                                        type="button"
+                                        onClick={() => setPlatePage(pageNumber)}
+                                        className={`px-2 py-1 rounded border text-xs ${
+                                          platePage === pageNumber
+                                            ? 'border-bambu-green text-bambu-green'
+                                            : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                                        }`}
+                                      >
+                                        {pageNumber + 1}
+                                      </button>
+                                    ))}
+                                    {end < totalPlatePages - 1 && <span className="px-1">…</span>}
+                                    {end < totalPlatePages && (
+                                      <button
+                                        type="button"
+                                        onClick={() => setPlatePage(totalPlatePages - 1)}
+                                        className={`px-2 py-1 rounded border text-xs ${
+                                          platePage === totalPlatePages - 1
+                                            ? 'border-bambu-green text-bambu-green'
+                                            : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                                        }`}
+                                      >
+                                        {totalPlatePages}
+                                      </button>
+                                    )}
+                                  </>
+                                );
+                              })()}
+                              <button
+                                type="button"
+                                onClick={() => setPlatePage((prev) => Math.min(prev + 1, totalPlatePages - 1))}
+                                disabled={platePage >= totalPlatePages - 1}
+                                className={`px-2 py-1 rounded border text-xs ${
+                                  platePage >= totalPlatePages - 1
+                                    ? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
+                                    : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                                }`}
+                              >
+                                Next
+                              </button>
+                            </div>
+                          </div>
                         )}
-                      </button>
-                    ))}
+                      </div>
+                    )}
                   </div>
-                  {selectedPlate && (
-                    <div className="mt-3 text-xs text-bambu-gray flex flex-wrap gap-x-4 gap-y-1">
-                      <span>Plate {selectedPlate.index}</span>
-                      {selectedPlate.print_time_seconds != null && (
-                        <span>ETA {Math.round(selectedPlate.print_time_seconds / 60)} min</span>
-                      )}
-                      {selectedPlate.filament_used_grams != null && (
-                        <span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>
-                      )}
-                      {selectedPlate.filaments.length > 0 && (
-                        <span>{selectedPlate.filaments.length} filament{selectedPlate.filaments.length !== 1 ? 's' : ''}</span>
-                      )}
-                    </div>
-                  )}
                 </div>
               )}
-              <div className="flex-1">
+              {splitFullscreen && (
+                <div
+                  role="separator"
+                  aria-orientation="horizontal"
+                  onMouseDown={(event) => {
+                    event.preventDefault();
+                    setIsDraggingDivider(true);
+                    setHasCustomSplit(true);
+                  }}
+                  className={`h-2 cursor-row-resize flex items-center justify-center ${
+                    isDraggingDivider ? 'bg-bambu-dark-tertiary' : 'bg-bambu-dark-secondary/60 hover:bg-bambu-dark-tertiary'
+                  }`}
+                >
+                  <div className="w-12 h-1 rounded-full bg-bambu-gray/50" />
+                </div>
+              )}
+              <div className={`flex-1 ${splitFullscreen ? 'min-h-0' : ''}`}>
                   <ModelViewer
                     url={isLibrary
                       ? api.getLibraryFileDownloadUrl(libraryFileId!)

+ 1 - 0
frontend/src/types/plates.ts

@@ -10,6 +10,7 @@ export interface PlateMetadata {
   index: number;
   name: string | null;
   objects: string[];
+  object_count?: number;
   has_thumbnail: boolean;
   thumbnail_url: string | null;
   print_time_seconds: number | null;