Просмотр исходного кода

fix(print): Print Anyway per printer, variant plate, nozzle per plate, sliced .3mf drop

Print Anyway now only covers the printers whose warning it answered; an
untouched variant plate dropdown sends the plate it shows; the nozzle per
filament comes from the chosen plate; and a sliced 3MF named without
.gcode can be dropped on a printer card or uploaded for print.
maziggy 6 дней назад
Родитель
Сommit
19212d066d
100 измененных файлов с 247 добавлено и 34 удалено
  1. 5 0
      CHANGELOG.md
  2. 1 1
      backend/app/api/routes/archives.py
  3. 1 1
      backend/app/api/routes/library.py
  4. 47 0
      backend/tests/integration/test_filament_requirements_plate_nozzle.py
  5. 35 0
      frontend/src/__tests__/components/PrintModal.test.tsx
  6. 30 0
      frontend/src/__tests__/components/VariantCandidatesPlate.test.tsx
  7. 1 1
      frontend/src/__tests__/pages/PrintersPageDropOnBusy.test.tsx
  8. 41 0
      frontend/src/__tests__/pages/PrintersPageDropPermission.test.tsx
  9. 12 1
      frontend/src/components/PrintModal/VariantCandidates.tsx
  10. 13 3
      frontend/src/components/PrintModal/index.tsx
  11. 2 1
      frontend/src/i18n/locales/de.ts
  12. 2 1
      frontend/src/i18n/locales/en.ts
  13. 2 1
      frontend/src/i18n/locales/es.ts
  14. 2 1
      frontend/src/i18n/locales/fr.ts
  15. 2 1
      frontend/src/i18n/locales/it.ts
  16. 2 1
      frontend/src/i18n/locales/ja.ts
  17. 2 1
      frontend/src/i18n/locales/ko.ts
  18. 2 1
      frontend/src/i18n/locales/nl.ts
  19. 2 1
      frontend/src/i18n/locales/pt-BR.ts
  20. 2 1
      frontend/src/i18n/locales/ru.ts
  21. 2 1
      frontend/src/i18n/locales/sv.ts
  22. 2 1
      frontend/src/i18n/locales/tr.ts
  23. 2 1
      frontend/src/i18n/locales/uk.ts
  24. 2 1
      frontend/src/i18n/locales/zh-CN.ts
  25. 2 1
      frontend/src/i18n/locales/zh-TW.ts
  26. 16 7
      frontend/src/pages/PrintersPage.tsx
  27. 12 0
      frontend/src/utils/libraryFiles.ts
  28. 0 0
      static/assets/AdditionalSection-aV59zOxS.js
  29. 0 0
      static/assets/AmsUnitCard-Bz4vgep_.js
  30. 0 0
      static/assets/ArchivesPage-8iHZfw7d.js
  31. 0 0
      static/assets/AssignToAmsModal-CvxcOvRZ.js
  32. 0 0
      static/assets/CamWallPage-DFElY6yY.js
  33. 0 0
      static/assets/CameraDiagnoseModal-Cggzn55w.js
  34. 0 0
      static/assets/CameraPage-CqsQzAcx.js
  35. 0 0
      static/assets/CameraWall-CdoWFPen.js
  36. 0 0
      static/assets/ColumnConfigModal-DAJh8-Ey.js
  37. 0 0
      static/assets/ConfigureAmsSlotModal-C1R6ghNk.js
  38. 0 0
      static/assets/ConnectAuthorizePage-DYENr7dc.js
  39. 0 0
      static/assets/ContextMenu-DyZ_6oa7.js
  40. 0 0
      static/assets/ExternalLinkPage-CfJcbQZJ.js
  41. 0 1
      static/assets/FileManagerPage-CPyIrq-Y.js
  42. 0 0
      static/assets/FileUploadModal-DD_Q7rK7.js
  43. 0 0
      static/assets/FinancePage-BnNEgRnz.js
  44. 0 0
      static/assets/GCodeViewerPage-DeF25Ar2.js
  45. 0 0
      static/assets/GroupEditPage-9HycDhIN.js
  46. 0 0
      static/assets/ImagePreviewModal-CDnIYPIt.js
  47. 0 0
      static/assets/InventoryPage-DoRsLx_V.js
  48. 0 0
      static/assets/LibraryTrashPage-CgqNMFMm.js
  49. 0 0
      static/assets/LoginPage-DfpyrLA9.js
  50. 0 0
      static/assets/MaintenancePage-BN6KWx9S.js
  51. 0 0
      static/assets/ModelSourcesPage-PSs1IlQq.js
  52. 0 0
      static/assets/NotificationsPage-Ch6gjh5G.js
  53. 0 1
      static/assets/PdfPreviewModal-BH7AG5Q1.js
  54. 0 0
      static/assets/PhotoGalleryModal-DePcP22U.js
  55. 0 0
      static/assets/PrinterLocationsPage-C2h7gqq-.js
  56. 0 0
      static/assets/PrintersPage-Bx44lfMy.js
  57. 0 0
      static/assets/PrintersPage-alt5xx8U.js
  58. 0 0
      static/assets/ProfilesPage-C9F4F4Aw.js
  59. 0 0
      static/assets/ProjectDetailPage--tFSihzP.js
  60. 0 0
      static/assets/ProjectsPage-DUEeYsv2.js
  61. 0 0
      static/assets/QueuePage-DsgpQCyK.js
  62. 0 0
      static/assets/RichTextEditor-BqQJ7NC0.js
  63. 0 0
      static/assets/SettingsPage-BEq4d62v.js
  64. 0 0
      static/assets/SetupPage-CY4HhtYk.js
  65. 0 0
      static/assets/SliceModal-CNlyiKB0.js
  66. 0 0
      static/assets/SpoolBuddyAmsPage-5PzF7CjE.js
  67. 0 0
      static/assets/SpoolBuddyCalibrationPage-3VmTP9vc.js
  68. 0 0
      static/assets/SpoolBuddyDashboard-STR0ezIL.js
  69. 0 0
      static/assets/SpoolBuddyInventoryPage-BzjnCuFI.js
  70. 1 1
      static/assets/SpoolBuddySettingsPage-pOMatKri.js
  71. 0 0
      static/assets/SpoolBuddyWriteTagPage-nsQGbs0R.js
  72. 0 0
      static/assets/SpreadsheetPreviewModal-BwkwCfzK.js
  73. 0 0
      static/assets/StatsPage-DYMz5_g-.js
  74. 0 0
      static/assets/StreamOverlayPage-CIV61c2u.js
  75. 0 0
      static/assets/SystemInfoPage-Sj5kWXMj.js
  76. 0 0
      static/assets/de-BVGbU5OF.js
  77. 0 0
      static/assets/de-nWk4m79_.js
  78. 0 0
      static/assets/es-BA5r-Dew.js
  79. 0 0
      static/assets/es-BUAHoD2j.js
  80. 0 0
      static/assets/fr-CwOKGQyw.js
  81. 0 0
      static/assets/fr-ZVAYtWDF.js
  82. 0 0
      static/assets/index-D3tAzFbt.js
  83. 1 1
      static/assets/inventorySearch-CIUduptj.js
  84. 0 0
      static/assets/it-Dp1gd_Qc.js
  85. 0 0
      static/assets/it-Dp8_cxYB.js
  86. 0 0
      static/assets/ja-BILvVHhC.js
  87. 0 0
      static/assets/ja-CPpDMIkO.js
  88. 0 0
      static/assets/ko-B5WGBQ2h.js
  89. 0 0
      static/assets/ko-CKpxNDDi.js
  90. 0 1
      static/assets/libraryFiles-B0B1SFfn.js
  91. 1 0
      static/assets/libraryFiles-CZJbXn67.js
  92. 0 0
      static/assets/locationSensorDefaults-DZ38u6ZO.js
  93. 0 0
      static/assets/nl-CpQP78IA.js
  94. 0 0
      static/assets/nl-oghjFBdI.js
  95. 0 0
      static/assets/pdf-CGMhSR9y.js
  96. 0 0
      static/assets/pt-BR-CovR6TdE.js
  97. 0 0
      static/assets/pt-BR-rXpuSczP.js
  98. 0 0
      static/assets/ru-C4tikgI_.js
  99. 0 0
      static/assets/ru-D92Ru8kS.js
  100. 0 0
      static/assets/sv-BLkRovui.js

