mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 23:45:40 +02:00
Add plate object count metadata and viewer display
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
{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" />
|
||||
<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}
|
||||
>
|
||||
<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!)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user