From 87c18e5fcf7762aa29369fa0916cc7936c767e8b Mon Sep 17 00:00:00 2001 From: MisterBeardy Date: Tue, 3 Feb 2026 14:14:00 -0500 Subject: [PATCH] Add plate object count metadata and viewer display --- backend/app/api/routes/archives.py | 30 +- backend/app/api/routes/library.py | 29 +- backend/app/api/routes/printers.py | 1 + frontend/src/components/ModelViewer.tsx | 68 ++- frontend/src/components/ModelViewerModal.tsx | 441 +++++++++++++++---- frontend/src/types/plates.ts | 1 + 6 files changed, 470 insertions(+), 100 deletions(-) diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index 356e40fc9..a3f79d7e0 100644 --- a/backend/app/api/routes/archives.py +++ b/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 diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index cb7c3d71e..2d61c10f8 100644 --- a/backend/app/api/routes/library.py +++ b/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 diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 5bb97bed6..8145538d1 100644 --- a/backend/app/api/routes/printers.py +++ b/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"), diff --git a/frontend/src/components/ModelViewer.tsx b/frontend/src/components/ModelViewer.tsx index bdc3ccd6f..22397238c 100644 --- a/frontend/src/components/ModelViewer.tsx +++ b/frontend/src/components/ModelViewer.tsx @@ -46,6 +46,7 @@ interface Parsed3MFData { objects: Map; buildItems: BuildItem[]; plateBounds: Map; + plateOffsets: Map; } // Parse 3MF transform - keep in 3MF coordinate space (Z-up) @@ -140,6 +141,7 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise { const objects = new Map(); const buildItems: BuildItem[] = []; const plateBounds = new Map(); + const plateOffsets = new Map(); 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 { 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 { 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 { } } } - 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 { } } - 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 { diff --git a/frontend/src/components/ModelViewerModal.tsx b/frontend/src/components/ModelViewerModal.tsx index 25260f7c1..afba5faf2 100644 --- a/frontend/src/components/ModelViewerModal.tsx +++ b/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(null); const [platesLoading, setPlatesLoading] = useState(false); const [selectedPlateId, setSelectedPlateId] = useState(null); + const [platePage, setPlatePage] = useState(0); + const [isFullscreen, setIsFullscreen] = useState(false); + const [platePanelHeight, setPlatePanelHeight] = useState(null); + const [isDraggingDivider, setIsDraggingDivider] = useState(false); + const [hasCustomSplit, setHasCustomSplit] = useState(false); + const splitContainerRef = useRef(null); + const platesPanelRef = useRef(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(null); + const platesViewportRef = useRef(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('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 (
e.stopPropagation()} > {/* Header */}
-

{title}

+
+

{title}

+ {hasObjectCount && ( + + {objectCountLabel}: {selectedObjectCount} object{selectedObjectCount !== 1 ? 's' : ''} + + )} +
+ @@ -205,92 +356,226 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
) : activeTab === '3d' && capabilities ? ( -
+
{hasMultiplePlates && ( -
+
Plates {platesLoading && }
-
- - {plates.map((plate) => ( - + {pagedPlates.map((plate) => ( + + ))} +
+
+ {(selectedPlate || shouldPaginatePlates) && ( +
+ {selectedPlate && ( +
+ Plate {selectedPlate.index} + {selectedPlate.print_time_seconds != null && ( + ETA {Math.round(selectedPlate.print_time_seconds / 60)} min + )} + {selectedPlate.filament_used_grams != null && ( + {selectedPlate.filament_used_grams.toFixed(1)} g + )} + {selectedPlate.filaments.length > 0 && ( + {selectedPlate.filaments.length} filament{selectedPlate.filaments.length !== 1 ? 's' : ''} + )}
)} -
-

- {plate.name || `Plate ${plate.index}`} -

-

- {plate.objects.length > 0 - ? plate.objects.slice(0, 2).join(', ') + (plate.objects.length > 2 ? '…' : '') - : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`} -

-
- {selectedPlateId === plate.index && ( - + {shouldPaginatePlates && ( +
+ Page {platePage + 1} of {totalPlatePages} +
+ + {(() => { + 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 && ( + + )} + {start > 1 && …} + {pages.map((pageNumber) => ( + + ))} + {end < totalPlatePages - 1 && …} + {end < totalPlatePages && ( + + )} + + ); + })()} + +
+
)} - - ))} +
+ )}
- {selectedPlate && ( -
- Plate {selectedPlate.index} - {selectedPlate.print_time_seconds != null && ( - ETA {Math.round(selectedPlate.print_time_seconds / 60)} min - )} - {selectedPlate.filament_used_grams != null && ( - {selectedPlate.filament_used_grams.toFixed(1)} g - )} - {selectedPlate.filaments.length > 0 && ( - {selectedPlate.filaments.length} filament{selectedPlate.filaments.length !== 1 ? 's' : ''} - )} -
- )}
)} -
+ {splitFullscreen && ( +
{ + 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' + }`} + > +
+
+ )} +