+ 5 - 0
CHANGELOG.md

@@ -163,6 +163,11 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 
 ### Fixed
 ### Fixed
+- **Print dialog fixes** —
+  - **Print Anyway** in a print to several printers now only waves through the printers whose filament warning it answered. Before, it went onto every selected printer, so one that was never checked also skipped the scheduler's filament checks.
+  - A job with printer alternatives whose plate dropdown was left untouched showed **Plate 1** but was queued with no plate, so it asked for every plate's filaments and the whole file's print time and could find no printer.
+  - On H2D and H2C, the nozzle shown for each filament now comes from the chosen plate, not from all plates of the file, which could match trays on the wrong nozzle.
+  - A sliced 3MF named without `.gcode` (a plate exported from Bambu Studio) can now be dropped on a printer card or picked in **Upload for Print**. An unsliced 3MF is refused with a message saying so.
 - **Orders and groupings show as finished however their last job ended** — An order or grouping was only re-checked when a print completed, an item was deleted or one was edited. A grouping whose last job was cancelled or stopped, or whose jobs were cancelled because their library file was deleted, an order whose last run was closed by the stuck-print cleanup, and one whose last open job failed before it reached the printer or was deleted, stayed **active** until the next restart. They are now re-checked on every way a job ends.
 - **Orders and groupings show as finished however their last job ended** — An order or grouping was only re-checked when a print completed, an item was deleted or one was edited. A grouping whose last job was cancelled or stopped, or whose jobs were cancelled because their library file was deleted, an order whose last run was closed by the stuck-print cleanup, and one whose last open job failed before it reached the printer or was deleted, stayed **active** until the next restart. They are now re-checked on every way a job ends.
 - **A job cancelled while it is being sent stays cancelled** — Cancelling a queued job during its upload (the edit dialog even asks you to cancel a job that is being sent) was overwritten when the upload then failed: the job turned into a failure, a failure notification went out, and the next job that needs the previous print to have succeeded was skipped. A failure is now only recorded for a job that is still waiting or printing, and the upload toast of a cancelled job closes instead of spinning. Likewise, a job stopped just as its printer turned busy is no longer put back in the queue, and a stopped job whose nozzle rack pick no longer fit no longer leaves its file on the printer.
 - **A job cancelled while it is being sent stays cancelled** — Cancelling a queued job during its upload (the edit dialog even asks you to cancel a job that is being sent) was overwritten when the upload then failed: the job turned into a failure, a failure notification went out, and the next job that needs the previous print to have succeeded was skipped. A failure is now only recorded for a job that is still waiting or printing, and the upload toast of a cancelled job closes instead of spinning. Likewise, a job stopped just as its printer turned busy is no longer put back in the queue, and a stopped job whose nozzle rack pick no longer fit no longer leaves its file on the printer.
 - **"Only if the previous print succeeded" looks at the right printer** — For a job queued for any printer of a model, the check ran before a printer was chosen, against jobs with no printer, so it nearly always let the job go, even onto a printer whose last print had failed; when it did trigger, the job was skipped for good. It now checks the printer it is about to use, passes over one whose last print failed and takes another of the model, and waits if every free one failed last. The queue then shows a **Resume after failure** banner for that model, which clears the gate on its printers, and the "job waiting" notification also goes out when a job that waited for a busy printer comes to wait on such a failure. On PostgreSQL, a job cancelled without an end time (cancelling an order, a pipeline or a kept order run) also counted as the latest print and hid a real failure before it; such cancels now record their end time, and the check no longer ranks a missing one first.
 - **"Only if the previous print succeeded" looks at the right printer** — For a job queued for any printer of a model, the check ran before a printer was chosen, against jobs with no printer, so it nearly always let the job go, even onto a printer whose last print had failed; when it did trigger, the job was skipped for good. It now checks the printer it is about to use, passes over one whose last print failed and takes another of the model, and waits if every free one failed last. The queue then shows a **Resume after failure** banner for that model, which clears the gate on its printers, and the "job waiting" notification also goes out when a job that waited for a busy printer comes to wait on such a failure. On PostgreSQL, a job cancelled without an end time (cancelling an order, a pipeline or a kept order run) also counted as the latest print and hid a real failure before it; such cancels now record their end time, and the check no longer ranks a missing one first.

+ 1 - 1
backend/app/api/routes/archives.py

@@ -5006,7 +5006,7 @@ async def get_filament_requirements(
             filaments.sort(key=lambda x: x["slot_id"])
             filaments.sort(key=lambda x: x["slot_id"])
 
 
             # Enrich with nozzle mapping for dual-nozzle printers
             # Enrich with nozzle mapping for dual-nozzle printers
-            nozzle_mapping = extract_nozzle_mapping_from_3mf(zf)
+            nozzle_mapping = extract_nozzle_mapping_from_3mf(zf, plate_id=plate_id)
             if nozzle_mapping:
             if nozzle_mapping:
                 for filament in filaments:
                 for filament in filaments:
                     filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
                     filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])

+ 1 - 1
backend/app/api/routes/library.py

@@ -3922,7 +3922,7 @@ async def get_library_file_filament_requirements(
             filaments.sort(key=lambda x: x["slot_id"])
             filaments.sort(key=lambda x: x["slot_id"])
 
 
             # Enrich with nozzle mapping for dual-nozzle printers
             # Enrich with nozzle mapping for dual-nozzle printers
-            nozzle_mapping = extract_nozzle_mapping_from_3mf(zf)
+            nozzle_mapping = extract_nozzle_mapping_from_3mf(zf, plate_id=plate_id)
             if nozzle_mapping:
             if nozzle_mapping:
                 for filament in filaments:
                 for filament in filaments:
                     filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
                     filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])

+ 47 - 0
backend/tests/integration/test_filament_requirements_plate_nozzle.py

