mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 01:57:41 +02:00
Merge branch '0.1.8b' into cadtoolbox/248
This commit is contained in:
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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文字のコードを入力',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user