Merge branch '0.1.8b' into cadtoolbox/248

This commit is contained in:
MartinNYHC
2026-02-04 14:53:05 +01:00
committed by GitHub
48 changed files with 12714 additions and 413 deletions
+25 -2
View File
@@ -95,11 +95,11 @@ describe('API Client Auth Header', () => {
expect(capturedHeaders!.get('Authorization')).toBeNull();
});
it('clears token on 401 Unauthorized response', async () => {
it('clears token on 401 with invalid token message', async () => {
server.use(
http.get('/api/v1/settings/spoolman', () => {
return HttpResponse.json(
{ detail: 'Not authenticated' },
{ detail: 'Could not validate credentials' },
{ status: 401 }
);
})
@@ -117,6 +117,29 @@ describe('API Client Auth Header', () => {
expect(getAuthToken()).toBeNull();
expect(localStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
});
it('does not clear token on 401 with generic auth error', async () => {
server.use(
http.get('/api/v1/settings/spoolman', () => {
return HttpResponse.json(
{ detail: 'Authentication required' },
{ status: 401 }
);
})
);
setAuthToken('valid-token');
expect(getAuthToken()).toBe('valid-token');
try {
await api.getSpoolmanSettings();
} catch {
// Expected to throw
}
// Token should NOT be cleared for generic auth errors (might be timing issue)
expect(getAuthToken()).toBe('valid-token');
});
});
describe('FormData requests include auth header', () => {
@@ -43,6 +43,7 @@ const createMockSettings = (overrides = {}) => ({
mode: 'immediate' as const,
model: '3DPrinter-X1-Carbon',
target_printer_id: null as number | null,
remote_interface_ip: null as string | null,
status: {
enabled: false,
running: false,
@@ -515,7 +516,7 @@ describe('VirtualPrinterSettings', () => {
proxy: {
running: true,
target_host: '192.168.1.100',
ftp_port: 9990,
ftp_port: 990, // Privileged port for Bambu Studio compatibility
mqtt_port: 8883,
ftp_connections: 1,
mqtt_connections: 2,
@@ -0,0 +1,349 @@
/**
* Tests for the useFilamentMapping hook and helper functions.
*
* Tests the tray_info_idx matching logic that ensures the exact spool
* selected during slicing is used when multiple trays have identical filament.
*/
import { describe, it, expect } from 'vitest';
import {
buildLoadedFilaments,
computeAmsMapping,
} from '../../hooks/useFilamentMapping';
import type { PrinterStatus } from '../../api/client';
// Helper to create a minimal printer status with AMS data
function createPrinterStatus(ams: PrinterStatus['ams'], vt_tray?: PrinterStatus['vt_tray']): PrinterStatus {
return {
ams,
vt_tray,
} as PrinterStatus;
}
describe('buildLoadedFilaments', () => {
it('returns empty array for undefined status', () => {
const result = buildLoadedFilaments(undefined);
expect(result).toEqual([]);
});
it('extracts filaments from AMS units', () => {
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },
{ id: 1, tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFA01' },
],
},
]);
const result = buildLoadedFilaments(status);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
type: 'PLA',
color: '#FF0000',
amsId: 0,
trayId: 0,
globalTrayId: 0,
trayInfoIdx: 'GFA00',
});
expect(result[1]).toMatchObject({
type: 'PETG',
color: '#00FF00',
globalTrayId: 1,
trayInfoIdx: 'GFA01',
});
});
it('includes tray_info_idx from AMS trays', () => {
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'P4d64437' },
],
},
]);
const result = buildLoadedFilaments(status);
expect(result[0].trayInfoIdx).toBe('P4d64437');
});
it('handles missing tray_info_idx', () => {
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, // No tray_info_idx
],
},
]);
const result = buildLoadedFilaments(status);
expect(result[0].trayInfoIdx).toBe('');
});
it('extracts external spool with tray_info_idx', () => {
const status = createPrinterStatus(
[],
{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }
);
const result = buildLoadedFilaments(status);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
type: 'TPU',
isExternal: true,
globalTrayId: 254,
trayInfoIdx: 'EXT001',
});
});
it('skips empty trays', () => {
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },
{ id: 1, tray_type: '', tray_color: '' }, // Empty tray
{ id: 2 }, // No tray_type
],
},
]);
const result = buildLoadedFilaments(status);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('PLA');
});
it('marks AMS-HT units correctly', () => {
const status = createPrinterStatus([
{
id: 128, // AMS-HT typically has high ID
tray: [
{ id: 0, tray_type: 'PLA-CF', tray_color: '000000', tray_info_idx: 'HT001' },
], // Single tray = AMS-HT
},
]);
const result = buildLoadedFilaments(status);
expect(result[0].isHt).toBe(true);
expect(result[0].globalTrayId).toBe(512); // 128 * 4 + 0
});
});
describe('computeAmsMapping', () => {
it('returns undefined for empty filament requirements', () => {
const status = createPrinterStatus([
{
id: 0,
tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
},
]);
expect(computeAmsMapping(undefined, status)).toBeUndefined();
expect(computeAmsMapping({ filaments: [] }, status)).toBeUndefined();
});
it('returns undefined when no filaments loaded', () => {
const reqs = {
filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
};
expect(computeAmsMapping(reqs, undefined)).toBeUndefined();
expect(computeAmsMapping(reqs, createPrinterStatus([]))).toBeUndefined();
});
it('matches by tray_info_idx with highest priority', () => {
const reqs = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA01' },
],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' }, // Same color, wrong idx
{ id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' }, // Exact idx match
{ id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' }, // Same color, wrong idx
],
},
]);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([1]); // Should pick tray 1, not tray 0
});
it('matches multiple identical filaments by tray_info_idx (H2D Pro scenario)', () => {
// This is the exact scenario from issue #245 - multiple black PLA spools
const reqs = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#000000', used_grams: 50, tray_info_idx: 'GFA03' },
],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },
{ id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },
{ id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },
{ id: 3, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA03' }, // This one
],
},
]);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([3]); // Should pick tray 3, not tray 0
});
it('falls back to color match when tray_info_idx is empty', () => {
const reqs = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: '' },
],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: '00FF00', tray_info_idx: 'GFA00' }, // Wrong color
{ id: 1, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA01' }, // Color match
],
},
]);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([1]);
});
it('falls back to color match when tray_info_idx does not match', () => {
const reqs = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: 'OLD_SPOOL' },
],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'NEW_SPOOL' }, // Different idx, same color
],
},
]);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([0]); // Falls back to color match
});
it('matches by type only when color differs', () => {
const reqs = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },
],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: '0000FF' }, // Same type, different color
],
},
]);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([0]); // Type-only match
});
it('returns -1 for unmatched slots', () => {
const reqs = {
filaments: [
{ slot_id: 1, type: 'TPU', color: '#FF0000', used_grams: 10 }, // No TPU loaded
],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' },
],
},
]);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([-1]);
});
it('avoids duplicate tray assignment', () => {
const reqs = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },
{ slot_id: 2, type: 'PLA', color: '#FF0000', used_grams: 10 }, // Same requirements
],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, // Only one PLA
],
},
]);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([0, -1]); // First slot gets the match, second is unmatched
});
it('handles multi-slot mapping with tray_info_idx', () => {
const reqs = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA00' },
{ slot_id: 2, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA02' },
],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },
{ id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },
{ id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },
],
},
]);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([0, 2]); // Each slot gets its specific tray
});
it('handles external spool matching', () => {
const reqs = {
filaments: [
{ slot_id: 1, type: 'TPU', color: '#0000FF', used_grams: 10, tray_info_idx: 'EXT001' },
],
};
const status = createPrinterStatus(
[],
{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }
);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([254]); // External spool global ID
});
});
+8 -2
View File
@@ -383,8 +383,14 @@ export const handlers = [
// Archives
// ========================================================================
http.get('/api/v1/archives/:id/plates', () => {
return HttpResponse.json([]);
http.get('/api/v1/archives/:id/plates', ({ params }) => {
const archiveId = Number(params.id);
return HttpResponse.json({
archive_id: Number.isFinite(archiveId) ? archiveId : 0,
filename: 'sample.3mf',
plates: [],
is_multi_plate: false,
});
}),
http.get('/api/v1/archives/:id/filament-requirements', () => {
@@ -84,8 +84,14 @@ describe('ArchivesPage', () => {
http.get('/api/v1/archives/tags', () => {
return HttpResponse.json(['test', 'calibration', 'functional']);
}),
http.get('/api/v1/archives/:id/plates', () => {
return HttpResponse.json([]);
http.get('/api/v1/archives/:id/plates', ({ params }) => {
const archiveId = Number(params.id);
return HttpResponse.json({
archive_id: Number.isFinite(archiveId) ? archiveId : 0,
filename: 'sample.3mf',
plates: [],
is_multi_plate: false,
});
}),
http.get('/api/v1/archives/:id/filament-requirements', () => {
return HttpResponse.json([]);
+61 -51
View File
@@ -1,3 +1,5 @@
import type { ArchivePlatesResponse, LibraryFilePlatesResponse } from '../types/plates';
const API_BASE = '/api/v1';
// Auth token storage
@@ -37,16 +39,27 @@ async function request<T>(
});
if (!response.ok) {
// Handle 401 Unauthorized - clear token and redirect to login
if (response.status === 401) {
setAuthToken(null);
// Don't throw here - let the auth context handle redirect
}
const error = await response.json().catch(() => ({}));
const detail = error.detail;
const message = typeof detail === 'string'
? detail
: (detail ? JSON.stringify(detail) : `HTTP ${response.status}`);
// Handle 401 Unauthorized - only clear token if it's actually invalid
// Don't clear on "Authentication required" which might be a timing issue
if (response.status === 401) {
const invalidTokenMessages = [
'Could not validate credentials',
'Token has expired',
'User not found or inactive',
'Invalid API key',
'API key has expired',
];
if (invalidTokenMessages.some(m => message.includes(m))) {
setAuthToken(null);
}
}
throw new Error(message);
}
@@ -2063,6 +2076,33 @@ export const api = {
}>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`),
getPrinterFileDownloadUrl: (printerId: number, path: string) =>
`${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,
getPrinterFileGcodeUrl: (printerId: number, path: string) =>
`${API_BASE}/printers/${printerId}/files/gcode?path=${encodeURIComponent(path)}`,
getPrinterFilePlates: (printerId: number, path: string) =>
request<{
printer_id: number;
path: string;
filename: string;
plates: Array<{
index: number;
name: string | null;
objects: string[];
has_thumbnail: boolean;
thumbnail_url: string | null;
print_time_seconds: number | null;
filament_used_grams: number | null;
filaments: Array<{
slot_id: number;
type: string;
color: string;
used_grams: number;
used_meters: number;
}>;
}>;
is_multi_plate: boolean;
}>(`/printers/${printerId}/files/plates?path=${encodeURIComponent(path)}`),
getPrinterFilePlateThumbnail: (printerId: number, plateIndex: number, path: string) =>
`${API_BASE}/printers/${printerId}/files/plate-thumbnail/${plateIndex}?path=${encodeURIComponent(path)}`,
downloadPrinterFile: async (printerId: number, path: string): Promise<void> => {
const headers: Record<string, string> = {};
if (authToken) {
@@ -2557,27 +2597,7 @@ export const api = {
getArchiveForSlicer: (id: number, filename: string) =>
`${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
getArchivePlates: (archiveId: number) =>
request<{
archive_id: number;
filename: string;
plates: Array<{
index: number;
name: string | null;
objects: string[];
has_thumbnail: boolean;
thumbnail_url: string | null;
print_time_seconds: number | null;
filament_used_grams: number | null;
filaments: Array<{
slot_id: number;
type: string;
color: string;
used_grams: number;
used_meters: number;
}>;
}>;
is_multi_plate: boolean;
}>(`/archives/${archiveId}/plates`),
request<ArchivePlatesResponse>(`/archives/${archiveId}/plates`),
getArchiveFilamentRequirements: (archiveId: number, plateId?: number) =>
request<{
archive_id: number;
@@ -2713,6 +2733,8 @@ export const api = {
},
checkFfmpeg: () =>
request<{ installed: boolean; path: string | null }>('/settings/check-ffmpeg'),
getNetworkInterfaces: () =>
request<{ interfaces: NetworkInterface[] }>('/settings/network-interfaces'),
// Cloud
getCloudStatus: () => request<CloudAuthStatus>('/cloud/status'),
@@ -2794,13 +2816,11 @@ export const api = {
// Tasmota Discovery (auto-detects network)
startTasmotaScan: () =>
fetch(`${API_BASE}/smart-plugs/discover/scan`, { method: 'POST' })
.then(res => res.ok ? res.json() : res.json().then(e => { throw new Error(e.detail || `HTTP ${res.status}`); })),
request<TasmotaScanStatus>('/smart-plugs/discover/scan', { method: 'POST' }),
getTasmotaScanStatus: () =>
request<TasmotaScanStatus>('/smart-plugs/discover/status'),
stopTasmotaScan: () =>
fetch(`${API_BASE}/smart-plugs/discover/stop`, { method: 'POST' })
.then(res => res.ok ? res.json() : res.json().then(e => { throw new Error(e.detail || `HTTP ${res.status}`); })),
request<TasmotaScanStatus>('/smart-plugs/discover/stop', { method: 'POST' }),
getDiscoveredTasmotaDevices: () =>
request<DiscoveredTasmotaDevice[]>('/smart-plugs/discover/devices'),
@@ -3565,27 +3585,7 @@ export const api = {
}
),
getLibraryFilePlates: (fileId: number) =>
request<{
file_id: number;
filename: string;
plates: Array<{
index: number;
name: string | null;
objects: string[];
has_thumbnail: boolean;
thumbnail_url: string | null;
print_time_seconds: number | null;
filament_used_grams: number | null;
filaments: Array<{
slot_id: number;
type: string;
color: string;
used_grams: number;
used_meters: number;
}>;
}>;
is_multi_plate: boolean;
}>(`/library/files/${fileId}/plates`),
request<LibraryFilePlatesResponse>(`/library/files/${fileId}/plates`),
getLibraryFileFilamentRequirements: (fileId: number, plateId?: number) =>
request<{
file_id: number;
@@ -3992,9 +3992,17 @@ export interface VirtualPrinterSettings {
mode: VirtualPrinterMode;
model: string;
target_printer_id: number | null; // For proxy mode
remote_interface_ip: string | null; // For SSDP proxy across networks
status: VirtualPrinterStatus;
}
export interface NetworkInterface {
name: string;
ip: string;
netmask: string;
subnet: string;
}
export interface VirtualPrinterModels {
models: Record<string, string>; // SSDP code -> display name
default: string;
@@ -4024,6 +4032,7 @@ export const virtualPrinterApi = {
mode?: 'immediate' | 'review' | 'print_queue' | 'proxy';
model?: string;
target_printer_id?: number;
remote_interface_ip?: string;
}) => {
const params = new URLSearchParams();
if (data.enabled !== undefined) params.set('enabled', String(data.enabled));
@@ -4031,6 +4040,7 @@ export const virtualPrinterApi = {
if (data.mode !== undefined) params.set('mode', data.mode);
if (data.model !== undefined) params.set('model', data.model);
if (data.target_printer_id !== undefined) params.set('target_printer_id', String(data.target_printer_id));
if (data.remote_interface_ip !== undefined) params.set('remote_interface_ip', data.remote_interface_ip);
return request<VirtualPrinterSettings>(`/settings/virtual-printer?${params.toString()}`, {
method: 'PUT',
@@ -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 {
+2 -2
View File
@@ -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);
+236 -3
View File
@@ -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>
);
}
+399 -144
View File
@@ -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) {
+183 -15
View File
@@ -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"
/>
+3 -1
View File
@@ -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>
) : (
+136 -45
View File
@@ -32,6 +32,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
isExternal: false,
label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
trayInfoIdx: tray.tray_info_idx || '',
});
}
});
@@ -50,6 +51,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
isExternal: true,
label: 'External',
globalTrayId: 254,
trayInfoIdx: printerStatus.vt_tray.tray_info_idx || '',
});
}
@@ -60,6 +62,14 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
* Compute AMS mapping for a printer given filament requirements and printer status.
* This is a non-hook version that can be called imperatively (e.g., in a loop for multiple printers).
*
* Priority: unique tray_info_idx match > exact color match > similar color match > type-only match
*
* The tray_info_idx is a filament type identifier stored in the 3MF file when the user
* slices (e.g., "GFA00" for generic PLA, "P4d64437" for custom presets). If the same
* tray_info_idx appears in only ONE available tray, we use that tray. If multiple trays
* have the same tray_info_idx (e.g., two spools of generic PLA), we fall back to color
* matching among those trays.
*
* @param filamentReqs - Required filaments from the 3MF file
* @param printerStatus - Current printer status with AMS information
* @returns AMS mapping array or undefined if no mapping needed
@@ -77,30 +87,66 @@ export function computeAmsMapping(
const usedTrayIds = new Set<number>();
const comparisons = filamentReqs.filaments.map((req) => {
// Auto-match: Find a loaded filament that matches by TYPE
// Priority: exact color match > similar color match > type-only match
const exactMatch = loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const similarMatch =
!exactMatch &&
loadedFilaments.find(
const reqTrayInfoIdx = req.tray_info_idx || '';
// Get available trays (not already used)
const available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
let idxMatch: LoadedFilament | undefined;
let exactMatch: LoadedFilament | undefined;
let similarMatch: LoadedFilament | undefined;
let typeOnlyMatch: LoadedFilament | undefined;
// Check if tray_info_idx is unique among available trays
if (reqTrayInfoIdx) {
const idxMatches = available.filter((f) => f.trayInfoIdx === reqTrayInfoIdx);
if (idxMatches.length === 1) {
// Unique tray_info_idx - use it as definitive match
idxMatch = idxMatches[0];
} else if (idxMatches.length > 1) {
// Multiple trays with same tray_info_idx - use color matching among them
exactMatch = idxMatches.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
if (!exactMatch) {
similarMatch = idxMatches.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
}
if (!exactMatch && !similarMatch) {
typeOnlyMatch = idxMatches.find(
(f) => f.type?.toUpperCase() === req.type?.toUpperCase()
);
}
}
}
// If no idx match, do standard type/color matching on all available trays
if (!idxMatch && !exactMatch && !similarMatch && !typeOnlyMatch) {
exactMatch = available.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const typeOnlyMatch =
!exactMatch &&
!similarMatch &&
loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
);
const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
if (!exactMatch) {
similarMatch = available.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
}
if (!exactMatch && !similarMatch) {
typeOnlyMatch = available.find(
(f) => f.type?.toUpperCase() === req.type?.toUpperCase()
);
}
}
const loaded = idxMatch || exactMatch || similarMatch || typeOnlyMatch || undefined;
// Mark this tray as used so it won't be assigned to another slot
if (loaded) {
@@ -143,6 +189,8 @@ export interface LoadedFilament {
isExternal: boolean;
label: string;
globalTrayId: number;
/** Unique spool identifier (e.g., "GFA00", "P4d64437") */
trayInfoIdx?: string;
}
/**
@@ -153,6 +201,8 @@ export interface FilamentRequirement {
type: string;
color: string;
used_grams: number;
/** Unique spool identifier from slicing (e.g., "GFA00", "P4d64437") */
tray_info_idx?: string;
}
/**
@@ -215,6 +265,7 @@ export function useLoadedFilaments(
isExternal: false,
label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
trayInfoIdx: tray.tray_info_idx || '',
});
}
});
@@ -233,6 +284,7 @@ export function useLoadedFilaments(
isExternal: true,
label: 'External',
globalTrayId: 254,
trayInfoIdx: printerStatus.vt_tray.tray_info_idx || '',
});
}
@@ -297,31 +349,69 @@ export function useFilamentMapping(
}
}
// Auto-match: Find a loaded filament that matches by TYPE
// Priority: exact color match > similar color match > type-only match
// Auto-match: Find a loaded filament
// Priority: unique tray_info_idx match > exact color match > similar color match > type-only match
// IMPORTANT: Exclude trays that are already assigned (manually or auto)
const exactMatch = loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const similarMatch =
!exactMatch &&
loadedFilaments.find(
const reqTrayInfoIdx = req.tray_info_idx || '';
// Get available trays (not already used)
const available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
let idxMatch: LoadedFilament | undefined;
let exactMatch: LoadedFilament | undefined;
let similarMatch: LoadedFilament | undefined;
let typeOnlyMatch: LoadedFilament | undefined;
// Check if tray_info_idx is unique among available trays
if (reqTrayInfoIdx) {
const idxMatches = available.filter((f) => f.trayInfoIdx === reqTrayInfoIdx);
if (idxMatches.length === 1) {
// Unique tray_info_idx - use it as definitive match
idxMatch = idxMatches[0];
} else if (idxMatches.length > 1) {
// Multiple trays with same tray_info_idx - use color matching among them
exactMatch = idxMatches.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
if (!exactMatch) {
similarMatch = idxMatches.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
}
if (!exactMatch && !similarMatch) {
typeOnlyMatch = idxMatches.find(
(f) => f.type?.toUpperCase() === req.type?.toUpperCase()
);
}
}
}
// If no idx match, do standard type/color matching on all available trays
if (!idxMatch && !exactMatch && !similarMatch && !typeOnlyMatch) {
exactMatch = available.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const typeOnlyMatch =
!exactMatch &&
!similarMatch &&
loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
);
const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
if (!exactMatch) {
similarMatch = available.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
}
if (!exactMatch && !similarMatch) {
typeOnlyMatch = available.find(
(f) => f.type?.toUpperCase() === req.type?.toUpperCase()
);
}
}
const loaded = idxMatch || exactMatch || similarMatch || typeOnlyMatch || undefined;
// Mark this tray as used so it won't be assigned to another slot
if (loaded) {
@@ -330,11 +420,12 @@ export function useFilamentMapping(
const hasFilament = !!loaded;
const typeMatch = hasFilament;
const colorMatch = !!exactMatch || !!similarMatch;
// idxMatch is always considered a color match (same spool = same color)
const colorMatch = !!idxMatch || !!exactMatch || !!similarMatch;
// Status: match (type+color or similar), type_only (type ok, color very different), mismatch (type not found)
// Status: match (tray_info_idx, type+color, or similar color), type_only (type ok, color very different), mismatch (type not found)
let status: FilamentStatus;
if (exactMatch || similarMatch) {
if (idxMatch || exactMatch || similarMatch) {
status = 'match';
} else if (typeOnlyMatch) {
status = 'type_only';
+14 -5
View File
@@ -687,6 +687,7 @@ export default {
pending: 'Ausstehend',
waiting: 'Wartend',
printing: 'Druckt',
paused: 'Pausiert',
completed: 'Abgeschlossen',
failed: 'Fehlgeschlagen',
skipped: 'Übersprungen',
@@ -2480,6 +2481,13 @@ export default {
hint: 'Wähle den Drucker aus, an den der Slicer-Datenverkehr weitergeleitet werden soll. Der Drucker muss im LAN-Modus sein.',
noPrinters: 'Keine Drucker konfiguriert. Füge zuerst einen Drucker hinzu, um den Proxy-Modus zu verwenden.',
},
remoteInterface: {
title: 'Slicer-Netzwerkschnittstelle',
configured: 'SSDP-Proxy aktiviert',
optional: 'Optional - für SSDP-Erkennung über Netzwerke hinweg',
placeholder: 'Schnittstelle für Slicer-Netzwerk auswählen...',
hint: 'Wähle die Netzwerkschnittstelle, die mit dem Slicer verbunden ist. Ermöglicht automatische Druckererkennung in Bambu Studio.',
},
mode: {
title: 'Modus',
archive: 'Archivieren',
@@ -2505,11 +2513,12 @@ export default {
step4: 'Der "Bambuddy"-Drucker sollte in der Erkennungsliste erscheinen',
step5: 'Verbinde mit dem von dir gesetzten Zugangscode',
step6: 'Wenn du zu Bambuddy "druckst", wird die 3MF-Datei stattdessen archiviert',
proxyStep1: 'Setze die Zieldrucker-IP (muss in deinem LAN sein)',
proxyStep2: 'Konfiguriere Portweiterleitung zu Bambuddy (Ports 9990, 8883)',
proxyStep3: 'Füge in deinem Slicer manuell einen Netzwerkdrucker hinzu',
proxyStep4: 'Gib Bambuddys externe Adresse und den Zugangscode des Druckers ein',
proxyStep5: 'Drucke wie gewohnt - der Datenverkehr wird an den echten Drucker weitergeleitet',
proxyStep1: 'Wähle den Zieldrucker (muss im LAN-Modus sein)',
proxyStep2: 'Bei Netzwerkübergreifend: Wähle die Slicer-Netzwerkschnittstelle',
proxyStep3: 'Aktiviere den Proxy - Drucker erscheint per SSDP in der Slicer-Erkennung',
proxyStep4: 'Verbinde mit dem Zugangscode des Druckers',
proxyStep5: 'Drucke wie gewohnt - der Datenverkehr wird über Bambuddy weitergeleitet',
proxyStep6: 'Kamera-Streaming erfordert NAT/IP-Weiterleitung (siehe Dokumentation)',
},
status: {
title: 'Status-Details',
+14 -5
View File
@@ -687,6 +687,7 @@ export default {
pending: 'Pending',
waiting: 'Waiting',
printing: 'Printing',
paused: 'Paused',
completed: 'Completed',
failed: 'Failed',
skipped: 'Skipped',
@@ -2480,6 +2481,13 @@ export default {
hint: 'Select the printer to proxy slicer traffic to. The printer must be in LAN mode.',
noPrinters: 'No printers configured. Add a printer first to use proxy mode.',
},
remoteInterface: {
title: 'Slicer Network Interface',
configured: 'SSDP proxy enabled',
optional: 'Optional - for SSDP discovery across networks',
placeholder: 'Select interface for slicer network...',
hint: 'Select the network interface connected to the slicer. Enables automatic printer discovery in Bambu Studio.',
},
mode: {
title: 'Mode',
archive: 'Archive',
@@ -2505,11 +2513,12 @@ export default {
step4: 'The "Bambuddy" printer should appear in the discovery list',
step5: 'Connect using the access code you set',
step6: 'When you "print" to Bambuddy, the 3MF file is archived instead',
proxyStep1: 'Set the target printer IP (must be on your LAN)',
proxyStep2: 'Configure port forwarding to Bambuddy (ports 9990, 8883)',
proxyStep3: 'In your slicer, manually add a network printer',
proxyStep4: "Enter Bambuddy's external address and printer's access code",
proxyStep5: 'Print as normal - traffic is relayed to the real printer',
proxyStep1: 'Select the target printer (must be in LAN mode)',
proxyStep2: 'For cross-network: select the slicer network interface',
proxyStep3: 'Enable the proxy - printer appears in slicer discovery via SSDP',
proxyStep4: 'Connect using the printer\'s access code',
proxyStep5: 'Print as normal - traffic is relayed through Bambuddy',
proxyStep6: 'Camera streaming requires NAT/IP forwarding (see docs)',
},
status: {
title: 'Status Details',
+30
View File
@@ -549,6 +549,7 @@ export default {
status: {
pending: '待機中',
printing: '印刷中',
paused: '一時停止',
completed: '完了',
failed: '失敗',
skipped: 'スキップ',
@@ -1473,6 +1474,35 @@ export default {
model: 'モデル',
serialNumber: 'シリアルナンバー',
pendingFiles: '保留中のファイル',
modeProxy: 'プロキシ',
modeProxyDesc: '実際のプリンターに中継',
descriptionProxy: 'スライサーのトラフィックを実際のプリンターに中継するプロキシを有効にし、任意のネットワーク経由でリモート印刷を可能にします。',
proxyingTo: '{{name}}にプロキシ中',
notActive: '非アクティブ',
targetPrinterTitle: 'ターゲットプリンター',
targetPrinterConfigured: 'プロキシターゲット設定済み',
targetPrinterNotConfigured: 'ターゲットプリンター未選択 - プロキシモードに必要です',
targetPrinterPlaceholder: 'プリンターを選択...',
targetPrinterHint: 'スライサーのトラフィックを中継するプリンターを選択します。プリンターはLANモードである必要があります。',
targetPrinterNoPrinters: 'プリンターが設定されていません。プロキシモードを使用するには、まずプリンターを追加してください。',
remoteInterfaceTitle: 'スライサーネットワークインターフェース',
remoteInterfaceConfigured: 'SSDPプロキシ有効',
remoteInterfaceOptional: 'オプション - ネットワーク間のSSDPディスカバリー用',
remoteInterfacePlaceholder: 'スライサーネットワークのインターフェースを選択...',
remoteInterfaceHint: 'スライサーに接続されているネットワークインターフェースを選択します。Bambu Studioでの自動プリンター検出を有効にします。',
targetPrinterRequired: '先にターゲットプリンターを選択してください',
ftpPort: 'FTPポート',
mqttPort: 'MQTTポート',
ftpConnections: 'FTP接続数',
mqttConnections: 'MQTT接続数',
targetPrinterStatus: 'ターゲットプリンター',
howItWorksProxy: '仕組み(プロキシモード)',
proxyStep1: 'ターゲットプリンターを選択(LANモードである必要があります)',
proxyStep2: 'クロスネットワーク時:スライサーネットワークインターフェースを選択',
proxyStep3: 'プロキシを有効化 - プリンターがSSDPでスライサー検出に表示されます',
proxyStep4: 'プリンターのアクセスコードで接続',
proxyStep5: '通常通り印刷 - トラフィックはBambuddyを経由して中継されます',
proxyStep6: 'カメラストリーミングにはNAT/IP転送が必要です(ドキュメント参照)',
},
enterNewCodeToChange: '新しいコードを入力して変更',
enter8CharCode: '8文字のコードを入力',
+13 -2
View File
@@ -99,6 +99,15 @@ function isSlicedFile(filename: string | null | undefined): boolean {
return lower.endsWith('.gcode') || lower.includes('.gcode.');
}
function getArchiveFileType(filename: string | null | undefined): string | undefined {
if (!filename) return undefined;
const lower = filename.toLowerCase();
if (lower.endsWith('.3mf')) return '3mf';
if (lower.endsWith('.stl')) return 'stl';
if (lower.endsWith('.gcode') || lower.includes('.gcode.')) return 'gcode';
return lower.split('.').pop();
}
// formatDate imported from '../utils/date' - handles UTC conversion
function ArchiveCard({
@@ -1040,6 +1049,7 @@ function ArchiveCard({
<ModelViewerModal
archiveId={archive.id}
title={archive.print_name || archive.filename}
fileType={getArchiveFileType(archive.filename)}
onClose={() => setShowViewer(false)}
/>
)}
@@ -1833,6 +1843,7 @@ function ArchiveListRow({
<ModelViewerModal
archiveId={archive.id}
title={archive.print_name || archive.filename}
fileType={getArchiveFileType(archive.filename)}
onClose={() => setShowViewer(false)}
/>
)}
@@ -2522,7 +2533,7 @@ export function ArchivesPage() {
</div>
)}
<div className="flex items-center justify-between mb-8">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-white">Archives</h1>
@@ -2542,7 +2553,7 @@ export function ArchivesPage() {
{filteredArchives?.length || 0} of {archives?.length || 0} prints
</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 sm:gap-3 flex-wrap">
{/* Export dropdown */}
<div className="relative">
<Button
+37
View File
@@ -52,6 +52,7 @@ import type {
import { Button } from '../components/Button';
import { ConfirmModal } from '../components/ConfirmModal';
import { PrintModal } from '../components/PrintModal';
import { ModelViewerModal } from '../components/ModelViewerModal';
import { useToast } from '../contexts/ToastContext';
import { useIsMobile } from '../hooks/useIsMobile';
import { useAuth } from '../contexts/AuthContext';
@@ -907,6 +908,7 @@ interface FileCardProps {
onDownload: (id: number) => void;
onAddToQueue?: (id: number) => void;
onPrint?: (file: LibraryFileListItem) => void;
onPreview3d?: (file: LibraryFileListItem) => void;
onRename?: (file: LibraryFileListItem) => void;
onGenerateThumbnail?: (file: LibraryFileListItem) => void;
thumbnailVersion?: number;
@@ -1021,6 +1023,19 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
{t('fileManager.schedulePrint')}
</button>
)}
{onPreview3d && (file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl') && (
<button
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
hasPermission('library:read') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
}`}
onClick={() => { if (hasPermission('library:read')) { onPreview3d(file); setShowActions(false); } }}
disabled={!hasPermission('library:read')}
title={!hasPermission('library:read') ? 'You do not have permission to preview files' : undefined}
>
<Box className="w-3.5 h-3.5" />
3D Preview
</button>
)}
<button
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
hasPermission('library:read') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
@@ -1110,6 +1125,7 @@ export function FileManagerPage() {
const [scheduleFile, setScheduleFile] = useState<LibraryFileListItem | null>(null);
const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
const [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
const [viewerFile, setViewerFile] = useState<LibraryFileListItem | null>(null);
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
});
@@ -1993,6 +2009,7 @@ export function FileManagerPage() {
if (file) setScheduleFile(file);
}}
onPrint={setPrintFile}
onPreview3d={setViewerFile}
onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
thumbnailVersion={thumbnailVersions[file.id]}
@@ -2128,6 +2145,20 @@ export function FileManagerPage() {
</button>
</>
)}
{(file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl') && (
<button
onClick={() => hasPermission('library:read') && setViewerFile(file)}
className={`p-1.5 rounded transition-colors ${
hasPermission('library:read')
? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
title={hasPermission('library:read') ? '3D Preview' : 'You do not have permission to preview files'}
disabled={!hasPermission('library:read')}
>
<Box className="w-4 h-4" />
</button>
)}
<button
onClick={() => hasPermission('library:read') && handleDownload(file.id)}
className={`p-1.5 rounded transition-colors ${
@@ -2296,6 +2327,12 @@ export function FileManagerPage() {
queryClient.invalidateQueries({ queryKey: ['queue'] });
queryClient.invalidateQueries({ queryKey: ['archives'] });
}}
{viewerFile && (
<ModelViewerModal
libraryFileId={viewerFile.id}
title={viewerFile.print_name || viewerFile.filename}
fileType={viewerFile.file_type}
onClose={() => setViewerFile(null)}
/>
)}
+47 -3
View File
@@ -1,6 +1,6 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import {
DndContext,
@@ -46,6 +46,7 @@ import {
CheckSquare,
Square,
User,
Pause,
} from 'lucide-react';
import { api } from '../api/client';
import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
@@ -80,7 +81,7 @@ function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat =
return formatDateTime(dateString, timeFormat);
}
function StatusBadge({ status, waitingReason, t }: { status: PrintQueueItem['status']; waitingReason?: string | null; t: (key: string) => string }) {
function StatusBadge({ status, waitingReason, printerState, t }: { status: PrintQueueItem['status']; waitingReason?: string | null; printerState?: string | null; t: (key: string) => string }) {
// Special case: pending with waiting_reason shows as "Waiting"
if (status === 'pending' && waitingReason) {
return (
@@ -91,6 +92,16 @@ function StatusBadge({ status, waitingReason, t }: { status: PrintQueueItem['sta
);
}
// Special case: printing but printer is paused
if (status === 'printing' && (printerState === 'PAUSE' || printerState === 'PAUSED')) {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border text-yellow-400 bg-yellow-400/10 border-yellow-400/20">
<Pause className="w-3.5 h-3.5" />
{t('queue.status.paused')}
</span>
);
}
const config = {
pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: t('queue.status.pending') },
printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: t('queue.status.printing') },
@@ -286,6 +297,7 @@ function SortableQueueItem({
onToggleSelect,
hasPermission,
canModify,
printerState,
t,
}: {
item: PrintQueueItem;
@@ -301,6 +313,7 @@ function SortableQueueItem({
onToggleSelect?: () => void;
hasPermission: (permission: Permission) => boolean;
canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
printerState?: string | null;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
const canReorder = hasPermission('queue:reorder');
@@ -500,7 +513,7 @@ function SortableQueueItem({
</div>
{/* Status badge */}
<StatusBadge status={item.status} waitingReason={item.waiting_reason} t={t} />
<StatusBadge status={item.status} waitingReason={item.waiting_reason} printerState={printerState} t={t} />
{/* Actions */}
<div className="flex items-center gap-1">
@@ -825,6 +838,36 @@ export function QueuePage() {
return items;
}, [queue, filterLocation, matchesLocationFilter]);
// Get unique printer IDs from active items to fetch their statuses
const activePrinterIds = useMemo(() => {
const ids = new Set<number>();
activeItems.forEach(item => {
if (item.printer_id) ids.add(item.printer_id);
});
return Array.from(ids);
}, [activeItems]);
// Fetch printer statuses for printers with active jobs
const printerStatusQueries = useQueries({
queries: activePrinterIds.map(printerId => ({
queryKey: ['printerStatus', printerId],
queryFn: () => api.getPrinterStatus(printerId),
refetchInterval: 5000,
})),
});
// Build a map of printer_id -> state for quick lookup
const printerStateMap = useMemo(() => {
const map: Record<number, string | null> = {};
activePrinterIds.forEach((printerId, index) => {
const result = printerStatusQueries[index];
if (result?.data?.state) {
map[printerId] = result.data.state;
}
});
return map;
}, [activePrinterIds, printerStatusQueries]);
const historyItems = useMemo(() => {
let items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
if (filterLocation) {
@@ -1035,6 +1078,7 @@ export function QueuePage() {
timeFormat={timeFormat}
hasPermission={hasPermission}
canModify={canModify}
printerState={item.printer_id ? printerStateMap[item.printer_id] : null}
t={t}
/>
))}
+2 -2
View File
@@ -672,12 +672,12 @@ export function StatsPage() {
return (
<div className="p-4 md:p-8">
<div className="flex items-center justify-between mb-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h1 className="text-2xl font-bold text-white">{t('stats.title')}</h1>
<p className="text-bambu-gray">{t('stats.subtitle')}</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
{/* Hidden widgets button - toggles panel in Dashboard */}
{hiddenCount > 0 && (
<Button
+41
View File
@@ -0,0 +1,41 @@
export interface PlateFilament {
slot_id: number;
type: string;
color: string;
used_grams: number;
used_meters: number;
}
export interface PlateMetadata {
index: number;
name: string | null;
objects: string[];
has_thumbnail: boolean;
thumbnail_url: string | null;
print_time_seconds: number | null;
filament_used_grams: number | null;
filaments: PlateFilament[];
}
export interface ArchivePlatesResponse {
archive_id: number;
filename: string;
plates: PlateMetadata[];
is_multi_plate: boolean;
}
export interface LibraryFilePlatesResponse {
file_id: number;
filename: string;
plates: PlateMetadata[];
is_multi_plate: boolean;
}
export interface ViewerPlateSelectionState {
selected_plate_id: number | null;
}
export interface PlateAssignment {
object_id: string;
plate_id: number | null;
}