@@ -0,0 +1,47 @@
+"""A plate's filament requirements name the nozzle that plate prints each slot on."""
+
+from __future__ import annotations
+
+import json
+import zipfile
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+# Slot 1 prints on group 0 on plate 1 and on group 1 on plate 2.
+SLICE_INFO = """<?xml version="1.0" encoding="UTF-8"?>
+<config>
+  <plate>
+    <metadata key="index" value="1"/>
+    <filament id="1" type="PLA" color="#FF0000" used_g="5.0" group_id="0"/>
+  </plate>
+  <plate>
+    <metadata key="index" value="2"/>
+    <filament id="1" type="PLA" color="#FF0000" used_g="7.0" group_id="1"/>
+  </plate>
+</config>"""
+
+
+async def test_each_plate_gets_its_own_nozzle(async_client: AsyncClient, printer_factory, archive_factory, tmp_path):
+    from backend.app.core.config import settings
+
+    path = tmp_path / "two-plates.3mf"
+    with zipfile.ZipFile(path, "w") as zf:
+        zf.writestr("Metadata/slice_info.config", SLICE_INFO)
+        zf.writestr("Metadata/project_settings.config", json.dumps({"physical_extruder_map": ["1", "0"]}))
+    printer = await printer_factory(model="H2D")
+    archive = await archive_factory(printer.id, file_path="two-plates.3mf")
+
+    with patch.object(settings, "base_dir", tmp_path):
+        nozzles = {}
+        for plate in (1, 2):
+            response = await async_client.get(
+                f"/api/v1/archives/{archive.id}/filament-requirements", params={"plate_id": plate}
+            )
+            assert response.status_code == 200, response.text
+            nozzles[plate] = [f["nozzle_id"] for f in response.json()["filaments"]]
+
+    assert nozzles == {1: [1], 2: [0]}

+ 35 - 0
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -2036,6 +2036,41 @@ describe('PrintModal — per-plate filament mapping (#2551 follow-up)', () => {
     expect(plate1?.ams_mapping).toEqual([0]);
     expect(plate1?.ams_mapping).toEqual([0]);
   });
   });
 
 
+  it('sets Print Anyway only on the printers whose filament warning it answered', async () => {
+    // X1 Carbon is short on the mapped spool, P1S is not. Answering the warning
+    // for X1 Carbon must not wave P1S's job through the scheduler's own check.
+    const queued: { printer_id: number; skip_filament_check?: boolean }[] = [];
+    server.use(
+      http.get('/api/v1/printers/:id/inventory-remain', ({ params }) => {
+        const grams = params.id === '1' ? 10 : 1000;
+        return HttpResponse.json({
+          inventory_remain_g: { '0': grams },
+          slot_materials: [
+            { ams_id: 0, tray_id: 0, global_tray_id: 0, material_key: 'preset:GFA00|color:FF0000', remaining_g: grams, extruder: 0 },
+          ],
+        });
+      }),
+      http.post('/api/v1/queue/', async ({ request }) => {
+        queued.push((await request.json()) as { printer_id: number; skip_filament_check?: boolean });
+        return HttpResponse.json({ id: queued.length, status: 'pending' });
+      }),
+    );
+
+    const user = userEvent.setup();
+    render(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
+    await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
+    await user.click(screen.getByText('X1 Carbon'));
+    await user.click(screen.getByText('P1S'));
+
+    await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
+    await waitFor(() => expect(screen.getByText(/Not enough filament/i)).toBeInTheDocument());
+    await user.click(screen.getByRole('button', { name: /print anyway/i }));
+
+    await waitFor(() => expect(queued.length).toBe(2));
+    const byPrinter = Object.fromEntries(queued.map((q) => [q.printer_id, q.skip_filament_check]));
+    expect(byPrinter).toEqual({ 1: true, 2: undefined });
+  });
+
   it('sums what the plates draw from one spool before warning about it', async () => {
   it('sums what the plates draw from one spool before warning about it', async () => {
     // Both plates map to the same red tray. 60 g left covers either plate on its
     // Both plates map to the same red tray. 60 g left covers either plate on its
     // own (40 g), but not both — the check has to add them up, not test them
     // own (40 g), but not both — the check has to add them up, not test them

+ 30 - 0
frontend/src/__tests__/components/VariantCandidatesPlate.test.tsx

@@ -0,0 +1,30 @@
+import { describe, it, expect, vi } from 'vitest';
+import { waitFor } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { render } from '../utils';
+import { VariantCandidates, type VariantCandidate } from '../../components/PrintModal/VariantCandidates';
+
+describe('VariantCandidates', () => {
+  it('chooses the plate it shows when none was picked', async () => {
+    server.use(
+      http.get('/api/v1/library/files/:id/plates', () =>
+        HttpResponse.json({
+          is_multi_plate: true,
+          plates: [
+            { index: 1, name: 'Plate 1' },
+            { index: 2, name: 'Plate 2' },
+          ],
+        }),
+      ),
+    );
+    const onPlateChange = vi.fn();
+    const candidates = [{ id: 7, filename: 'part.3mf', sliced_for_model: 'X1C' }] as VariantCandidate[];
+
+    render(
+      <VariantCandidates candidates={candidates} onReorder={vi.fn()} plateByFile={{}} onPlateChange={onPlateChange} />,
+    );
+
+    await waitFor(() => expect(onPlateChange).toHaveBeenCalledWith(7, 1));
+  });
+});

+ 1 - 1
frontend/src/__tests__/pages/PrintersPageDropOnBusy.test.tsx

@@ -196,7 +196,7 @@ describe('PrintersPage — drop onto a busy printer (#2849)', () => {
     fireEvent.drop(el, { dataTransfer: { files: [stl] } });
     fireEvent.drop(el, { dataTransfer: { files: [stl] } });
 
 
     await waitFor(() =>
     await waitFor(() =>
-      expect(screen.getByText('Only .gcode and .gcode.3mf files can be printed')).toBeInTheDocument()
+      expect(screen.getByText('Only sliced files can be printed (.gcode, .gcode.3mf or a sliced .3mf)')).toBeInTheDocument()
     );
     );
     expect(uploads).toEqual([]);
     expect(uploads).toEqual([]);
   });
   });

+ 41 - 0
frontend/src/__tests__/pages/PrintersPageDropPermission.test.tsx

@@ -143,3 +143,44 @@ describe('PrintersPage — drop zone permissions (#2849)', () => {
     expect(screen.queryByText('Drop to queue')).toBeNull();
     expect(screen.queryByText('Drop to queue')).toBeNull();
   });
   });
 });
 });
