mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
Merge branch '0.1.8b' into cadtoolbox/248
This commit is contained in:
@@ -87,10 +87,12 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
|
||||
if (isVisible && triggerRef.current && cardRef.current) {
|
||||
const triggerRect = triggerRef.current.getBoundingClientRect();
|
||||
const cardHeight = cardRef.current.offsetHeight;
|
||||
const spaceAbove = triggerRect.top;
|
||||
// Account for fixed header (56px) - space above should exclude header area
|
||||
const headerHeight = 56;
|
||||
const spaceAbove = triggerRect.top - headerHeight;
|
||||
const spaceBelow = window.innerHeight - triggerRect.bottom;
|
||||
|
||||
// Prefer top, but flip to bottom if not enough space
|
||||
// Prefer top, but flip to bottom if not enough space (accounting for header)
|
||||
if (spaceAbove < cardHeight + 12 && spaceBelow > spaceAbove) {
|
||||
setPosition('bottom');
|
||||
} else {
|
||||
|
||||
@@ -143,7 +143,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
|
||||
|
||||
months.push({
|
||||
month: monthStr,
|
||||
filament: Math.round(monthArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0) * (a.quantity || 1), 0)),
|
||||
filament: Math.round(monthArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0)),
|
||||
cost: monthArchives.reduce((sum, a) => sum + (a.cost || 0), 0),
|
||||
prints: monthArchives.reduce((sum, a) => sum + (a.quantity || 1), 0),
|
||||
});
|
||||
@@ -153,7 +153,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
|
||||
}, [archives]);
|
||||
|
||||
const chartData = timeRange === '7d' || timeRange === '30d' ? dailyData : weeklyData;
|
||||
const totalFilament = filteredArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0) * (a.quantity || 1), 0);
|
||||
const totalFilament = filteredArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0);
|
||||
const totalCost = filteredArchives.reduce((sum, a) => sum + (a.cost || 0), 0);
|
||||
const totalPrints = filteredArchives.reduce((sum, a) => sum + (a.quantity || 1), 0);
|
||||
|
||||
|
||||
@@ -20,10 +20,14 @@ import {
|
||||
CheckSquare,
|
||||
Square,
|
||||
MinusSquare,
|
||||
Box,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { ModelViewer } from './ModelViewer';
|
||||
import { GcodeViewer } from './GcodeViewer';
|
||||
import type { PlateMetadata } from '../types/plates';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
interface FileManagerModalProps {
|
||||
@@ -32,6 +36,205 @@ interface FileManagerModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type PrinterViewerTab = '3d' | 'gcode';
|
||||
|
||||
interface PrinterFileViewerModalProps {
|
||||
printerId: number;
|
||||
filePath: string;
|
||||
filename: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function PrinterFileViewerModal({ printerId, filePath, filename, onClose }: PrinterFileViewerModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<PrinterViewerTab | null>(null);
|
||||
const [plates, setPlates] = useState<PlateMetadata[]>([]);
|
||||
const [platesLoading, setPlatesLoading] = useState(false);
|
||||
const [selectedPlateId, setSelectedPlateId] = useState<number | null>(null);
|
||||
|
||||
const ext = filename.toLowerCase().split('.').pop() || '';
|
||||
const hasModel = ext === '3mf' || ext === 'stl';
|
||||
const hasGcode = ext === 'gcode' || ext === '3mf';
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab(hasModel ? '3d' : hasGcode ? 'gcode' : null);
|
||||
}, [hasModel, hasGcode]);
|
||||
|
||||
useEffect(() => {
|
||||
setPlates([]);
|
||||
setSelectedPlateId(null);
|
||||
|
||||
if (!hasModel) return;
|
||||
|
||||
setPlatesLoading(true);
|
||||
api.getPrinterFilePlates(printerId, filePath)
|
||||
.then((data) => setPlates(data.plates || []))
|
||||
.catch(() => setPlates([]))
|
||||
.finally(() => setPlatesLoading(false));
|
||||
}, [filePath, hasModel, printerId]);
|
||||
|
||||
const hasMultiplePlates = plates.length > 1;
|
||||
const selectedPlate = selectedPlateId == null
|
||||
? null
|
||||
: plates.find((plate) => plate.index === selectedPlateId) ?? null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-6" 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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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">{filename}</h2>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex border-b border-bambu-dark-tertiary">
|
||||
<button
|
||||
onClick={() => hasModel && setActiveTab('3d')}
|
||||
disabled={!hasModel}
|
||||
className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === '3d'
|
||||
? 'text-bambu-green border-b-2 border-bambu-green'
|
||||
: hasModel
|
||||
? 'text-bambu-gray hover:text-white'
|
||||
: 'text-bambu-gray/30 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<Box className="w-4 h-4" />
|
||||
3D Model
|
||||
{!hasModel && <span className="text-xs">(not available)</span>}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => hasGcode && setActiveTab('gcode')}
|
||||
disabled={!hasGcode}
|
||||
className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === 'gcode'
|
||||
? 'text-bambu-green border-b-2 border-bambu-green'
|
||||
: hasGcode
|
||||
? 'text-bambu-gray hover:text-white'
|
||||
: 'text-bambu-gray/30 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
G-code Preview
|
||||
{!hasGcode && <span className="text-xs">(not sliced)</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden p-4">
|
||||
{activeTab === '3d' && hasModel ? (
|
||||
<div className="w-full h-full flex flex-col gap-3">
|
||||
{hasMultiplePlates && (
|
||||
<div className="rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3">
|
||||
<div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
|
||||
<Box 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">
|
||||
<Box 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 && (
|
||||
<CheckSquare 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'
|
||||
}`}
|
||||
>
|
||||
{plate.has_thumbnail ? (
|
||||
<img
|
||||
src={api.getPrinterFilePlateThumbnail(printerId, plate.index, filePath)}
|
||||
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">
|
||||
<Box 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">
|
||||
{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 && (
|
||||
<CheckSquare className="w-4 h-4 text-bambu-green flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</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">
|
||||
<ModelViewer
|
||||
url={api.getPrinterFileDownloadUrl(printerId, filePath)}
|
||||
fileType={ext}
|
||||
selectedPlateId={selectedPlateId}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === 'gcode' && hasGcode ? (
|
||||
<GcodeViewer
|
||||
gcodeUrl={api.getPrinterFileGcodeUrl(printerId, filePath)}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-bambu-gray">
|
||||
No preview available for this file
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
@@ -92,6 +295,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
|
||||
const [filesToDelete, setFilesToDelete] = useState<string[]>([]);
|
||||
const [sortBy, setSortBy] = useState<SortOption>('name-asc');
|
||||
const [downloadProgress, setDownloadProgress] = useState<{ current: number; total: number } | null>(null);
|
||||
const [viewerFile, setViewerFile] = useState<{ path: string; name: string } | null>(null);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
@@ -253,6 +457,8 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-bambu-gray hover:text-white transition-colors"
|
||||
title="Close file manager"
|
||||
aria-label="Close file manager"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -294,6 +500,8 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
||||
className="appearance-none bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm py-1.5 pl-2 pr-6 focus:border-bambu-green focus:outline-none cursor-pointer"
|
||||
title="Sort files"
|
||||
aria-label="Sort files"
|
||||
>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
@@ -318,6 +526,8 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
|
||||
onClick={navigateUp}
|
||||
disabled={currentPath === '/'}
|
||||
className="p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Go to parent folder"
|
||||
aria-label="Go to parent folder"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -408,9 +618,23 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
|
||||
/>
|
||||
<span className="flex-1 text-white truncate">{file.name}</span>
|
||||
{!file.is_directory && (
|
||||
<span className="text-sm text-bambu-gray">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-bambu-gray">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
{(file.name.toLowerCase().endsWith('.3mf') || file.name.toLowerCase().endsWith('.gcode') || file.name.toLowerCase().endsWith('.stl')) && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setViewerFile({ path: file.path, name: file.name });
|
||||
}}
|
||||
className="p-1 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green"
|
||||
title="3D View"
|
||||
>
|
||||
<Box className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{file.is_directory && (
|
||||
<ChevronLeft className="w-4 h-4 text-bambu-gray rotate-180" />
|
||||
@@ -508,6 +732,15 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
|
||||
onCancel={() => setFilesToDelete([])}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewerFile && (
|
||||
<PrinterFileViewerModal
|
||||
printerId={printerId}
|
||||
filePath={viewerFile.path}
|
||||
filename={viewerFile.name}
|
||||
onClose={() => setViewerFile(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
|
||||
import JSZip from 'jszip';
|
||||
import { Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
@@ -14,8 +15,10 @@ interface BuildVolume {
|
||||
|
||||
interface ModelViewerProps {
|
||||
url: string;
|
||||
fileType?: string;
|
||||
buildVolume?: BuildVolume;
|
||||
filamentColors?: string[];
|
||||
selectedPlateId?: number | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -29,12 +32,20 @@ interface ObjectData {
|
||||
id: string;
|
||||
meshes: MeshData[];
|
||||
defaultExtruder: number; // Default extruder for object (used if mesh doesn't have specific one)
|
||||
plateId?: number | null;
|
||||
}
|
||||
|
||||
interface BuildItem {
|
||||
objectId: string;
|
||||
transform: THREE.Matrix4;
|
||||
extruder?: number; // Can override object's extruder
|
||||
plateId?: number | null;
|
||||
}
|
||||
|
||||
interface Parsed3MFData {
|
||||
objects: Map<string, ObjectData>;
|
||||
buildItems: BuildItem[];
|
||||
plateBounds: Map<number, { minX: number; minY: number; maxX: number; maxY: number }>;
|
||||
}
|
||||
|
||||
// Parse 3MF transform - keep in 3MF coordinate space (Z-up)
|
||||
@@ -101,10 +112,34 @@ async function parseMeshFromDoc(doc: Document, defaultExtruder: number = 0): Pro
|
||||
return meshes;
|
||||
}
|
||||
|
||||
async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string, ObjectData>; buildItems: BuildItem[] }> {
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
function parsePlateIdFromAttributes(element: Element): number | null {
|
||||
const plateAttribute = Array.from(element.attributes).find((attr) => {
|
||||
const name = attr.name.toLowerCase();
|
||||
return (
|
||||
name === 'plate_id' ||
|
||||
name === 'plater_id' ||
|
||||
name === 'plateid' ||
|
||||
name === 'platerid' ||
|
||||
name.endsWith(':plate_id') ||
|
||||
name.endsWith(':plater_id')
|
||||
);
|
||||
});
|
||||
|
||||
if (!plateAttribute?.value) return null;
|
||||
const parsed = Number.parseInt(plateAttribute.value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
async function parse3MF(arrayBuffer: ArrayBuffer): Promise<Parsed3MFData> {
|
||||
let zip: JSZip;
|
||||
try {
|
||||
zip = await JSZip.loadAsync(arrayBuffer);
|
||||
} catch {
|
||||
throw new Error('Unsupported file format');
|
||||
}
|
||||
const objects = new Map<string, ObjectData>();
|
||||
const buildItems: BuildItem[] = [];
|
||||
const plateBounds = new Map<number, { minX: number; minY: number; maxX: number; maxY: number }>();
|
||||
const parser = new DOMParser();
|
||||
|
||||
// Helper to load and parse a model file from the zip
|
||||
@@ -121,6 +156,8 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
|
||||
// Maps: object ID -> default extruder, and (object ID, part ID) -> part-specific extruder
|
||||
const extruderMapById = new Map<string, number>();
|
||||
const partExtruderMap = new Map<string, number>(); // Key: "objectId:partId"
|
||||
const objectNameById = new Map<string, string>();
|
||||
const plateAssignmentsByObjectId = new Map<string, number>();
|
||||
const modelSettingsFile = zip.files['Metadata/model_settings.config'];
|
||||
if (modelSettingsFile) {
|
||||
try {
|
||||
@@ -132,7 +169,7 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
|
||||
const objectId = objEl.getAttribute('id');
|
||||
if (!objectId) continue;
|
||||
|
||||
// Find object-level extruder
|
||||
// Find object-level extruder + name
|
||||
const directMetadata = Array.from(objEl.children).filter(
|
||||
(el) => el.tagName === 'metadata' && el.getAttribute('key') === 'extruder'
|
||||
);
|
||||
@@ -143,6 +180,14 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
|
||||
}
|
||||
}
|
||||
|
||||
const nameMetadata = Array.from(objEl.children).find(
|
||||
(el) => el.tagName === 'metadata' && el.getAttribute('key') === 'name'
|
||||
);
|
||||
const objectName = nameMetadata?.getAttribute('value');
|
||||
if (objectName) {
|
||||
objectNameById.set(objectId, objectName);
|
||||
}
|
||||
|
||||
// Find part-level extruders
|
||||
const partElements = objEl.getElementsByTagName('part');
|
||||
for (let j = 0; j < partElements.length; j++) {
|
||||
@@ -162,11 +207,78 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse plate -> object assignments
|
||||
const plateElements = doc.getElementsByTagName('plate');
|
||||
for (let i = 0; i < plateElements.length; i++) {
|
||||
const plateEl = plateElements[i];
|
||||
let plateId: number | null = null;
|
||||
const metadataElements = plateEl.getElementsByTagName('metadata');
|
||||
for (let j = 0; j < metadataElements.length; j++) {
|
||||
const metaEl = metadataElements[j];
|
||||
const key = metaEl.getAttribute('key');
|
||||
if (key === 'plater_id' || key === 'plate_id') {
|
||||
const value = metaEl.getAttribute('value');
|
||||
if (value) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
plateId = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (plateId == null) continue;
|
||||
|
||||
const modelInstances = plateEl.getElementsByTagName('model_instance');
|
||||
for (let j = 0; j < modelInstances.length; j++) {
|
||||
const instanceEl = modelInstances[j];
|
||||
const instanceMetadata = instanceEl.getElementsByTagName('metadata');
|
||||
for (let k = 0; k < instanceMetadata.length; k++) {
|
||||
const metaEl = instanceMetadata[k];
|
||||
if (metaEl.getAttribute('key') === 'object_id') {
|
||||
const value = metaEl.getAttribute('value');
|
||||
if (value) {
|
||||
plateAssignmentsByObjectId.set(value, plateId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore model_settings.config parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
// Parse plate_*.json for plate assignments by object name (source-only / unsliced files)
|
||||
const plateAssignmentsByName = new Map<string, number>();
|
||||
const plateJsonNames = Object.keys(zip.files).filter(
|
||||
(name) => name.startsWith('Metadata/plate_') && name.endsWith('.json')
|
||||
);
|
||||
for (const name of plateJsonNames) {
|
||||
const match = name.match(/^Metadata\/plate_(\d+)\.json$/);
|
||||
if (!match) continue;
|
||||
const plateIndex = Number.parseInt(match[1], 10);
|
||||
if (!Number.isFinite(plateIndex)) continue;
|
||||
try {
|
||||
const payload = await zip.files[name].async('string');
|
||||
const json = JSON.parse(payload) as { bbox_objects?: Array<{ name?: string }>; bbox_all?: number[] };
|
||||
const objectsList = json.bbox_objects ?? [];
|
||||
for (const entry of objectsList) {
|
||||
if (entry?.name) {
|
||||
plateAssignmentsByName.set(entry.name, plateIndex);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(json.bbox_all) && json.bbox_all.length >= 4) {
|
||||
const [minX, minY, maxX, maxY] = json.bbox_all;
|
||||
if ([minX, minY, maxX, maxY].every((value) => Number.isFinite(value))) {
|
||||
plateBounds.set(plateIndex, { minX, minY, maxX, maxY });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore plate json parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
// Find the main 3D model file
|
||||
const mainModelPath = Object.keys(zip.files).find(
|
||||
(name) => name === '3D/3dmodel.model' || name.endsWith('/3dmodel.model')
|
||||
@@ -184,11 +296,11 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
|
||||
}
|
||||
}
|
||||
}
|
||||
return { objects, buildItems };
|
||||
return { objects, buildItems, plateBounds };
|
||||
}
|
||||
|
||||
const mainDoc = await loadModelFile(mainModelPath);
|
||||
if (!mainDoc) return { objects, buildItems };
|
||||
if (!mainDoc) return { objects, buildItems, plateBounds };
|
||||
|
||||
// Parse objects - Bambu Studio uses components to reference external files
|
||||
const objectElements = mainDoc.getElementsByTagName('object');
|
||||
@@ -197,6 +309,8 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
|
||||
const objectId = objEl.getAttribute('id');
|
||||
if (!objectId) continue;
|
||||
|
||||
const objectPlateId = parsePlateIdFromAttributes(objEl) ?? plateAssignmentsByObjectId.get(objectId) ?? null;
|
||||
|
||||
// Get default extruder from model_settings.config map, falling back to attribute or default
|
||||
let defaultExtruder = extruderMapById.get(objectId) ?? -1;
|
||||
if (defaultExtruder < 0) {
|
||||
@@ -279,7 +393,7 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
|
||||
}
|
||||
|
||||
if (meshes.length > 0) {
|
||||
objects.set(objectId, { id: objectId, meshes, defaultExtruder });
|
||||
objects.set(objectId, { id: objectId, meshes, defaultExtruder, plateId: objectPlateId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,11 +407,15 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
|
||||
if (!objectId) continue;
|
||||
|
||||
const transform = parseTransform(itemEl.getAttribute('transform'));
|
||||
buildItems.push({ objectId, transform });
|
||||
const itemPlateId = parsePlateIdFromAttributes(itemEl);
|
||||
const objectPlateId = objects.get(objectId)?.plateId ?? null;
|
||||
const objectName = objectNameById.get(objectId);
|
||||
const namePlateId = objectName ? plateAssignmentsByName.get(objectName) ?? null : null;
|
||||
buildItems.push({ objectId, transform, plateId: itemPlateId ?? objectPlateId ?? namePlateId ?? null });
|
||||
}
|
||||
}
|
||||
|
||||
return { objects, buildItems };
|
||||
return { objects, buildItems, plateBounds };
|
||||
}
|
||||
|
||||
function createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry {
|
||||
@@ -321,14 +439,145 @@ function createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry {
|
||||
return geometry;
|
||||
}
|
||||
|
||||
export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, filamentColors, className = '' }: ModelViewerProps) {
|
||||
function disposeGroup(group: THREE.Group) {
|
||||
group.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.geometry.dispose();
|
||||
if (Array.isArray(child.material)) {
|
||||
for (const material of child.material) {
|
||||
material.dispose();
|
||||
}
|
||||
} else {
|
||||
child.material.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildModelGroup(
|
||||
parsedData: Parsed3MFData,
|
||||
selectedPlateId: number | null,
|
||||
filamentColors?: string[],
|
||||
): THREE.Group {
|
||||
const { objects, buildItems } = parsedData;
|
||||
const group = new THREE.Group();
|
||||
|
||||
// Create materials for each extruder color
|
||||
const getMaterial = (extruder: number): THREE.MeshPhongMaterial => {
|
||||
const defaultColor = '#00ae42';
|
||||
const colorStr = filamentColors?.[extruder] || defaultColor;
|
||||
// Convert hex color string to THREE.js color
|
||||
const color = new THREE.Color(colorStr);
|
||||
return new THREE.MeshPhongMaterial({
|
||||
color,
|
||||
shininess: 30,
|
||||
flatShading: false,
|
||||
});
|
||||
};
|
||||
|
||||
// Group geometries by extruder index (using per-mesh extruder)
|
||||
const geometriesByExtruder = new Map<number, THREE.BufferGeometry[]>();
|
||||
|
||||
const hasPlateAssignments = buildItems.some((item) => item.plateId != null);
|
||||
const plateFilteredItems = selectedPlateId == null || !hasPlateAssignments
|
||||
? buildItems
|
||||
: buildItems.filter((item) => item.plateId === selectedPlateId);
|
||||
const activeBuildItems = plateFilteredItems.length > 0 ? plateFilteredItems : buildItems;
|
||||
|
||||
// If we have build items, use them for positioning
|
||||
if (activeBuildItems.length > 0) {
|
||||
for (const item of activeBuildItems) {
|
||||
const objectData = objects.get(item.objectId);
|
||||
if (!objectData) continue;
|
||||
|
||||
for (const meshData of objectData.meshes) {
|
||||
// Use mesh's extruder, or item override, or object default
|
||||
const extruder = item.extruder ?? meshData.extruder;
|
||||
|
||||
// Apply build transform to vertices in 3MF space BEFORE coordinate conversion
|
||||
const transformedVertices: number[] = [];
|
||||
for (let k = 0; k < meshData.vertices.length; k += 3) {
|
||||
const v = new THREE.Vector3(
|
||||
meshData.vertices[k],
|
||||
meshData.vertices[k + 1],
|
||||
meshData.vertices[k + 2]
|
||||
);
|
||||
v.applyMatrix4(item.transform);
|
||||
transformedVertices.push(v.x, v.y, v.z);
|
||||
}
|
||||
// Now create geometry with coordinate conversion
|
||||
const geometry = createGeometryFromMesh({
|
||||
vertices: transformedVertices,
|
||||
triangles: meshData.triangles,
|
||||
extruder: extruder,
|
||||
});
|
||||
|
||||
if (!geometriesByExtruder.has(extruder)) {
|
||||
geometriesByExtruder.set(extruder, []);
|
||||
}
|
||||
geometriesByExtruder.get(extruder)!.push(geometry);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: just add all objects without transforms
|
||||
for (const objectData of objects.values()) {
|
||||
for (const meshData of objectData.meshes) {
|
||||
// Use per-mesh extruder
|
||||
const extruder = meshData.extruder;
|
||||
const geometry = createGeometryFromMesh(meshData);
|
||||
if (!geometriesByExtruder.has(extruder)) {
|
||||
geometriesByExtruder.set(extruder, []);
|
||||
}
|
||||
geometriesByExtruder.get(extruder)!.push(geometry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create meshes for each extruder group
|
||||
for (const [extruder, geometries] of geometriesByExtruder) {
|
||||
if (geometries.length === 0) continue;
|
||||
|
||||
const mergedGeometry = geometries.length === 1
|
||||
? geometries[0]
|
||||
: mergeGeometries(geometries, false);
|
||||
|
||||
if (mergedGeometry) {
|
||||
const material = getMaterial(extruder);
|
||||
const mesh = new THREE.Mesh(mergedGeometry, material);
|
||||
group.add(mesh);
|
||||
}
|
||||
|
||||
// Dispose individual geometries if merged
|
||||
if (geometries.length > 1) {
|
||||
for (const geom of geometries) {
|
||||
geom.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
export function ModelViewer({
|
||||
url,
|
||||
fileType,
|
||||
buildVolume = { x: 256, y: 256, z: 256 },
|
||||
filamentColors,
|
||||
selectedPlateId = null,
|
||||
className = '',
|
||||
}: ModelViewerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
|
||||
const sceneRef = useRef<THREE.Scene | null>(null);
|
||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||
const controlsRef = useRef<OrbitControls | null>(null);
|
||||
const modelGroupRef = useRef<THREE.Group | null>(null);
|
||||
const plateRef = useRef<THREE.Mesh | null>(null);
|
||||
const gridRef = useRef<THREE.GridHelper | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [parsedData, setParsedData] = useState<Parsed3MFData | null>(null);
|
||||
const [stlGeometry, setStlGeometry] = useState<THREE.BufferGeometry | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
@@ -377,6 +626,7 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, fil
|
||||
const gridDivisions = Math.ceil(gridSize / 16);
|
||||
const gridHelper = new THREE.GridHelper(gridSize, gridDivisions, 0x444444, 0x333333);
|
||||
scene.add(gridHelper);
|
||||
gridRef.current = gridHelper;
|
||||
|
||||
// Build plate indicator
|
||||
const plateGeometry = new THREE.PlaneGeometry(buildVolume.x, buildVolume.y);
|
||||
@@ -390,6 +640,7 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, fil
|
||||
plate.rotation.x = -Math.PI / 2;
|
||||
plate.position.y = -0.5; // Slightly below Y=0 so models sit on top
|
||||
scene.add(plate);
|
||||
plateRef.current = plate;
|
||||
|
||||
// Animation loop - keep it simple for reliability
|
||||
let animationId: number;
|
||||
@@ -400,143 +651,51 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, fil
|
||||
};
|
||||
animate();
|
||||
|
||||
// Load 3MF
|
||||
fetch(url)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Failed to load file');
|
||||
return res.arrayBuffer();
|
||||
})
|
||||
.then(parse3MF)
|
||||
.then(({ objects, buildItems }) => {
|
||||
if (objects.size === 0) {
|
||||
throw new Error('No meshes found in 3MF file');
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setParsedData(null);
|
||||
setStlGeometry(null);
|
||||
|
||||
// Create materials for each extruder color
|
||||
const getMaterial = (extruder: number): THREE.MeshPhongMaterial => {
|
||||
const defaultColor = '#00ae42';
|
||||
const colorStr = filamentColors?.[extruder] || defaultColor;
|
||||
// Convert hex color string to THREE.js color
|
||||
const color = new THREE.Color(colorStr);
|
||||
return new THREE.MeshPhongMaterial({
|
||||
color,
|
||||
shininess: 30,
|
||||
flatShading: false,
|
||||
});
|
||||
};
|
||||
const normalizedType = (fileType || url.split('?')[0].split('.').pop() || '').toLowerCase();
|
||||
|
||||
const group = new THREE.Group();
|
||||
// Group geometries by extruder index (using per-mesh extruder)
|
||||
const geometriesByExtruder = new Map<number, THREE.BufferGeometry[]>();
|
||||
|
||||
// If we have build items, use them for positioning
|
||||
if (buildItems.length > 0) {
|
||||
for (const item of buildItems) {
|
||||
const objectData = objects.get(item.objectId);
|
||||
if (!objectData) continue;
|
||||
|
||||
for (const meshData of objectData.meshes) {
|
||||
// Use mesh's extruder, or item override, or object default
|
||||
const extruder = item.extruder ?? meshData.extruder;
|
||||
|
||||
// Apply build transform to vertices in 3MF space BEFORE coordinate conversion
|
||||
const transformedVertices: number[] = [];
|
||||
for (let k = 0; k < meshData.vertices.length; k += 3) {
|
||||
const v = new THREE.Vector3(
|
||||
meshData.vertices[k],
|
||||
meshData.vertices[k + 1],
|
||||
meshData.vertices[k + 2]
|
||||
);
|
||||
v.applyMatrix4(item.transform);
|
||||
transformedVertices.push(v.x, v.y, v.z);
|
||||
}
|
||||
// Now create geometry with coordinate conversion
|
||||
const geometry = createGeometryFromMesh({
|
||||
vertices: transformedVertices,
|
||||
triangles: meshData.triangles,
|
||||
extruder: extruder,
|
||||
});
|
||||
|
||||
if (!geometriesByExtruder.has(extruder)) {
|
||||
geometriesByExtruder.set(extruder, []);
|
||||
}
|
||||
geometriesByExtruder.get(extruder)!.push(geometry);
|
||||
}
|
||||
if (normalizedType === 'stl') {
|
||||
fetch(url)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Failed to load file');
|
||||
return res.arrayBuffer();
|
||||
})
|
||||
.then((buffer) => {
|
||||
const loader = new STLLoader();
|
||||
const geometry = loader.parse(buffer);
|
||||
geometry.computeVertexNormals();
|
||||
geometry.rotateX(-Math.PI / 2);
|
||||
setStlGeometry(geometry);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
} else if (normalizedType === '3mf') {
|
||||
fetch(url)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Failed to load file');
|
||||
return res.arrayBuffer();
|
||||
})
|
||||
.then(parse3MF)
|
||||
.then((parsed) => {
|
||||
if (parsed.objects.size === 0) {
|
||||
throw new Error('No meshes found in 3MF file');
|
||||
}
|
||||
} else {
|
||||
// Fallback: just add all objects without transforms
|
||||
for (const objectData of objects.values()) {
|
||||
for (const meshData of objectData.meshes) {
|
||||
// Use per-mesh extruder
|
||||
const extruder = meshData.extruder;
|
||||
const geometry = createGeometryFromMesh(meshData);
|
||||
if (!geometriesByExtruder.has(extruder)) {
|
||||
geometriesByExtruder.set(extruder, []);
|
||||
}
|
||||
geometriesByExtruder.get(extruder)!.push(geometry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create meshes for each extruder group
|
||||
for (const [extruder, geometries] of geometriesByExtruder) {
|
||||
if (geometries.length === 0) continue;
|
||||
|
||||
const mergedGeometry = geometries.length === 1
|
||||
? geometries[0]
|
||||
: mergeGeometries(geometries, false);
|
||||
|
||||
if (mergedGeometry) {
|
||||
const material = getMaterial(extruder);
|
||||
const mesh = new THREE.Mesh(mergedGeometry, material);
|
||||
group.add(mesh);
|
||||
}
|
||||
|
||||
// Dispose individual geometries if merged
|
||||
if (geometries.length > 1) {
|
||||
for (const geom of geometries) {
|
||||
geom.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get bounding box to position model
|
||||
const box = new THREE.Box3().setFromObject(group);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
|
||||
// Always place models on the build plate (Y=0)
|
||||
group.position.y = -box.min.y;
|
||||
|
||||
// For models without build transforms, also center X/Z
|
||||
if (buildItems.length === 0) {
|
||||
group.position.x = -center.x;
|
||||
group.position.z = -center.z;
|
||||
}
|
||||
|
||||
scene.add(group);
|
||||
|
||||
// Recalculate bounding box after positioning
|
||||
const finalBox = new THREE.Box3().setFromObject(group);
|
||||
const finalCenter = finalBox.getCenter(new THREE.Vector3());
|
||||
const finalSize = finalBox.getSize(new THREE.Vector3());
|
||||
|
||||
// Adjust camera to fit model
|
||||
const maxDim = Math.max(finalSize.x, finalSize.y, finalSize.z);
|
||||
const cameraDistance = maxDim * 1.8;
|
||||
camera.position.set(
|
||||
finalCenter.x + cameraDistance * 0.7,
|
||||
finalCenter.y + cameraDistance * 0.5,
|
||||
finalCenter.z + cameraDistance * 0.7
|
||||
);
|
||||
controls.target.copy(finalCenter);
|
||||
controls.update();
|
||||
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
setParsedData(parsed);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
setError('Unsupported file format');
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
// Handle resize
|
||||
const handleResize = () => {
|
||||
@@ -555,8 +714,104 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, fil
|
||||
controls.dispose();
|
||||
renderer.dispose();
|
||||
container.removeChild(renderer.domElement);
|
||||
modelGroupRef.current = null;
|
||||
plateRef.current = null;
|
||||
gridRef.current = null;
|
||||
};
|
||||
}, [url, buildVolume, filamentColors]);
|
||||
}, [url, buildVolume, fileType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sceneRef.current || !cameraRef.current || !controlsRef.current) return;
|
||||
if (!parsedData && !stlGeometry) return;
|
||||
|
||||
if (modelGroupRef.current) {
|
||||
sceneRef.current.remove(modelGroupRef.current);
|
||||
disposeGroup(modelGroupRef.current);
|
||||
}
|
||||
|
||||
const isStlModel = !!stlGeometry;
|
||||
const group = isStlModel
|
||||
? (() => {
|
||||
const materialColor = filamentColors?.[0] || '#00ae42';
|
||||
const material = new THREE.MeshPhongMaterial({ color: new THREE.Color(materialColor), shininess: 30 });
|
||||
const mesh = new THREE.Mesh(stlGeometry!, material);
|
||||
const stlGroup = new THREE.Group();
|
||||
stlGroup.add(mesh);
|
||||
return stlGroup;
|
||||
})()
|
||||
: buildModelGroup(parsedData!, selectedPlateId ?? null, filamentColors);
|
||||
modelGroupRef.current = group;
|
||||
sceneRef.current.add(group);
|
||||
|
||||
// Get bounding box to position model
|
||||
const box = new THREE.Box3().setFromObject(group);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
|
||||
// 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;
|
||||
|
||||
let plateOffsetX = 0;
|
||||
let plateOffsetZ = 0;
|
||||
if (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const plateCenterX = buildVolume.x / 2;
|
||||
const plateCenterZ = buildVolume.y / 2;
|
||||
|
||||
if (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0) {
|
||||
group.position.x = centerOffsetX - plateOffsetX;
|
||||
group.position.z = centerOffsetZ - plateOffsetZ;
|
||||
} else if (isStlModel) {
|
||||
group.position.x = centerOffsetX + plateCenterX;
|
||||
group.position.z = centerOffsetZ + plateCenterZ;
|
||||
} else {
|
||||
group.position.x = centerOffsetX;
|
||||
group.position.z = centerOffsetZ;
|
||||
}
|
||||
|
||||
if (plateRef.current) {
|
||||
plateRef.current.position.x = plateCenterX;
|
||||
plateRef.current.position.z = plateCenterZ;
|
||||
}
|
||||
|
||||
if (gridRef.current) {
|
||||
gridRef.current.position.x = plateCenterX;
|
||||
gridRef.current.position.z = plateCenterZ;
|
||||
}
|
||||
|
||||
// Recalculate bounding box after positioning
|
||||
const finalBox = new THREE.Box3().setFromObject(group);
|
||||
const finalCenter = finalBox.getCenter(new THREE.Vector3());
|
||||
const finalSize = finalBox.getSize(new THREE.Vector3());
|
||||
|
||||
// Adjust camera to fit model
|
||||
const maxDim = Math.max(finalSize.x, finalSize.y, finalSize.z);
|
||||
const cameraDistance = maxDim * 1.8;
|
||||
cameraRef.current.position.set(
|
||||
finalCenter.x + cameraDistance * 0.7,
|
||||
finalCenter.y + cameraDistance * 0.5,
|
||||
finalCenter.z + cameraDistance * 0.7
|
||||
);
|
||||
controlsRef.current.target.copy(finalCenter);
|
||||
controlsRef.current.update();
|
||||
|
||||
setLoading(false);
|
||||
}, [parsedData, stlGeometry, selectedPlateId, filamentColors, buildVolume]);
|
||||
|
||||
const resetView = () => {
|
||||
if (cameraRef.current && controlsRef.current) {
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, ExternalLink, Box, Code2, Loader2 } from 'lucide-react';
|
||||
import { X, ExternalLink, Box, Code2, Loader2, Layers, Check } from 'lucide-react';
|
||||
import { ModelViewer } from './ModelViewer';
|
||||
import { GcodeViewer } from './GcodeViewer';
|
||||
import { Button } from './Button';
|
||||
import { api } from '../api/client';
|
||||
import { openInSlicer } from '../utils/slicer';
|
||||
import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
|
||||
|
||||
type ViewTab = '3d' | 'gcode';
|
||||
|
||||
interface ModelViewerModalProps {
|
||||
archiveId: number;
|
||||
archiveId?: number;
|
||||
libraryFileId?: number;
|
||||
title: string;
|
||||
fileType?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -22,10 +25,14 @@ interface Capabilities {
|
||||
filament_colors: string[];
|
||||
}
|
||||
|
||||
export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModalProps) {
|
||||
export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose }: ModelViewerModalProps) {
|
||||
const isLibrary = libraryFileId != null;
|
||||
const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
|
||||
const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [platesData, setPlatesData] = useState<ArchivePlatesResponse | LibraryFilePlatesResponse | null>(null);
|
||||
const [platesLoading, setPlatesLoading] = useState(false);
|
||||
const [selectedPlateId, setSelectedPlateId] = useState<number | null>(null);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
@@ -37,6 +44,31 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
|
||||
if (isLibrary) {
|
||||
const normalizedType = (fileType || '').toLowerCase();
|
||||
const hasModel = normalizedType === '3mf' || normalizedType === 'stl';
|
||||
const hasGcode = normalizedType === 'gcode' || normalizedType === '3mf';
|
||||
setCapabilities({
|
||||
has_model: hasModel,
|
||||
has_gcode: hasGcode,
|
||||
has_source: false,
|
||||
build_volume: { x: 256, y: 256, z: 256 },
|
||||
filament_colors: [],
|
||||
});
|
||||
setActiveTab(hasModel ? '3d' : hasGcode ? 'gcode' : null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!archiveId) {
|
||||
setCapabilities(null);
|
||||
setActiveTab(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
api.getArchiveCapabilities(archiveId)
|
||||
.then(caps => {
|
||||
setCapabilities(caps);
|
||||
@@ -54,12 +86,56 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
|
||||
setActiveTab('3d');
|
||||
setLoading(false);
|
||||
});
|
||||
}, [archiveId]);
|
||||
}, [archiveId, fileType, isLibrary]);
|
||||
|
||||
useEffect(() => {
|
||||
setPlatesLoading(true);
|
||||
setSelectedPlateId(null);
|
||||
|
||||
if (isLibrary) {
|
||||
const normalizedType = (fileType || '').toLowerCase();
|
||||
if (!libraryFileId || normalizedType !== '3mf') {
|
||||
setPlatesData(null);
|
||||
setPlatesLoading(false);
|
||||
return;
|
||||
}
|
||||
api.getLibraryFilePlates(libraryFileId)
|
||||
.then((data) => setPlatesData(data))
|
||||
.catch(() => setPlatesData(null))
|
||||
.finally(() => setPlatesLoading(false));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!archiveId) {
|
||||
setPlatesData(null);
|
||||
setPlatesLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
api.getArchivePlates(archiveId)
|
||||
.then((data) => setPlatesData(data))
|
||||
.catch(() => setPlatesData(null))
|
||||
.finally(() => setPlatesLoading(false));
|
||||
}, [archiveId, fileType, isLibrary, libraryFileId]);
|
||||
|
||||
const plates = platesData?.plates ?? [];
|
||||
const hasMultiplePlates = (platesData?.is_multi_plate ?? false) && plates.length > 1;
|
||||
const selectedPlate: PlateMetadata | null = selectedPlateId == null
|
||||
? null
|
||||
: plates.find((plate) => plate.index === selectedPlateId) ?? null;
|
||||
|
||||
const canOpenInSlicer = isLibrary ? (fileType || '').toLowerCase() === '3mf' : true;
|
||||
|
||||
const handleOpenInSlicer = () => {
|
||||
if (!canOpenInSlicer) return;
|
||||
// URL must include .3mf filename for Bambu Studio to recognize the format
|
||||
const filename = title || 'model';
|
||||
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId, filename)}`;
|
||||
if (isLibrary) {
|
||||
const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
|
||||
openInSlicer(downloadUrl);
|
||||
return;
|
||||
}
|
||||
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
|
||||
openInSlicer(downloadUrl);
|
||||
};
|
||||
|
||||
@@ -76,7 +152,7 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
|
||||
<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-2">
|
||||
<Button variant="secondary" size="sm" onClick={handleOpenInSlicer}>
|
||||
<Button variant="secondary" size="sm" onClick={handleOpenInSlicer} disabled={!canOpenInSlicer}>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Open in Slicer
|
||||
</Button>
|
||||
@@ -129,17 +205,109 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
|
||||
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
|
||||
</div>
|
||||
) : activeTab === '3d' && capabilities ? (
|
||||
<ModelViewer
|
||||
url={capabilities.has_source
|
||||
? api.getSource3mfDownloadUrl(archiveId)
|
||||
: api.getArchiveDownload(archiveId)}
|
||||
buildVolume={capabilities.build_volume}
|
||||
filamentColors={capabilities.filament_colors}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
<div className="w-full h-full flex flex-col gap-3">
|
||||
{hasMultiplePlates && (
|
||||
<div className="rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3">
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<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" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</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">
|
||||
<ModelViewer
|
||||
url={isLibrary
|
||||
? api.getLibraryFileDownloadUrl(libraryFileId!)
|
||||
: (capabilities.has_source
|
||||
? api.getSource3mfDownloadUrl(archiveId!)
|
||||
: api.getArchiveDownload(archiveId!))}
|
||||
fileType={fileType}
|
||||
buildVolume={capabilities.build_volume}
|
||||
filamentColors={capabilities.filament_colors}
|
||||
selectedPlateId={selectedPlateId}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === 'gcode' && capabilities ? (
|
||||
<GcodeViewer
|
||||
gcodeUrl={api.getArchiveGcode(archiveId)}
|
||||
gcodeUrl={isLibrary ? api.getLibraryFileGcodeUrl(libraryFileId!) : api.getArchiveGcode(archiveId!)}
|
||||
filamentColors={capabilities.filament_colors}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useToast } from '../../contexts/ToastContext';
|
||||
import { useFilamentMapping } from '../../hooks/useFilamentMapping';
|
||||
import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
|
||||
import { isPlaceholderDate } from '../../utils/amsHelpers';
|
||||
import { toDateTimeLocalValue } from '../../utils/date';
|
||||
import { PrinterSelector } from './PrinterSelector';
|
||||
import { PlateSelector } from './PlateSelector';
|
||||
import { FilamentMapping } from './FilamentMapping';
|
||||
@@ -90,7 +91,8 @@ export function PrintModal({
|
||||
let scheduledTime = '';
|
||||
if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
|
||||
const date = new Date(queueItem.scheduled_time);
|
||||
scheduledTime = date.toISOString().slice(0, 16);
|
||||
// Use toDateTimeLocalValue to convert UTC to local time for datetime-local input
|
||||
scheduledTime = toDateTimeLocalValue(date);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -19,8 +19,9 @@ export function VirtualPrinterSettings() {
|
||||
const [localMode, setLocalMode] = useState<LocalMode>('immediate');
|
||||
const [localModel, setLocalModel] = useState('3DPrinter-X1-Carbon');
|
||||
const [localTargetPrinterId, setLocalTargetPrinterId] = useState<number | null>(null);
|
||||
const [localRemoteInterfaceIp, setLocalRemoteInterfaceIp] = useState('');
|
||||
const [showAccessCode, setShowAccessCode] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | 'targetPrinter' | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | 'targetPrinter' | 'remoteInterface' | null>(null);
|
||||
|
||||
// Fetch current settings
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
@@ -41,6 +42,13 @@ export function VirtualPrinterSettings() {
|
||||
queryFn: api.getPrinters,
|
||||
});
|
||||
|
||||
// Fetch network interfaces for SSDP proxy (only in proxy mode)
|
||||
const { data: networkInterfaces } = useQuery({
|
||||
queryKey: ['network-interfaces'],
|
||||
queryFn: () => api.getNetworkInterfaces().then(res => res.interfaces),
|
||||
enabled: localMode === 'proxy',
|
||||
});
|
||||
|
||||
// Initialize local state from settings
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
@@ -53,12 +61,13 @@ export function VirtualPrinterSettings() {
|
||||
setLocalMode(mode);
|
||||
setLocalModel(settings.model);
|
||||
setLocalTargetPrinterId(settings.target_printer_id);
|
||||
setLocalRemoteInterfaceIp(settings.remote_interface_ip || '');
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: LocalMode; model?: string; target_printer_id?: number }) =>
|
||||
mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: LocalMode; model?: string; target_printer_id?: number; remote_interface_ip?: string }) =>
|
||||
virtualPrinterApi.updateSettings(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['virtual-printer-settings'] });
|
||||
@@ -148,6 +157,12 @@ export function VirtualPrinterSettings() {
|
||||
updateMutation.mutate({ model });
|
||||
};
|
||||
|
||||
const handleRemoteInterfaceChange = (ip: string) => {
|
||||
setLocalRemoteInterfaceIp(ip);
|
||||
setPendingAction('remoteInterface');
|
||||
updateMutation.mutate({ remote_interface_ip: ip });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -350,6 +365,45 @@ export function VirtualPrinterSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remote Interface - only for proxy mode (SSDP proxy) */}
|
||||
{localMode === 'proxy' && (
|
||||
<div className="py-3 border-t border-bambu-dark-tertiary">
|
||||
<div className="text-white font-medium mb-2">{t('virtualPrinter.remoteInterface.title')}</div>
|
||||
<div className="text-sm text-bambu-gray mb-3">
|
||||
{localRemoteInterfaceIp ? (
|
||||
<span className="flex items-center gap-1 text-green-400">
|
||||
<Check className="w-4 h-4" />
|
||||
{t('virtualPrinter.remoteInterface.configured')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-bambu-gray">
|
||||
<Info className="w-4 h-4" />
|
||||
{t('virtualPrinter.remoteInterface.optional')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={localRemoteInterfaceIp}
|
||||
onChange={(e) => handleRemoteInterfaceChange(e.target.value)}
|
||||
disabled={pendingAction === 'remoteInterface'}
|
||||
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white appearance-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed pr-10"
|
||||
>
|
||||
<option value="">{t('virtualPrinter.remoteInterface.placeholder')}</option>
|
||||
{networkInterfaces?.map((iface) => (
|
||||
<option key={iface.ip} value={iface.ip}>
|
||||
{iface.name} ({iface.ip}) - {iface.subnet}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray mt-2">
|
||||
{t('virtualPrinter.remoteInterface.hint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mode */}
|
||||
<div className="py-3 border-t border-bambu-dark-tertiary">
|
||||
<div className="text-white font-medium mb-2">{t('virtualPrinter.mode.title')}</div>
|
||||
@@ -504,11 +558,11 @@ export function VirtualPrinterSettings() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-bambu-gray">{t('virtualPrinter.status.ftpConnections')}</div>
|
||||
<div className="text-white">{status.proxy.ftp_connections}</div>
|
||||
<div className="text-white">{status.proxy.ftp_connections ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-bambu-gray">{t('virtualPrinter.status.mqttConnections')}</div>
|
||||
<div className="text-white">{status.proxy.mqtt_connections}</div>
|
||||
<div className="text-white">{status.proxy.mqtt_connections ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user