+
+describe('PrintersPage — dropping a 3MF on a printer card', () => {
+  beforeEach(() => {
+    uploads.length = 0;
+    permissions.granted = ['library:upload', 'queue:create'];
+  });
+
+  const dropThreeMf = async (fileType: string) => {
+    const deleted: string[] = [];
+    renderPage();
+    server.use(
+      http.post('/api/v1/library/files', () =>
+        HttpResponse.json({ id: 9, filename: 'Foo.3mf', file_type: fileType, metadata: {} }),
+      ),
+      http.delete('/api/v1/library/files/:id', ({ params }) => {
+        deleted.push(params.id as string);
+        return HttpResponse.json({ status: 'deleted' });
+      }),
+    );
+    const el = await card();
+    const file = new File(['PK'], 'Foo.3mf', { type: 'application/vnd.ms-package.3dmanufacturing-3dmodel+xml' });
+    fireEvent.drop(el, { dataTransfer: { files: [file] } });
+    return deleted;
+  };
+
+  it('takes a sliced Foo.3mf', async () => {
+    const deleted = await dropThreeMf('gcode.3mf');
+
+    // The print dialog opens for it.
+    expect(await screen.findByText('Foo.3mf')).toBeInTheDocument();
+    expect(screen.queryByText(/has no sliced G-code/)).not.toBeInTheDocument();
+    expect(deleted).toEqual([]);
+  });
+
+  it('removes an unsliced 3MF again and says why', async () => {
+    const deleted = await dropThreeMf('3mf');
+
+    expect(await screen.findByText(/has no sliced G-code/)).toBeInTheDocument();
+    expect(deleted).toEqual(['9']);
+  });
+});

+ 12 - 1
frontend/src/components/PrintModal/VariantCandidates.tsx

@@ -1,4 +1,4 @@
-import { useMemo } from 'react';
+import { useEffect, useMemo } from 'react';
 import { useQueries } from '@tanstack/react-query';
 import { useQueries } from '@tanstack/react-query';
 import { ArrowDown, ArrowUp, Layers, Printer as PrinterIcon } from 'lucide-react';
 import { ArrowDown, ArrowUp, Layers, Printer as PrinterIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
@@ -73,6 +73,17 @@ export function VariantCandidates({
     return out;
     return out;
   }, [candidates, plateQueries]);
   }, [candidates, plateQueries]);
 
 
+  // The dropdown shows the first plate when none is chosen; make that the
+  // choice too, or the job is queued with no plate (all plates' filaments and
+  // the whole file's print time) while the dialog says "Plate 1".
+  useEffect(() => {
+    if (readOnly) return;
+    for (const [fileId, plates] of Object.entries(platesByFile)) {
+      const id = Number(fileId);
+      if (plateByFile[id] == null && plates.length > 0) onPlateChange(id, plates[0].index);
+    }
+  }, [platesByFile, plateByFile, onPlateChange, readOnly]);
+
   const move = (from: number, to: number) => {
   const move = (from: number, to: number) => {
     if (to < 0 || to >= candidates.length) return;
     if (to < 0 || to >= candidates.length) return;
     const next = [...candidates];
     const next = [...candidates];

+ 13 - 3
frontend/src/components/PrintModal/index.tsx

@@ -131,6 +131,7 @@ export function PrintModal({
   const [candidatePlates, setCandidatePlates] = useState<Record<number, number | null>>({});
   const [candidatePlates, setCandidatePlates] = useState<Record<number, number | null>>({});
 
 
   type FilamentWarningItem = {
   type FilamentWarningItem = {
+    printerId: number;
     printerName: string;
     printerName: string;
     slotLabel: string;
     slotLabel: string;
     requiredGrams: number;
     requiredGrams: number;
@@ -1004,7 +1005,13 @@ export function PrintModal({
       ? scheduleOptions.requireManualStart
       ? scheduleOptions.requireManualStart
       : scheduleOptions.scheduleType === 'scheduled' && !!queueItem?.manual_start);
       : scheduleOptions.scheduleType === 'scheduled' && !!queueItem?.manual_start);
 
 
-  const handleSubmit = async (e?: React.FormEvent, options?: { skipFilamentCheck?: boolean }) => {
+  // `acknowledgedPrinters`: the printers whose filament warning the user
+  // answered with "Print Anyway". Only their jobs skip the scheduler's check;
+  // another printer in the same submit was never checked against.
+  const handleSubmit = async (
+    e?: React.FormEvent,
+    options?: { skipFilamentCheck?: boolean; acknowledgedPrinters?: number[] },
+  ) => {
     e?.preventDefault();
     e?.preventDefault();
 
 
     if (billingEnabled && selectedCostCenter == null) {
     if (billingEnabled && selectedCostCenter == null) {
@@ -1099,6 +1106,7 @@ export function PrintModal({
               const needed = pooledRequired.get(key) ?? 0;
               const needed = pooledRequired.get(key) ?? 0;
               if (available >= needed) continue;
               if (available >= needed) continue;
               warningItems.push({
               warningItems.push({
+                printerId,
                 printerName,
                 printerName,
                 slotLabel,
                 slotLabel,
                 requiredGrams: needed,
                 requiredGrams: needed,
@@ -1112,6 +1120,7 @@ export function PrintModal({
 
 
             if (slot.remaining_g >= requiredGrams) continue;
             if (slot.remaining_g >= requiredGrams) continue;
             warningItems.push({
             warningItems.push({
+              printerId,
               printerName,
               printerName,
               slotLabel,
               slotLabel,
               requiredGrams,
               requiredGrams,
@@ -1359,7 +1368,7 @@ export function PrintModal({
       // persist that acknowledgement so the scheduler doesn't immediately
       // persist that acknowledgement so the scheduler doesn't immediately
       // re-flag the item on its first dispatch tick (#1698-followup).
       // re-flag the item on its first dispatch tick (#1698-followup).
       skip_filament_check:
       skip_filament_check:
-        options?.skipFilamentCheck === true ||
+        (printerId != null && options?.acknowledgedPrinters?.includes(printerId)) ||
         (printerId != null && mappingSubstitutesMaterial(printerId, plateId, getMappingForPrinter(printerId, plateId)))
         (printerId != null && mappingSubstitutesMaterial(printerId, plateId, getMappingForPrinter(printerId, plateId)))
           ? true
           ? true
           : undefined,
           : undefined,
@@ -2149,8 +2158,9 @@ export function PrintModal({
           cancelText={t('common.cancel')}
           cancelText={t('common.cancel')}
           variant="warning"
           variant="warning"
           onConfirm={() => {
           onConfirm={() => {
+            const acknowledgedPrinters = [...new Set(filamentWarningItems.map((item) => item.printerId))];
             setFilamentWarningItems(null);
             setFilamentWarningItems(null);
-            void handleSubmit(undefined, { skipFilamentCheck: true });
+            void handleSubmit(undefined, { skipFilamentCheck: true, acknowledgedPrinters });
           }}
           }}
           onCancel={() => setFilamentWarningItems(null)}
           onCancel={() => setFilamentWarningItems(null)}
         />
         />

+ 2 - 1
frontend/src/i18n/locales/de.ts

@@ -953,7 +953,8 @@ export default {
     developerModeWarning: 'Der Entwickler-LAN-Modus ist nicht aktiviert auf: {{names}}. Einige Funktionen funktionieren möglicherweise nicht.',
     developerModeWarning: 'Der Entwickler-LAN-Modus ist nicht aktiviert auf: {{names}}. Einige Funktionen funktionieren möglicherweise nicht.',
     howToEnable: 'Aktivieren',
     howToEnable: 'Aktivieren',
     incompatibleFile: 'Diese Datei wurde für {{slicedFor}} geslicet, aber dieser Drucker ist ein {{printerModel}}',
     incompatibleFile: 'Diese Datei wurde für {{slicedFor}} geslicet, aber dieser Drucker ist ein {{printerModel}}',
-    dropNotPrintable: 'Nur .gcode- und .gcode.3mf-Dateien können gedruckt werden',
+    dropNotPrintable: 'Nur geslicte Dateien können gedruckt werden (.gcode, .gcode.3mf oder eine geslicte .3mf)',
+    dropNotSliced: 'Diese Datei enthält keinen geslicten G-Code. Slice sie zuerst und drucke sie dann.',
     dropToPrint: 'Zum Drucken ablegen',
     dropToPrint: 'Zum Drucken ablegen',
     dropToQueue: 'Zum Einreihen ablegen',
     dropToQueue: 'Zum Einreihen ablegen',
   },
   },

+ 2 - 1
frontend/src/i18n/locales/en.ts

@@ -960,7 +960,8 @@ export default {
     developerModeWarning: 'Developer LAN mode is not enabled on: {{names}}. Some features may not work.',
     developerModeWarning: 'Developer LAN mode is not enabled on: {{names}}. Some features may not work.',
     howToEnable: 'How to enable',
     howToEnable: 'How to enable',
     incompatibleFile: 'This file was sliced for {{slicedFor}}, but this printer is a {{printerModel}}',
     incompatibleFile: 'This file was sliced for {{slicedFor}}, but this printer is a {{printerModel}}',
-    dropNotPrintable: 'Only .gcode and .gcode.3mf files can be printed',
+    dropNotPrintable: 'Only sliced files can be printed (.gcode, .gcode.3mf or a sliced .3mf)',
+    dropNotSliced: 'This file has no sliced G-code. Slice it first, then print it.',
     dropToPrint: 'Drop to print',
     dropToPrint: 'Drop to print',
     dropToQueue: 'Drop to queue',
     dropToQueue: 'Drop to queue',
   },
   },

+ 2 - 1
frontend/src/i18n/locales/es.ts

@@ -953,7 +953,8 @@ export default {
     developerModeWarning: 'El modo desarrollador LAN no está activado en: {{names}}. Es posible que algunas funciones no funcionen.',
     developerModeWarning: 'El modo desarrollador LAN no está activado en: {{names}}. Es posible que algunas funciones no funcionen.',
     howToEnable: 'Cómo activarlo',
     howToEnable: 'Cómo activarlo',
     incompatibleFile: 'Este archivo se laminó para {{slicedFor}}, pero esta impresora es una {{printerModel}}',
     incompatibleFile: 'Este archivo se laminó para {{slicedFor}}, pero esta impresora es una {{printerModel}}',
-    dropNotPrintable: 'Solo se pueden imprimir archivos .gcode y .gcode.3mf',
+    dropNotPrintable: 'Solo se pueden imprimir archivos laminados (.gcode, .gcode.3mf o un .3mf laminado)',
+    dropNotSliced: 'Este archivo no tiene G-code laminado. Lamínalo primero y luego imprímelo.',
     dropToPrint: 'Suelte para imprimir',
     dropToPrint: 'Suelte para imprimir',
     dropToQueue: 'Suelte para poner en cola',
     dropToQueue: 'Suelte para poner en cola',
   },
   },

+ 2 - 1
frontend/src/i18n/locales/fr.ts

@@ -953,7 +953,8 @@ export default {
     developerModeWarning: 'Le mode développeur LAN n\'est pas activé sur : {{names}}. Certaines fonctionnalités peuvent ne pas fonctionner.',
     developerModeWarning: 'Le mode développeur LAN n\'est pas activé sur : {{names}}. Certaines fonctionnalités peuvent ne pas fonctionner.',
     howToEnable: 'Comment activer',
     howToEnable: 'Comment activer',
     incompatibleFile: 'Ce fichier a été tranché pour {{slicedFor}}, mais cette imprimante est une {{printerModel}}',
     incompatibleFile: 'Ce fichier a été tranché pour {{slicedFor}}, mais cette imprimante est une {{printerModel}}',
-    dropNotPrintable: 'Seuls les fichiers .gcode et .gcode.3mf peuvent être imprimés',
+    dropNotPrintable: 'Seuls les fichiers tranchés peuvent être imprimés (.gcode, .gcode.3mf ou un .3mf tranché)',
+    dropNotSliced: "Ce fichier ne contient pas de G-code tranché. Tranchez-le d'abord, puis imprimez-le.",
     dropToPrint: 'Déposer pour imprimer',
     dropToPrint: 'Déposer pour imprimer',
     dropToQueue: 'Déposer pour mettre en file',
     dropToQueue: 'Déposer pour mettre en file',
   },
   },

+ 2 - 1
frontend/src/i18n/locales/it.ts

@@ -953,7 +953,8 @@ export default {
     developerModeWarning: 'La modalità sviluppatore LAN non è attivata su: {{names}}. Alcune funzionalità potrebbero non funzionare.',
     developerModeWarning: 'La modalità sviluppatore LAN non è attivata su: {{names}}. Alcune funzionalità potrebbero non funzionare.',
     howToEnable: 'Come attivare',
     howToEnable: 'Come attivare',
     incompatibleFile: 'Questo file è stato preparato per {{slicedFor}}, ma questa stampante è una {{printerModel}}',
     incompatibleFile: 'Questo file è stato preparato per {{slicedFor}}, ma questa stampante è una {{printerModel}}',
-    dropNotPrintable: 'Solo i file .gcode e .gcode.3mf possono essere stampati',
+    dropNotPrintable: 'Si possono stampare solo file slicati (.gcode, .gcode.3mf o un .3mf slicato)',
+    dropNotSliced: 'Questo file non contiene G-code slicato. Slicalo prima, poi stampalo.',
     dropToPrint: 'Rilascia per stampare',
     dropToPrint: 'Rilascia per stampare',
     dropToQueue: 'Rilascia per accodare',
     dropToQueue: 'Rilascia per accodare',
   },
   },

+ 2 - 1
frontend/src/i18n/locales/ja.ts

@@ -952,7 +952,8 @@ export default {
     developerModeWarning: '開発者LANモードが有効になっていません: {{names}}。一部の機能が動作しない可能性があります。',
     developerModeWarning: '開発者LANモードが有効になっていません: {{names}}。一部の機能が動作しない可能性があります。',
     howToEnable: '有効化方法',
     howToEnable: '有効化方法',
     incompatibleFile: 'このファイルは{{slicedFor}}用にスライスされていますが、このプリンターは{{printerModel}}です',
     incompatibleFile: 'このファイルは{{slicedFor}}用にスライスされていますが、このプリンターは{{printerModel}}です',
-    dropNotPrintable: '.gcodeおよび.gcode.3mfファイルのみ印刷できます',
+    dropNotPrintable: '印刷できるのはスライス済みのファイルのみです(.gcode、.gcode.3mf、またはスライス済みの .3mf)',
+    dropNotSliced: 'このファイルにはスライス済みの G-code がありません。先にスライスしてから印刷してください。',
     dropToPrint: 'ドロップして印刷',
     dropToPrint: 'ドロップして印刷',
     dropToQueue: 'ドロップしてキューに追加',
     dropToQueue: 'ドロップしてキューに追加',
   },
   },

+ 2 - 1
frontend/src/i18n/locales/ko.ts

@@ -905,7 +905,8 @@ export default {
     developerModeWarning: '개발자 LAN 모드가 활성화되지 않음: {{names}}. 일부 기능이 작동하지 않을 수 있습니다.',
     developerModeWarning: '개발자 LAN 모드가 활성화되지 않음: {{names}}. 일부 기능이 작동하지 않을 수 있습니다.',
     howToEnable: '활성화 방법',
     howToEnable: '활성화 방법',
     incompatibleFile: '이 파일은 {{slicedFor}}용으로 슬라이싱되었지만, 이 프린터는 {{printerModel}}입니다',
     incompatibleFile: '이 파일은 {{slicedFor}}용으로 슬라이싱되었지만, 이 프린터는 {{printerModel}}입니다',
-    dropNotPrintable: '.gcode 및 .gcode.3mf 파일만 인쇄할 수 있습니다',
+    dropNotPrintable: '슬라이스된 파일만 출력할 수 있습니다 (.gcode, .gcode.3mf 또는 슬라이스된 .3mf)',
+    dropNotSliced: '이 파일에는 슬라이스된 G-code가 없습니다. 먼저 슬라이스한 다음 출력하세요.',
     dropToPrint: '놓아서 인쇄',
     dropToPrint: '놓아서 인쇄',
     dropToQueue: '놓아서 대기열에 추가',
     dropToQueue: '놓아서 대기열에 추가',
     addPreflight: {
     addPreflight: {

+ 2 - 1
frontend/src/i18n/locales/nl.ts

@@ -958,7 +958,8 @@ export default {
     developerModeWarning: 'Developer LAN-modus is niet ingeschakeld op: {{names}}. Sommige functies werken mogelijk niet.',
     developerModeWarning: 'Developer LAN-modus is niet ingeschakeld op: {{names}}. Sommige functies werken mogelijk niet.',
     howToEnable: 'Inschakelen',
     howToEnable: 'Inschakelen',
     incompatibleFile: 'Dit bestand is gesliced voor {{slicedFor}}, maar deze printer is een {{printerModel}}',
     incompatibleFile: 'Dit bestand is gesliced voor {{slicedFor}}, maar deze printer is een {{printerModel}}',
-    dropNotPrintable: 'Alleen .gcode- en .gcode.3mf-bestanden kunnen worden afgedrukt',
+    dropNotPrintable: 'Alleen gesliceerde bestanden kunnen worden geprint (.gcode, .gcode.3mf of een gesliceerde .3mf)',
+    dropNotSliced: 'Dit bestand bevat geen gesliceerde G-code. Slice het eerst en print het daarna.',
     dropToPrint: 'Sleep hierheen om af te drukken',
     dropToPrint: 'Sleep hierheen om af te drukken',
     dropToQueue: 'Sleep hierheen voor wachtrij',
     dropToQueue: 'Sleep hierheen voor wachtrij',
   },
   },

+ 2 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -953,7 +953,8 @@ export default {
     developerModeWarning: 'O modo desenvolvedor LAN não está ativado em: {{names}}. Alguns recursos podem não funcionar.',
     developerModeWarning: 'O modo desenvolvedor LAN não está ativado em: {{names}}. Alguns recursos podem não funcionar.',
     howToEnable: 'Como ativar',
     howToEnable: 'Como ativar',
     incompatibleFile: 'Este arquivo foi fatiado para {{slicedFor}}, mas esta impressora é uma {{printerModel}}',
     incompatibleFile: 'Este arquivo foi fatiado para {{slicedFor}}, mas esta impressora é uma {{printerModel}}',
-    dropNotPrintable: 'Apenas arquivos .gcode e .gcode.3mf podem ser impressos',
+    dropNotPrintable: 'Apenas arquivos fatiados podem ser impressos (.gcode, .gcode.3mf ou um .3mf fatiado)',
+    dropNotSliced: 'Este arquivo não tem G-code fatiado. Fatie-o primeiro e depois imprima.',
     dropToPrint: 'Solte para imprimir',
     dropToPrint: 'Solte para imprimir',
     dropToQueue: 'Solte para adicionar à fila',
     dropToQueue: 'Solte para adicionar à fila',
   },
   },

+ 2 - 1
frontend/src/i18n/locales/ru.ts

@@ -910,7 +910,8 @@ export default {
     developerModeWarning: "Режим LAN для разработчиков не включён на: {{names}}. Некоторые функции могут не работать.",
     developerModeWarning: "Режим LAN для разработчиков не включён на: {{names}}. Некоторые функции могут не работать.",
     howToEnable: "Как включить",
     howToEnable: "Как включить",
     incompatibleFile: "Файл подготовлен для {{slicedFor}}, а выбранный принтер — {{printerModel}}",
     incompatibleFile: "Файл подготовлен для {{slicedFor}}, а выбранный принтер — {{printerModel}}",
-    dropNotPrintable: "Для печати подходят только файлы .gcode и .gcode.3mf",
+    dropNotPrintable: 'Печатать можно только нарезанные файлы (.gcode, .gcode.3mf или нарезанный .3mf)',
+    dropNotSliced: 'В этом файле нет нарезанного G-кода. Сначала нарежьте его, затем печатайте.',
     dropToPrint: "Перетащите файл для печати",
     dropToPrint: "Перетащите файл для печати",
     dropToQueue: "Перетащите файл в очередь",
     dropToQueue: "Перетащите файл в очередь",
   },
   },

+ 2 - 1
frontend/src/i18n/locales/sv.ts

@@ -958,7 +958,8 @@ export default {
     developerModeWarning: 'Utvecklar-LAN-läge är inte aktiverat på: {{names}}. Vissa funktioner kommer kanske inte att fungera.',
     developerModeWarning: 'Utvecklar-LAN-läge är inte aktiverat på: {{names}}. Vissa funktioner kommer kanske inte att fungera.',
     howToEnable: 'Hur man aktiverar',
     howToEnable: 'Hur man aktiverar',
     incompatibleFile: 'Denna fil var slicad för {{slicedFor}}, men denna skrivare är en {{printerModel}}',
     incompatibleFile: 'Denna fil var slicad för {{slicedFor}}, men denna skrivare är en {{printerModel}}',
-    dropNotPrintable: 'Endast .gcode- och .gcode.3mf-filer kan skrivas ut',
+    dropNotPrintable: 'Endast slicade filer kan skrivas ut (.gcode, .gcode.3mf eller en slicad .3mf)',
+    dropNotSliced: 'Filen innehåller ingen slicad G-kod. Slica den först och skriv sedan ut den.',
     dropToPrint: 'Släpp för att skriva ut',
     dropToPrint: 'Släpp för att skriva ut',
     dropToQueue: 'Skrivare upptagen',
     dropToQueue: 'Skrivare upptagen',
   },
   },

+ 2 - 1
frontend/src/i18n/locales/tr.ts

@@ -953,7 +953,8 @@ export default {
     developerModeWarning: 'Geliştirici LAN modu şunlarda etkin değil: {{names}}. Bazı özellikler çalışmayabilir.',
     developerModeWarning: 'Geliştirici LAN modu şunlarda etkin değil: {{names}}. Bazı özellikler çalışmayabilir.',
     howToEnable: 'Nasıl etkinleştirilir',
     howToEnable: 'Nasıl etkinleştirilir',
     incompatibleFile: 'Bu dosya {{slicedFor}} için dilimlendi, ancak bu yazıcı bir {{printerModel}}',
     incompatibleFile: 'Bu dosya {{slicedFor}} için dilimlendi, ancak bu yazıcı bir {{printerModel}}',
-    dropNotPrintable: 'Yalnızca .gcode ve .gcode.3mf dosyaları yazdırılabilir',
+    dropNotPrintable: 'Yalnızca dilimlenmiş dosyalar yazdırılabilir (.gcode, .gcode.3mf veya dilimlenmiş bir .3mf)',
+    dropNotSliced: 'Bu dosyada dilimlenmiş G-code yok. Önce dilimleyin, sonra yazdırın.',
     dropToPrint: 'Yazdırmak için bırakın',
     dropToPrint: 'Yazdırmak için bırakın',
     dropToQueue: 'Kuyruğa eklemek için bırakın',
     dropToQueue: 'Kuyruğa eklemek için bırakın',
   },
   },

+ 2 - 1
frontend/src/i18n/locales/uk.ts

@@ -957,7 +957,8 @@ export default {
     developerModeWarning: "Режим розробника LAN не ввімкнено на: {{names}}. Деякі функції можуть не працювати.",
     developerModeWarning: "Режим розробника LAN не ввімкнено на: {{names}}. Деякі функції можуть не працювати.",
     howToEnable: "Як увімкнути",
     howToEnable: "Як увімкнути",
     incompatibleFile: "Цей файл нарізано для {{slicedFor}}, а модель цього принтера — {{printerModel}}",
     incompatibleFile: "Цей файл нарізано для {{slicedFor}}, а модель цього принтера — {{printerModel}}",
-    dropNotPrintable: "Можна роздрукувати лише файли .gcode і .gcode.3mf.",
+    dropNotPrintable: 'Друкувати можна лише нарізані файли (.gcode, .gcode.3mf або нарізаний .3mf)',
+    dropNotSliced: 'У цьому файлі немає нарізаного G-коду. Спочатку наріжте його, потім друкуйте.',
     dropToPrint: "Відпустіть для друку",
     dropToPrint: "Відпустіть для друку",
     dropToQueue: "Відпустіть, щоб додати в чергу",
     dropToQueue: "Відпустіть, щоб додати в чергу",
   },
   },

+ 2 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -953,7 +953,8 @@ export default {
     developerModeWarning: '以下打印机未启用开发者局域网模式:{{names}}。某些功能可能无法使用。',
     developerModeWarning: '以下打印机未启用开发者局域网模式:{{names}}。某些功能可能无法使用。',
     howToEnable: '如何启用',
     howToEnable: '如何启用',
     incompatibleFile: '此文件是为 {{slicedFor}} 切片的,但该打印机是 {{printerModel}}',
     incompatibleFile: '此文件是为 {{slicedFor}} 切片的,但该打印机是 {{printerModel}}',
-    dropNotPrintable: '只能打印 .gcode 和 .gcode.3mf 文件',
+    dropNotPrintable: '只能打印已切片的文件(.gcode、.gcode.3mf 或已切片的 .3mf)',
+    dropNotSliced: '此文件不含已切片的 G-code。请先切片,再打印。',
     dropToPrint: '拖放以打印',
     dropToPrint: '拖放以打印',
     dropToQueue: '拖放以加入队列',
     dropToQueue: '拖放以加入队列',
   },
   },

+ 2 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -953,7 +953,8 @@ export default {
     developerModeWarning: '以下印表機未啟用開發者區域網路模式:{{names}}。某些功能可能無法使用。',
     developerModeWarning: '以下印表機未啟用開發者區域網路模式:{{names}}。某些功能可能無法使用。',
     howToEnable: '如何啟用',
     howToEnable: '如何啟用',
     incompatibleFile: '此檔案是為 {{slicedFor}} 切片的,但該印表機是 {{printerModel}}',
     incompatibleFile: '此檔案是為 {{slicedFor}} 切片的,但該印表機是 {{printerModel}}',
-    dropNotPrintable: '只能列印 .gcode 和 .gcode.3mf 檔案',
+    dropNotPrintable: '只能列印已切片的檔案(.gcode、.gcode.3mf 或已切片的 .3mf)',
+    dropNotSliced: '此檔案不含已切片的 G-code。請先切片,再列印。',
     dropToPrint: '拖放以列印',
     dropToPrint: '拖放以列印',
     dropToQueue: '拖放以加入佇列',
     dropToQueue: '拖放以加入佇列',
   },
   },

+ 16 - 7
frontend/src/pages/PrintersPage.tsx

@@ -206,6 +206,7 @@ import { getColorName, parseFilamentColor, isLightColor } from '../utils/colors'
 import { NumberInput } from '../components/NumberInput';
 import { NumberInput } from '../components/NumberInput';
 import { getCurrencySymbol } from '../utils/currency';
 import { getCurrencySymbol } from '../utils/currency';
 import { useUiSettings } from '../hooks/useUiSettings';
 import { useUiSettings } from '../hooks/useUiSettings';
+import { isSlicedLibraryFile, mayBeSlicedFileName } from '../utils/libraryFiles';
 
 
 // The status filter's options, and the only values it may hold. One list so a
 // The status filter's options, and the only values it may hold. One list so a
 // saved filter cannot be validated against a set the dropdown has since moved
 // saved filter cannot be validated against a set the dropdown has since moved
@@ -3553,16 +3554,21 @@ function PrinterCard({
     const file = droppedFiles[0];
     const file = droppedFiles[0];
     if (!file) return;
     if (!file) return;
 
 
-    // Only accept sliced/printable files (.gcode, .gcode.3mf, etc.)
-    const lower = file.name.toLowerCase();
-    if (!lower.endsWith('.gcode') && !lower.includes('.gcode.')) {
-      showToast(t('printers.dropNotPrintable', 'Only .gcode and .gcode.3mf files can be printed'), 'error');
+    // Sliced files only. A plain .3mf can be sliced too (a Studio plate
+    // export), which only its content tells — checked after the upload.
+    if (!mayBeSlicedFileName(file.name)) {
+      showToast(t('printers.dropNotPrintable'), 'error');
       return;
       return;
     }
     }
 
 
     setIsDropUploading(true);
     setIsDropUploading(true);
     try {
     try {
       const result = await api.uploadLibraryFile(file, null);
       const result = await api.uploadLibraryFile(file, null);
+      if (!isSlicedLibraryFile(result)) {
+        await api.deleteLibraryFile(result.id).catch(() => {});
+        showToast(t('printers.dropNotSliced'), 'error');
+        return;
+      }
 
 
       // Check printer compatibility if sliced_for_model is available in metadata
       // Check printer compatibility if sliced_for_model is available in metadata
       const slicedFor = (result.metadata as Record<string, unknown>)?.sliced_for_model as string | undefined;
       const slicedFor = (result.metadata as Record<string, unknown>)?.sliced_for_model as string | undefined;
@@ -6790,12 +6796,15 @@ function PrinterCard({
           autoUpload
           autoUpload
           accept=".gcode,.3mf"
           accept=".gcode,.3mf"
           validateFile={(file) => {
           validateFile={(file) => {
-            const lower = file.name.toLowerCase();
-            if (!lower.endsWith('.gcode') && !lower.includes('.gcode.')) {
-              return t('printers.dropNotPrintable', 'Only .gcode and .gcode.3mf files can be printed');
+            if (!mayBeSlicedFileName(file.name)) {
+              return t('printers.dropNotPrintable');
             }
             }
           }}
           }}
           onFileUploaded={(uploadedFile) => {
           onFileUploaded={(uploadedFile) => {
+            if (!isSlicedLibraryFile(uploadedFile)) {
+              api.deleteLibraryFile(uploadedFile.id).catch(() => {});
+              return t('printers.dropNotSliced');
+            }
             // Check printer compatibility if sliced_for_model is available in metadata
             // Check printer compatibility if sliced_for_model is available in metadata
             const slicedFor = (uploadedFile.metadata as Record<string, unknown>)?.sliced_for_model as string | undefined;
             const slicedFor = (uploadedFile.metadata as Record<string, unknown>)?.sliced_for_model as string | undefined;
             const printerModel = mapModelCode(printer.model);
             const printerModel = mapModelCode(printer.model);

+ 12 - 0
frontend/src/utils/libraryFiles.ts

@@ -26,6 +26,18 @@ export function isSlicedLibraryFile(file: {
   return lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf');
   return lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf');
 }
 }
 
 
+/**
+ * Can a file of this name be a sliced one, before it is uploaded and read?
+ *
+ * `.gcode` and `.gcode.3mf` are, by name. A plain `.3mf` may be too (a plate
+ * exported from Studio), which only its content tells: check
+ * `isSlicedLibraryFile` on the upload result.
+ */
+export function mayBeSlicedFileName(filename: string): boolean {
+  const lower = filename.toLowerCase();
+  return lower.endsWith('.gcode') || lower.includes('.gcode.') || lower.endsWith('.3mf');
+}
+
 /**
 /**
  * Is a library file something a slicer can take as *input*?
  * Is a library file something a slicer can take as *input*?
  *
  *

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/AdditionalSection-aV59zOxS.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/AmsUnitCard-Bz4vgep_.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ArchivesPage-8iHZfw7d.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/AssignToAmsModal-CvxcOvRZ.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CamWallPage-DFElY6yY.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CameraDiagnoseModal-Cggzn55w.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CameraPage-CqsQzAcx.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CameraWall-CdoWFPen.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ColumnConfigModal-DAJh8-Ey.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ConfigureAmsSlotModal-C1R6ghNk.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ConnectAuthorizePage-DYENr7dc.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ContextMenu-DyZ_6oa7.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ExternalLinkPage-CfJcbQZJ.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/FileManagerPage-CPyIrq-Y.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/FileUploadModal-DD_Q7rK7.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/FinancePage-BnNEgRnz.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/GCodeViewerPage-DeF25Ar2.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/GroupEditPage-9HycDhIN.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ImagePreviewModal-CDnIYPIt.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/InventoryPage-DoRsLx_V.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/LibraryTrashPage-CgqNMFMm.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/LoginPage-DfpyrLA9.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/MaintenancePage-BN6KWx9S.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ModelSourcesPage-PSs1IlQq.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/NotificationsPage-Ch6gjh5G.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/PdfPreviewModal-BH7AG5Q1.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PhotoGalleryModal-DePcP22U.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PrinterLocationsPage-C2h7gqq-.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PrintersPage-Bx44lfMy.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PrintersPage-alt5xx8U.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ProfilesPage-C9F4F4Aw.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ProjectDetailPage--tFSihzP.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ProjectsPage-DUEeYsv2.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/QueuePage-DsgpQCyK.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/RichTextEditor-BqQJ7NC0.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SettingsPage-BEq4d62v.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SetupPage-CY4HhtYk.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SliceModal-CNlyiKB0.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyAmsPage-5PzF7CjE.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-3VmTP9vc.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyDashboard-STR0ezIL.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-BzjnCuFI.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
static/assets/SpoolBuddySettingsPage-pOMatKri.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-nsQGbs0R.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpreadsheetPreviewModal-BwkwCfzK.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/StatsPage-DYMz5_g-.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/StreamOverlayPage-CIV61c2u.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SystemInfoPage-Sj5kWXMj.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/de-BVGbU5OF.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/de-nWk4m79_.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/es-BA5r-Dew.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/es-BUAHoD2j.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/fr-CwOKGQyw.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/fr-ZVAYtWDF.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-D3tAzFbt.js


+ 1 - 1
static/assets/inventorySearch-CHSxpBgm.js → static/assets/inventorySearch-CIUduptj.js

@@ -1 +1 @@
-import{mt as e}from"./index-DWBWyYf2.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
+import{mt as e}from"./index-D3tAzFbt.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/it-Dp1gd_Qc.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/it-Dp8_cxYB.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ja-BILvVHhC.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ja-CPpDMIkO.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ko-B5WGBQ2h.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ko-CKpxNDDi.js


+ 0 - 1
static/assets/libraryFiles-B0B1SFfn.js

@@ -1 +0,0 @@
-import{i as e,n as t}from"./slicer-DgCA7cW6.js";function n(e){let t=(e.file_type||``).toLowerCase();if(t===`gcode`||t===`gcode.3mf`)return!0;let n=(e.filename||``).toLowerCase();return n.endsWith(`.gcode`)||n.endsWith(`.gcode.3mf`)}function r(r,i,a){return n(r)?!1:i?t(r.filename):e(r.filename,a)}export{n,r as t};

+ 1 - 0
static/assets/libraryFiles-CZJbXn67.js

@@ -0,0 +1 @@
+import{i as e,n as t}from"./slicer-DgCA7cW6.js";function n(e){let t=(e.file_type||``).toLowerCase();if(t===`gcode`||t===`gcode.3mf`)return!0;let n=(e.filename||``).toLowerCase();return n.endsWith(`.gcode`)||n.endsWith(`.gcode.3mf`)}function r(e){let t=e.toLowerCase();return t.endsWith(`.gcode`)||t.includes(`.gcode.`)||t.endsWith(`.3mf`)}function i(r,i,a){return n(r)?!1:i?t(r.filename):e(r.filename,a)}export{n,r,i as t};

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/locationSensorDefaults-DZ38u6ZO.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/nl-CpQP78IA.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/nl-oghjFBdI.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/pdf-CGMhSR9y.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/pt-BR-CovR6TdE.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/pt-BR-rXpuSczP.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ru-C4tikgI_.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ru-D92Ru8kS.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/sv-BLkRovui.js


Некоторые файлы не были показаны из-за большого количества измененных файлов