Browse Source

feat(print-modal): show each printer slot's colour in the filament mapping (issue #3159)

    The Print / Schedule dialog's filament mapping is where the colour a slice
    asked for is compared against the colour actually loaded, and only the
    left-hand side of that comparison had a swatch. The slot, and every slot in
    its dropdown, was text -- and the text cannot be trusted: a slot's colour name
    is resolved from the Color Catalog or, failing that, from hue, so a
    third-party beige is announced as "Orange". A "Color mismatch" warning then
    gives no way to tell a real mismatch from two names for the same hex without
    opening the printer card in another tab, which on a farm swapping twenty or
    thirty non-Bambu colours between machines is a check made many times a day.

    Each slot now carries its colour and its hex, and the slot whose colour is
    exactly the one the slice asked for is ticked. This works for a slot bound to
    an inventory spool and for one configured through Configure Slot or on the
    printer itself: the second kind has no inventory row behind it, and the
    printer's own tray colour is then what draws. A bound spool contributes what a
    tray record cannot -- SlotSpoolIdentity gains extra_colors and effect_type, so
    a two-tone or glittery spool draws as itself rather than as its base colour.

    The same treatment goes to the filament-override picker used for model-based
    assignment. It is the same choice on the other dispatch path, and leaving it
    text-only would have made one decision read two ways.

    Both controls stop being <select>s to do it, because an <option> renders text
    and nothing else. SlotPicker keeps what the select gave for free -- arrow,
    Home/End, Enter and Escape keys, listbox semantics, and the border colouring
    that encodes match, same-type-different-colour and not-loaded -- and is
    portaled with position:fixed so it is not clipped by the dialog's own scroll
    container, flipping above the row when there is no room below.
maziggy 4 days ago
parent
commit
5ac21d9202
100 changed files with 1036 additions and 612 deletions
  1. 8 0
      CHANGELOG.md
  2. 18 0
      backend/app/services/filament_deficit.py
  3. 44 0
      backend/tests/unit/services/test_filament_deficit.py
  4. 4 0
      backend/tests/unit/test_inventory_remain_endpoint.py
  5. 32 20
      frontend/src/__tests__/components/FilamentMapping.test.tsx
  6. 113 79
      frontend/src/__tests__/components/FilamentOverride.test.tsx
  7. 78 17
      frontend/src/__tests__/components/PrintModal.test.tsx
  8. 8 2
      frontend/src/__tests__/components/PrintModalCrossModel.test.tsx
  9. 183 0
      frontend/src/__tests__/components/SlotPicker.test.tsx
  10. 76 0
      frontend/src/__tests__/hooks/useFilamentMapping.test.ts
  11. 9 0
      frontend/src/api/client.ts
  12. 47 30
      frontend/src/components/PrintModal/FilamentMapping.tsx
  13. 34 20
      frontend/src/components/PrintModal/FilamentOverride.tsx
  14. 302 0
      frontend/src/components/PrintModal/SlotPicker.tsx
  15. 19 0
      frontend/src/components/PrintModal/displayHex.ts
  16. 16 0
      frontend/src/hooks/useFilamentMapping.ts
  17. 3 0
      frontend/src/i18n/locales/de.ts
  18. 3 0
      frontend/src/i18n/locales/en.ts
  19. 3 0
      frontend/src/i18n/locales/es.ts
  20. 3 0
      frontend/src/i18n/locales/fr.ts
  21. 3 0
      frontend/src/i18n/locales/it.ts
  22. 3 0
      frontend/src/i18n/locales/ja.ts
  23. 3 0
      frontend/src/i18n/locales/ko.ts
  24. 3 0
      frontend/src/i18n/locales/nl.ts
  25. 3 0
      frontend/src/i18n/locales/pt-BR.ts
  26. 3 0
      frontend/src/i18n/locales/ru.ts
  27. 3 0
      frontend/src/i18n/locales/sv.ts
  28. 3 0
      frontend/src/i18n/locales/tr.ts
  29. 3 0
      frontend/src/i18n/locales/uk.ts
  30. 3 0
      frontend/src/i18n/locales/zh-CN.ts
  31. 3 0
      frontend/src/i18n/locales/zh-TW.ts
  32. 0 0
      static/assets/index-CN5d-0KI.js
  33. 0 1
      static/assets/index-ChscM3lF.css
  34. 0 116
      static/assets/process-schema-CzfeynAH.js
  35. 0 0
      static/assets/process-toggle-rules-DDBax3G5.js
  36. 0 0
      static/assets/process-ui-tree-BWrKRLV6.js
  37. BIN
      static/fonts/inter-latin-ext.woff2
  38. BIN
      static/fonts/inter-latin.woff2
  39. BIN
      static/icons/ams-ht.png
  40. 0 1
      static/icons/ams-settings.svg
  41. 0 9
      static/icons/ams-wiring-center.svg
  42. 0 17
      static/icons/ams-wiring-left.svg
  43. 0 17
      static/icons/ams-wiring-right.svg
  44. BIN
      static/icons/ams.png
  45. 0 1
      static/icons/chamber.svg
  46. BIN
      static/icons/dual-extruder-left.png
  47. BIN
      static/icons/dual-extruder-right.png
  48. BIN
      static/icons/dual-extruder-right_sav.png
  49. BIN
      static/icons/dual-extruder.png
  50. BIN
      static/icons/extruder-change-filament.png
  51. BIN
      static/icons/extruder-left-right.png
  52. 0 51
      static/icons/eye.svg
  53. 0 1
      static/icons/heatbed.svg
  54. 0 44
      static/icons/home.svg
  55. 0 6
      static/icons/hotend.svg
  56. 0 4
      static/icons/humidity-empty.svg
  57. 0 4
      static/icons/humidity-full.svg
  58. 0 4
      static/icons/humidity-half.svg
  59. BIN
      static/icons/jogpad.png
  60. 0 5
      static/icons/jogpad.svg
  61. 0 4
      static/icons/lamp.svg
  62. 0 12
      static/icons/micro-sd.svg
  63. 0 1
      static/icons/reload.svg
  64. 0 0
      static/icons/settings.svg
  65. BIN
      static/icons/single-extruder1.png
  66. BIN
      static/icons/single-extruder2.png
  67. 0 1
      static/icons/skip-objects.svg
  68. 0 53
      static/icons/snowflake.svg
  69. 0 6
      static/icons/speed.svg
  70. 0 4
      static/icons/temperature.svg
  71. 0 6
      static/icons/ventilation.svg
  72. 0 1
      static/icons/video-camera.svg
  73. 0 2
      static/icons/water.svg
  74. 0 73
      static/icons/webcam.svg
  75. BIN
      static/img/android-chrome-192x192.png
  76. BIN
      static/img/android-chrome-512x512.png
  77. BIN
      static/img/apple-touch-icon.png
  78. BIN
      static/img/bambuddy_logo_dark.png
  79. BIN
      static/img/bambuddy_logo_dark_transparent.png
  80. BIN
      static/img/bambuddy_logo_light.png
  81. BIN
      static/img/bed/bed_cool.png
  82. BIN
      static/img/bed/bed_cool_supertack.png
  83. BIN
      static/img/bed/bed_engineering.png
  84. BIN
      static/img/bed/bed_high_templ.png
  85. BIN
      static/img/bed/bed_pei.png
  86. BIN
      static/img/bed/bed_pei_cool.png
  87. BIN
      static/img/favicon-16x16.png
  88. BIN
      static/img/favicon-32x32.png
  89. BIN
      static/img/favicon.png
  90. BIN
      static/img/printers/a1.png
  91. BIN
      static/img/printers/a1f.png
  92. BIN
      static/img/printers/a1mini.png
  93. BIN
      static/img/printers/a2l.png
  94. BIN
      static/img/printers/default.png
  95. BIN
      static/img/printers/h2c.png
  96. BIN
      static/img/printers/h2d.png
  97. BIN
      static/img/printers/h2dpro.png
  98. BIN
      static/img/printers/o1c.png
  99. BIN
      static/img/printers/o1e.png
  100. BIN
      static/img/printers/o1s.png

File diff suppressed because it is too large
+ 8 - 0
CHANGELOG.md


+ 18 - 0
backend/app/services/filament_deficit.py

@@ -342,6 +342,15 @@ class SlotSpoolIdentity:
     subtype: str | None
     color_name: str | None
     rgba: str | None
+    # The rest of the spool's colour, for drawing it rather than naming it
+    # (#3159). A tray record carries one hex and nothing else, so a two-tone
+    # or glittery spool renders as its base colour unless the binding says
+    # otherwise -- and on a farm of third-party spools that is exactly the
+    # swatch somebody is comparing against the slicer's colour. Same shape the
+    # inventory row stores: ``extra_colors`` a comma-separated hex list,
+    # ``effect_type`` one of the client's FilamentEffect names.
+    extra_colors: str | None = None
+    effect_type: str | None = None
 
     def to_dict(self) -> dict:
         return {
@@ -350,6 +359,8 @@ class SlotSpoolIdentity:
             "subtype": self.subtype,
             "color_name": self.color_name,
             "rgba": self.rgba,
+            "extra_colors": self.extra_colors,
+            "effect_type": self.effect_type,
         }
 
 
@@ -367,6 +378,8 @@ def _identity_from_internal(spool) -> SlotSpoolIdentity:
         subtype=_clean(spool.subtype),
         color_name=_clean(spool.color_name),
         rgba=_clean(spool.rgba),
+        extra_colors=_clean(spool.extra_colors),
+        effect_type=_clean(spool.effect_type),
     )
 
 
@@ -408,6 +421,11 @@ def _identity_from_spoolman(spool_dict: dict) -> SlotSpoolIdentity | None:
         subtype=_clean(mapped.get("subtype")),
         color_name=color_name,
         rgba=_clean(mapped.get("rgba")),
+        # `_map_spoolman_spool` builds `extra_colors` from Spoolman's own
+        # multi-colour hexes and pins `effect_type` to None, because Spoolman
+        # has no field for it -- see the note there.
+        extra_colors=_clean(mapped.get("extra_colors")),
+        effect_type=_clean(mapped.get("effect_type")),
     )
 
 

+ 44 - 0
backend/tests/unit/services/test_filament_deficit.py

@@ -991,8 +991,52 @@ class TestSlotSpoolIdentity:
             # naming this slot from the hex is exactly the bug.
             "color_name": "Orange",
             "rgba": "FEC600FF",
+            # A plain single-colour spool: nothing extra to draw (#3159).
+            "extra_colors": None,
+            "effect_type": None,
         }
 
+    @pytest.mark.asyncio
+    async def test_internal_mode_carries_the_rest_of_the_colour(self, db_session, printer_factory):
+        """A tray record holds one hex, so the binding is the only source for the rest.
+
+        Without this the Print dialog's slot picker (#3159) draws a two-tone or
+        glittery spool as its base colour -- the one comparison the picker
+        exists to make, made against the wrong swatch.
+        """
+        from backend.app.services.filament_deficit import build_slot_materials
+
+        printer = await printer_factory(model="H2C")
+        spool = Spool(
+            brand="eSUN",
+            material="PLA",
+            subtype="Multicolor",
+            color_name="Rainbow",
+            rgba="FF0000FF",
+            extra_colors="00FF00,0000FF",
+            effect_type="Glitter",
+            label_weight=1000,
+            weight_used=0.0,
+        )
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(spool)
+        await _assign(db_session, printer_id=printer.id, spool_id=spool.id, ams_id=2, tray_id=0)
+
+        patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=False, model="H2C")
+        for p in patches:
+            p.start()
+        try:
+            slots = await build_slot_materials(db_session, printer.id)
+        finally:
+            for p in patches:
+                p.stop()
+
+        identity = slots[0].spool
+        assert identity is not None
+        assert identity.extra_colors == "00FF00,0000FF"
+        assert identity.effect_type == "Glitter"
+
     @pytest.mark.asyncio
     async def test_blank_fields_become_null_so_the_client_can_fall_back(self, db_session, printer_factory):
         """Per-field, not all-or-nothing: an unnamed colour still falls back to

+ 4 - 0
backend/tests/unit/test_inventory_remain_endpoint.py

@@ -206,4 +206,8 @@ class TestGetInventoryRemain:
             "subtype": "Basic",
             "color_name": "Orange",
             "rgba": "FEC600FF",
+            # The rest of the colour, for the slot picker's swatch (#3159).
+            # None here: this spool is a single flat colour.
+            "extra_colors": None,
+            "effect_type": None,
         }

+ 32 - 20
frontend/src/__tests__/components/FilamentMapping.test.tsx

@@ -10,13 +10,29 @@
  */
 
 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import { screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
+import { screen, waitFor, cleanup, fireEvent, within } from '@testing-library/react';
 import { http, HttpResponse } from 'msw';
 import { render } from '../utils';
 import { server } from '../mocks/server';
 import { FilamentMapping } from '../../components/PrintModal/FilamentMapping';
 import type { PrinterStatus } from '../../api/client';
 
+/**
+ * Open the slot picker and hand back its listbox.
+ *
+ * The picker replaced a native `<select>` (#3159). A `<select>` keeps every
+ * `<option>` mounted whether or not it is open, so these assertions used to
+ * read the closed control; a listbox renders its rows only while open. They
+ * are about what the user is *offered*, so they open it first — and scope to
+ * the listbox, because the chosen slot's label now also appears on the
+ * trigger.
+ */
+async function openSlotPicker(): Promise<HTMLElement> {
+  const trigger = await waitFor(() => screen.getAllByRole('combobox')[0]);
+  fireEvent.click(trigger);
+  return await waitFor(() => screen.getByRole('listbox'));
+}
+
 const mockFilamentReqs = {
   filaments: [
     // Required filament asks for the LEFT extruder (nozzle_id=1).
@@ -92,19 +108,18 @@ describe('FilamentMapping — FTS routing', () => {
       />,
     );
 
-    // Both PLA and PETG slots must appear in the dropdown despite ams_extruder_map
-    // being empty and the requirement asking for nozzle 1. Without the FTS guard
-    // the dropdown would render only the "-- Select slot --" placeholder.
-    await waitFor(() => {
-      expect(screen.getByText(/Bambu PLA/)).toBeInTheDocument();
-    });
-    expect(screen.getByText(/Bambu PETG/)).toBeInTheDocument();
+    // Both PLA and PETG slots must be offered despite ams_extruder_map being
+    // empty and the requirement asking for nozzle 1. Without the FTS guard the
+    // picker would offer only the "-- Select slot --" placeholder.
+    const listbox = await openSlotPicker();
+    expect(within(listbox).getByText(/Bambu PLA/)).toBeInTheDocument();
+    expect(within(listbox).getByText(/Bambu PETG/)).toBeInTheDocument();
 
     // Each slot is badged for the switch INLET its AMS is plumbed into, using
     // the same L-for-In-A lettering as the printer card. Both slots are in
     // AMS 0, which is on In-A.
-    expect(screen.getByText(/Bambu PETG/).textContent).toMatch(/\[L\]/);
-    expect(screen.getByText(/Bambu PLA/).textContent).toMatch(/\[L\]/);
+    expect(within(listbox).getByText(/Bambu PETG/).textContent).toMatch(/\[L\]/);
+    expect(within(listbox).getByText(/Bambu PLA/).textContent).toMatch(/\[L\]/);
   });
 
   it('does not badge slots whose AMS has no inlet binding yet', async () => {
@@ -135,10 +150,8 @@ describe('FilamentMapping — FTS routing', () => {
       />,
     );
 
-    await waitFor(() => {
-      expect(screen.getByText(/Bambu PETG/)).toBeInTheDocument();
-    });
-    expect(screen.getByText(/Bambu PETG/).textContent).not.toMatch(/\[[LR]\]/);
+    const listbox = await openSlotPicker();
+    expect(within(listbox).getByText(/Bambu PETG/).textContent).not.toMatch(/\[[LR]\]/);
   });
 
   it('renders the per-slot force-color-match checkbox in printer mode (#1717)', async () => {
@@ -258,12 +271,11 @@ describe('FilamentMapping — FTS routing', () => {
     );
 
     // Required nozzle is 1 (LEFT) and AMS 0 is wired to extruder 0 (RIGHT).
-    // Both slots must STILL appear so the user can pick them — explicitly the
-    // cross-extruder scenario the #1722 fix unblocks.
-    await waitFor(() => {
-      expect(screen.getByText(/Bambu PLA/)).toBeInTheDocument();
-    });
-    expect(screen.getByText(/Bambu PETG/)).toBeInTheDocument();
+    // Both slots must STILL be offered so the user can pick them — explicitly
+    // the cross-extruder scenario the #1722 fix unblocks.
+    const listbox = await openSlotPicker();
+    expect(within(listbox).getByText(/Bambu PLA/)).toBeInTheDocument();
+    expect(within(listbox).getByText(/Bambu PETG/)).toBeInTheDocument();
   });
 
   it('renders sub-brand + material-disambiguated colour on the required side (#1718)', async () => {

+ 113 - 79
frontend/src/__tests__/components/FilamentOverride.test.tsx

@@ -6,7 +6,7 @@
  */
 
 import { describe, it, expect, vi, afterEach } from 'vitest';
-import { screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
+import { screen, fireEvent, cleanup, waitFor, within } from '@testing-library/react';
 import { http, HttpResponse } from 'msw';
 import { render } from '../utils';
 import { server } from '../mocks/server';
@@ -27,6 +27,33 @@ const defaultAvailable = [
 
 const mockOnChange = vi.fn();
 
+/**
+ * Open an override picker and hand back its rows.
+ *
+ * These assertions used to read `select.querySelectorAll('option')`. The
+ * control gained a swatch per choice (#3159), which an `<option>` cannot
+ * render, so it is a listbox now: its rows exist only while it is open, and
+ * each carries its identity on `data-value` the way `<option value>` did.
+ */
+function openPicker(index = 0): HTMLElement[] {
+  fireEvent.click(screen.getAllByRole('combobox')[index]);
+  return within(screen.getByRole('listbox')).getAllByRole('option');
+}
+
+/** The identities the picker offers, in order — the old `option.value` list. */
+function optionValues(index = 0): (string | null)[] {
+  return openPicker(index).map((o) => o.getAttribute('data-value'));
+}
+
+/**
+ * What the closed control reads as. With nothing overridden this is the
+ * "original filament" row, which is where the #1718 label resolution lands —
+ * previously read off `option[value=""]`.
+ */
+function triggerText(index = 0): string {
+  return screen.getAllByRole('combobox')[index].textContent ?? '';
+}
+
 afterEach(() => {
   cleanup();
   vi.clearAllMocks();
@@ -122,15 +149,10 @@ describe('FilamentOverride', () => {
         />
       );
 
-      const select = screen.getByRole('combobox');
-      const options = select.querySelectorAll('option');
-
-      // 1 default "Original" option + 2 PLA options (not PETG)
-      expect(options).toHaveLength(3);
-
-      // Verify no PETG option values exist
-      const optionValues = Array.from(options).map((o) => o.getAttribute('value'));
-      expect(optionValues).not.toContain('PETG|#0000FF');
+      // 1 default "Original" row + 2 PLA rows (not PETG)
+      const values = optionValues();
+      expect(values).toHaveLength(3);
+      expect(values).not.toContain('PETG|#0000FF');
     });
 
     it('shows all same-type options regardless of color', () => {
@@ -149,11 +171,8 @@ describe('FilamentOverride', () => {
         />
       );
 
-      const select = screen.getByRole('combobox');
-      const options = select.querySelectorAll('option');
-
-      // 1 default "Original" option + 3 PLA color options
-      expect(options).toHaveLength(4);
+      // 1 default "Original" row + 3 PLA colour rows
+      expect(optionValues()).toHaveLength(4);
     });
   });
 
@@ -173,9 +192,7 @@ describe('FilamentOverride', () => {
         />
       );
 
-      const select = screen.getByRole('combobox');
-      const options = Array.from(select.querySelectorAll('option'));
-      const optionTexts = options.map((o) => o.textContent);
+      const optionTexts = openPicker().map((o) => o.textContent);
 
       // Should show "PLA Basic" and "PLA Matte", not just "PLA"
       expect(optionTexts.some((t) => t?.includes('PLA Basic'))).toBe(true);
@@ -196,11 +213,9 @@ describe('FilamentOverride', () => {
         />
       );
 
-      const select = screen.getByRole('combobox');
-      const options = Array.from(select.querySelectorAll('option'));
-      // Non-default option should show "PLA" as the type fallback
-      const nonDefaultOptions = options.filter((o) => o.getAttribute('value') !== '');
-      expect(nonDefaultOptions[0].textContent).toContain('PLA');
+      // Non-default row should show "PLA" as the type fallback
+      const nonDefault = openPicker().filter((o) => o.getAttribute('data-value') !== '');
+      expect(nonDefault[0].textContent).toContain('PLA');
     });
   });
 
@@ -226,15 +241,11 @@ describe('FilamentOverride', () => {
         />
       );
 
-      const select = screen.getByRole('combobox');
-      const options = select.querySelectorAll('option');
-
       // 1 default + 1 PLA with extruder_id=0 (extruder_id=1 is filtered out)
-      expect(options).toHaveLength(2);
-
-      const optionValues = Array.from(options).map((o) => o.getAttribute('value'));
-      expect(optionValues).toContain('PLA|#FF0000');
-      expect(optionValues).not.toContain('PLA|#00FF00');
+      const values = optionValues();
+      expect(values).toHaveLength(2);
+      expect(values).toContain('PLA|#FF0000');
+      expect(values).not.toContain('PLA|#00FF00');
     });
 
     it('shows all filaments when nozzle_id is undefined', () => {
@@ -258,11 +269,8 @@ describe('FilamentOverride', () => {
         />
       );
 
-      const select = screen.getByRole('combobox');
-      const options = select.querySelectorAll('option');
-
-      // 1 default + 2 PLA options (no nozzle filtering)
-      expect(options).toHaveLength(3);
+      // 1 default + 2 PLA rows (no nozzle filtering)
+      expect(optionValues()).toHaveLength(3);
     });
 
     it('includes filaments with null extruder_id', () => {
@@ -287,16 +295,12 @@ describe('FilamentOverride', () => {
         />
       );
 
-      const select = screen.getByRole('combobox');
-      const options = select.querySelectorAll('option');
-
       // 1 default + extruder_id=0 + extruder_id=null (extruder_id=1 filtered out)
-      expect(options).toHaveLength(3);
-
-      const optionValues = Array.from(options).map((o) => o.getAttribute('value'));
-      expect(optionValues).toContain('PLA|#FF0000');
-      expect(optionValues).toContain('PLA|#00FF00');
-      expect(optionValues).not.toContain('PLA|#FFFFFF');
+      const values = optionValues();
+      expect(values).toHaveLength(3);
+      expect(values).toContain('PLA|#FF0000');
+      expect(values).toContain('PLA|#00FF00');
+      expect(values).not.toContain('PLA|#FFFFFF');
     });
   });
 
@@ -311,8 +315,8 @@ describe('FilamentOverride', () => {
         />
       );
 
-      const select = screen.getByRole('combobox');
-      fireEvent.change(select, { target: { value: 'PLA|#00FF00' } });
+      const row = openPicker().find((o) => o.getAttribute('data-value') === 'PLA|#00FF00');
+      fireEvent.click(row as HTMLElement);
 
       expect(mockOnChange).toHaveBeenCalledWith({
         1: { type: 'PLA', color: '#00FF00' },
@@ -333,13 +337,60 @@ describe('FilamentOverride', () => {
         />
       );
 
-      const select = screen.getByRole('combobox');
-      fireEvent.change(select, { target: { value: '' } });
+      const row = openPicker().find((o) => o.getAttribute('data-value') === '');
+      fireEvent.click(row as HTMLElement);
 
       expect(mockOnChange).toHaveBeenCalledWith({});
     });
   });
 
+  describe('colour swatches (#3159)', () => {
+    it('draws the colour of every filament it offers, and of the original', () => {
+      render(
+        <FilamentOverride
+          filamentReqs={defaultFilamentReqs}
+          availableFilaments={defaultAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />
+      );
+
+      const rows = openPicker();
+      // The original-filament row plus the two same-type candidates: model
+      // mode pools across printers, so there is no slot binding behind an
+      // entry and each swatch is the plain tray colour.
+      expect(rows).toHaveLength(3);
+      for (const row of rows) {
+        expect(within(row).getByTestId('filament-swatch')).toBeInTheDocument();
+      }
+    });
+
+    it('prints each candidate hex and marks the one the slice asked for', () => {
+      render(
+        <FilamentOverride
+          filamentReqs={defaultFilamentReqs}
+          availableFilaments={defaultAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />
+      );
+
+      const rows = openPicker();
+      const listbox = screen.getByRole('listbox');
+      expect(within(listbox).getByText('#00FF00')).toBeInTheDocument();
+      // #FF0000 twice: the original-filament row states what the slice asked
+      // for, and one candidate carries it.
+      expect(within(listbox).getAllByText('#FF0000')).toHaveLength(2);
+
+      // The marker belongs on the candidate, not on the original — it answers
+      // "which of these is the colour the slice asked for", which is the
+      // judgement the colour names cannot be trusted to make.
+      const marked = rows.filter((r) => within(r).queryByTitle('Exact colour match'));
+      expect(marked).toHaveLength(1);
+      expect(marked[0].getAttribute('data-value')).toBe('PLA|#FF0000');
+    });
+  });
+
   describe('original-label SKU resolution (#1718)', () => {
     it('uses the builtin filament name when tray_info_idx maps to a known SKU', async () => {
       // Stamped by Bambu Studio when slicing with PLA Matte Charcoal: 3MF
@@ -367,15 +418,11 @@ describe('FilamentOverride', () => {
         />,
       );
 
-      // Wait for the queries to resolve and the resolved label to land in
-      // the dropdown's "original" placeholder option. The tooltip on the
-      // color swatch carries the same text, so we scope to the option to
-      // avoid the multi-match.
-      await waitFor(() => {
-        const select = screen.getByRole('combobox');
-        const placeholder = select.querySelector('option[value=""]');
-        expect(placeholder?.textContent).toMatch(/Bambu PLA Matte/);
-      });
+      // Wait for the queries to resolve and the resolved label to land on the
+      // control, which reads as the "original filament" row while nothing is
+      // overridden. Reading the closed trigger rather than the open list keeps
+      // this clear of the swatch tooltip, which carries the same text.
+      await waitFor(() => expect(triggerText()).toMatch(/Bambu PLA Matte/));
     });
 
     it('prefers the cloud user-preset name over the builtin entry for the same id', async () => {
@@ -405,11 +452,7 @@ describe('FilamentOverride', () => {
         />,
       );
 
-      await waitFor(() => {
-        const select = screen.getByRole('combobox');
-        const placeholder = select.querySelector('option[value=""]');
-        expect(placeholder?.textContent).toMatch(/My House PLA/);
-      });
+      await waitFor(() => expect(triggerText()).toMatch(/My House PLA/));
       // The builtin fallback must NOT bleed through anywhere — neither the
       // placeholder option nor the tooltip.
       expect(screen.queryByText(/Bambu PLA Basic/)).not.toBeInTheDocument();
@@ -452,11 +495,7 @@ describe('FilamentOverride', () => {
         />,
       );
 
-      await waitFor(() => {
-        const select = screen.getByRole('combobox');
-        const placeholder = select.querySelector('option[value=""]');
-        expect(placeholder?.textContent).toMatch(/Bambu PLA Matte \(Charcoal\)/);
-      });
+      await waitFor(() => expect(triggerText()).toMatch(/Bambu PLA Matte \(Charcoal\)/));
     });
 
     it('disambiguates per slot when two slots share a hex but differ in material', async () => {
@@ -497,10 +536,9 @@ describe('FilamentOverride', () => {
       );
 
       await waitFor(() => {
-        const selects = screen.getAllByRole('combobox');
-        expect(selects).toHaveLength(2);
-        expect(selects[0].querySelector('option[value=""]')?.textContent).toMatch(/Bambu PLA Matte \(Charcoal\)/);
-        expect(selects[1].querySelector('option[value=""]')?.textContent).toMatch(/Bambu PLA Basic \(Black\)/);
+        expect(screen.getAllByRole('combobox')).toHaveLength(2);
+        expect(triggerText(0)).toMatch(/Bambu PLA Matte \(Charcoal\)/);
+        expect(triggerText(1)).toMatch(/Bambu PLA Basic \(Black\)/);
       });
     });
 
@@ -536,10 +574,8 @@ describe('FilamentOverride', () => {
       // Wait for the builtin lookup to land so we know the row mounted; the
       // colour fallback to getColorName for #FF0000 produces "Red"-shaped text.
       await waitFor(() => {
-        const select = screen.getByRole('combobox');
-        const placeholder = select.querySelector('option[value=""]');
-        expect(placeholder?.textContent).toMatch(/Bambu PLA Matte/);
-        expect(placeholder?.textContent).not.toMatch(/null/);
+        expect(triggerText()).toMatch(/Bambu PLA Matte/);
+        expect(triggerText()).not.toMatch(/null/);
       });
     });
 
@@ -571,9 +607,7 @@ describe('FilamentOverride', () => {
       await waitFor(() => {
         expect(screen.getByText('(25g)')).toBeInTheDocument();
       });
-      const select = screen.getByRole('combobox');
-      const placeholder = select.querySelector('option[value=""]');
-      expect(placeholder?.textContent).toMatch(/PLA \(/);
+      expect(triggerText()).toMatch(/PLA \(/);
     });
   });
 });

+ 78 - 17
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -8,7 +8,7 @@
 
 import { describe, it, expect, vi, beforeEach } from 'vitest';
 import type React from 'react';
-import { screen, waitFor, fireEvent, render as rtlRender } from '@testing-library/react';
+import { screen, waitFor, fireEvent, within, render as rtlRender } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { BrowserRouter } from 'react-router-dom';
@@ -59,6 +59,32 @@ const createMockQueueItem = (overrides: Partial<PrintQueueItem> = {}): PrintQueu
   ...overrides,
 });
 
+/**
+ * Choose a row in a SlotPicker (#3159).
+ *
+ * The slot and override controls were native `<select>`s until a swatch had to
+ * go next to each choice, which an `<option>` cannot render. They are listboxes
+ * now, so a test picks by opening the control and clicking a row instead of
+ * calling `selectOptions` with a value. `triggerName` matches the control's
+ * aria-label ("Printer slot for …" / "Filament override for …"), which is a
+ * steadier handle than the old "find the select that has an option with this
+ * value".
+ */
+async function pickFromSlotPicker(
+  user: ReturnType<typeof userEvent.setup>,
+  triggerName: RegExp,
+  rowText: RegExp,
+): Promise<void> {
+  const trigger = await waitFor(() => {
+    const found = screen.getAllByRole('combobox', { name: triggerName });
+    if (found.length === 0) throw new Error(`no picker matching ${triggerName}`);
+    return found[0];
+  });
+  await user.click(trigger);
+  const listbox = await screen.findByRole('listbox');
+  await user.click(within(listbox).getByText(rowText));
+}
+
 describe('PrintModal', () => {
   const mockOnClose = vi.fn();
   const mockOnSuccess = vi.fn();
@@ -1778,15 +1804,10 @@ describe('PrintModal — per-plate filament mapping (#2551 follow-up)', () => {
     await waitFor(() => expect(screen.getByText(/Filament Mapping — Plate 1/)).toBeInTheDocument());
 
     // Force plate 1's slot 1 onto the black tray (1) instead of the auto-matched red (0).
+    // A2 is AMS 0 / tray 1, i.e. global tray id 1 — the value the old
+    // `selectOptions(..., '1')` picked.
     await user.click(screen.getByText(/Filament Mapping — Plate 1/));
-    const traySelects = await waitFor(() => {
-      const found = screen.getAllByRole('combobox').filter((el) =>
-        Array.from((el as HTMLSelectElement).options).some((o) => o.value === '1'),
-      );
-      if (found.length === 0) throw new Error('no tray select rendered');
-      return found;
-    });
-    await user.selectOptions(traySelects[0], '1');
+    await pickFromSlotPicker(user, /printer slot for/i, /^A2:/);
 
     // Now move the job to the other printer.
     await user.click(screen.getByText('X1 Carbon')); // deselect
@@ -2347,6 +2368,47 @@ describe('PrintModal — override survives "Any model" -> "Specific Printer" (#3
     await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
   };
 
+  // The slot picker's list is portaled to <body>, outside the dialog's own
+  // DOM, and the dialog closes both on a click outside itself and on Escape.
+  // Either would be a bad regression: picking a slot, or dismissing the list,
+  // would throw away everything typed into the dialog (#3159).
+  it('does not close the dialog when a slot is picked from the portaled list', async () => {
+    const user = userEvent.setup();
+    render(<PrintModal mode="edit-queue-item" archiveId={1} archiveName="Job" queueItem={anyP2SItem()} onClose={mockOnClose} />);
+
+    await moveToPrinter01(user);
+    // The panel is collapsed by default; the picker only exists once open.
+    await user.click(await screen.findByText(/filament mapping/i));
+
+    const trigger = await screen.findByRole('combobox', { name: /printer slot for/i });
+    await user.click(trigger);
+    const listbox = await screen.findByRole('listbox');
+    await user.click(within(listbox).getAllByRole('option')[1]);
+
+    expect(mockOnClose).not.toHaveBeenCalled();
+    expect(screen.getByText(/filament mapping/i)).toBeInTheDocument();
+  });
+
+  it('closes only the slot list on Escape, not the dialog', async () => {
+    const user = userEvent.setup();
+    render(<PrintModal mode="edit-queue-item" archiveId={1} archiveName="Job" queueItem={anyP2SItem()} onClose={mockOnClose} />);
+
+    await moveToPrinter01(user);
+    await user.click(await screen.findByText(/filament mapping/i));
+
+    await user.click(await screen.findByRole('combobox', { name: /printer slot for/i }));
+    await screen.findByRole('listbox');
+    await user.keyboard('{Escape}');
+
+    await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
+    expect(mockOnClose).not.toHaveBeenCalled();
+
+    // And a second Escape, with no list open, still closes the dialog — the
+    // picker must not have swallowed the key permanently.
+    await user.keyboard('{Escape}');
+    expect(mockOnClose).toHaveBeenCalled();
+  });
+
   it('matches the chosen printer against the override and keeps it on the item', async () => {
     const user = userEvent.setup();
     render(<PrintModal mode="edit-queue-item" archiveId={1} archiveName="Job" queueItem={anyP2SItem()} onClose={mockOnClose} />);
@@ -2486,14 +2548,13 @@ describe('PrintModal — override survives "Any model" -> "Specific Printer" (#3
       return select as HTMLSelectElement;
     });
     await user.selectOptions(modelSelect, 'P2S');
-    const overrideSelect = await waitFor(() => {
-      const select = screen
-        .getAllByRole('combobox')
-        .find((el) => [...(el as HTMLSelectElement).options].some((o) => o.value === `PLA|${BONE_WHITE}`));
-      if (!select) throw new Error('override select not rendered');
-      return select as HTMLSelectElement;
-    });
-    await user.selectOptions(overrideSelect, `PLA|${BONE_WHITE}`);
+    // Matched on the hex the row now prints (#3159) rather than on the option
+    // value, so the assertion names the colour the user is actually picking.
+    await pickFromSlotPicker(
+      user,
+      /filament override for/i,
+      new RegExp(BONE_WHITE.toUpperCase().replace('#', '#?')),
+    );
 
     await moveToPrinter01(user);
     await waitFor(() => expect(screen.getByText(/filament mapping/i)).toBeInTheDocument());

+ 8 - 2
frontend/src/__tests__/components/PrintModalCrossModel.test.tsx

@@ -9,7 +9,7 @@
  */
 
 import { describe, it, expect, beforeEach } from 'vitest';
-import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { screen, waitFor, fireEvent, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { http, HttpResponse } from 'msw';
 import { render } from '../utils';
@@ -94,8 +94,14 @@ describe('PrintModal cross-model mode', () => {
 
     // PLA Matte is loaded only on the H2D. It has to be offered anyway: the job
     // can land there, and choosing it simply narrows which candidates match.
+    // The control is a listbox since it gained a swatch per choice (#3159), so
+    // its rows exist only while it is open.
+    const trigger = await screen.findByRole('combobox', { name: /filament override for/i });
+    fireEvent.click(trigger);
     await waitFor(() => {
-      const options = screen.getAllByRole('option').map((o) => o.textContent ?? '');
+      const options = within(screen.getByRole('listbox'))
+        .getAllByRole('option')
+        .map((o) => o.textContent ?? '');
       expect(options.some((o) => o.includes('PLA Matte'))).toBe(true);
       expect(options.some((o) => o.includes('PLA Basic'))).toBe(true);
     });

+ 183 - 0
frontend/src/__tests__/components/SlotPicker.test.tsx

@@ -0,0 +1,183 @@
+/**
+ * Tests for SlotPicker — the filament chooser that replaced the Print /
+ * Schedule dialog's native `<select>`s so each choice can carry its colour
+ * (#3159).
+ *
+ * The point of the control is the swatch, so these pin that a colour is drawn
+ * for every row and for the chosen one; that a slot with no inventory binding
+ * still gets the tray's own colour; and that the keyboard still works, because
+ * the `<select>` this replaced was operable without a mouse and losing that
+ * would be a regression dressed up as a feature.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { screen, within, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { SlotPicker, type SlotPickerOption } from '../../components/PrintModal/SlotPicker';
+import { displayHex } from '../../components/PrintModal/displayHex';
+
+const OPTIONS: SlotPickerOption[] = [
+  {
+    value: '0',
+    label: 'A1: Devil Design PLA Basic (Orange)',
+    meta: ' - 820g left',
+    rgba: '#FEC600',
+    hex: '#FEC600',
+  },
+  {
+    value: '1',
+    // A slot configured on the printer with no inventory spool behind it: the
+    // tray colour is all there is, and it still has to draw.
+    label: 'A2: PLA (Black)',
+    rgba: '#000000',
+    hex: '#000000',
+  },
+  {
+    value: '2',
+    label: 'A3: eSUN PLA Silk (Rainbow)',
+    rgba: '#FF0000',
+    extraColors: '00FF00,0000FF',
+    subtype: 'Multicolor',
+    hex: '#FF0000',
+    exactMatch: true,
+  },
+];
+
+function renderPicker(props: Partial<React.ComponentProps<typeof SlotPicker>> = {}) {
+  const onChange = props.onChange ?? vi.fn();
+  render(
+    <SlotPicker
+      value=""
+      options={OPTIONS}
+      placeholder="-- Select slot --"
+      ariaLabel="Printer slot for PLA"
+      exactMatchLabel="Exact colour match"
+      {...props}
+      onChange={onChange}
+    />,
+  );
+  return { onChange };
+}
+
+async function open(user: ReturnType<typeof userEvent.setup>) {
+  await user.click(screen.getByRole('combobox', { name: /printer slot for pla/i }));
+  return await screen.findByRole('listbox');
+}
+
+describe('displayHex', () => {
+  it('prints six digits for an opaque colour and eight for a see-through one', () => {
+    // Alpha is kept only when it carries information: a Clear filament must
+    // not read as if it were solid, and an opaque spool must not be given a
+    // meaningless "FF" tail.
+    expect(displayHex('#FEC600')).toBe('#FEC600');
+    expect(displayHex('FEC600FF')).toBe('#FEC600');
+    expect(displayHex('#00AAFF80')).toBe('#00AAFF80');
+    expect(displayHex('#00000000')).toBe('#00000000');
+  });
+
+  it('prints nothing rather than something misleading', () => {
+    expect(displayHex(null)).toBeNull();
+    expect(displayHex('')).toBeNull();
+    expect(displayHex('#ABC')).toBeNull();
+    expect(displayHex('not-a-colour')).toBeNull();
+  });
+});
+
+describe('SlotPicker', () => {
+  it('draws a swatch for every offered slot', async () => {
+    const user = userEvent.setup();
+    renderPicker();
+
+    const listbox = await open(user);
+    // One per option. The placeholder has no colour and deliberately draws
+    // nothing rather than a grey swatch, which would read as a grey spool.
+    expect(within(listbox).getAllByTestId('filament-swatch')).toHaveLength(OPTIONS.length);
+  });
+
+  it('draws the chosen slot\'s colour on the closed control', async () => {
+    renderPicker({ value: '1' });
+
+    const trigger = screen.getByRole('combobox', { name: /printer slot for pla/i });
+    const swatch = within(trigger).getByTestId('filament-swatch');
+    expect(swatch).toBeInTheDocument();
+    expect(trigger).toHaveTextContent('A2: PLA (Black)');
+  });
+
+  it('renders a multi-colour spool as a gradient, not its base colour', async () => {
+    const user = userEvent.setup();
+    renderPicker();
+
+    const listbox = await open(user);
+    const rainbow = within(listbox).getByRole('option', { name: /eSUN PLA Silk/ });
+    const swatch = within(rainbow).getByTestId('filament-swatch');
+    // `Multicolor` is what turns the stops into a conic sweep; a slot with no
+    // extra stops would be a flat linear-gradient of one colour.
+    expect(swatch.style.backgroundImage).toContain('conic-gradient');
+    expect(swatch.style.backgroundImage).toContain('#00FF00');
+  });
+
+  it('shows each slot\'s hex and marks the one that matches exactly', async () => {
+    const user = userEvent.setup();
+    renderPicker();
+
+    const listbox = await open(user);
+    expect(within(listbox).getByText('#FEC600')).toBeInTheDocument();
+    // The marker is what answers "is this a real mismatch or two names for the
+    // same hex" without leaving the dialog.
+    const exact = within(listbox).getByTitle('Exact colour match');
+    expect(within(listbox).getByRole('option', { name: /eSUN PLA Silk/ })).toContainElement(exact);
+  });
+
+  it('picks a slot with the keyboard', async () => {
+    const user = userEvent.setup();
+    const { onChange } = renderPicker();
+
+    const trigger = screen.getByRole('combobox', { name: /printer slot for pla/i });
+    trigger.focus();
+    // Row 0 is the placeholder, so two presses land on the second slot.
+    await user.keyboard('{ArrowDown}');
+    await screen.findByRole('listbox');
+    await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
+
+    expect(onChange).toHaveBeenCalledWith('1');
+    await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
+  });
+
+  it('closes on Escape without letting the dialog around it see the key', async () => {
+    const user = userEvent.setup();
+    const onDialogEscape = vi.fn();
+    document.addEventListener('keydown', onDialogEscape);
+    try {
+      renderPicker();
+      await open(user);
+      await user.keyboard('{Escape}');
+
+      await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
+      // The picker stops propagation, so a dialog listening for Escape on the
+      // document never sees it and stays open (#3159).
+      expect(onDialogEscape).not.toHaveBeenCalled();
+    } finally {
+      document.removeEventListener('keydown', onDialogEscape);
+    }
+  });
+
+  it('reports the value the caller gave the row, not its position', async () => {
+    const user = userEvent.setup();
+    const { onChange } = renderPicker();
+
+    const listbox = await open(user);
+    await user.click(within(listbox).getByRole('option', { name: /Devil Design/ }));
+    expect(onChange).toHaveBeenCalledWith('0');
+  });
+
+  it('offers the placeholder as a real choice so a slot can be cleared', async () => {
+    const user = userEvent.setup();
+    const { onChange } = renderPicker({ value: '1' });
+
+    await user.click(screen.getByRole('combobox', { name: /printer slot for pla/i }));
+    const listbox = await screen.findByRole('listbox');
+    await user.click(within(listbox).getByRole('option', { name: /Select slot/ }));
+    expect(onChange).toHaveBeenCalledWith('');
+  });
+});

+ 76 - 0
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -397,6 +397,82 @@ describe('computeAmsMapping', () => {
   });
 });
 
+describe('buildLoadedFilaments - slot colour for the picker (#3159)', () => {
+  it('carries the bound spool\'s extra stops and effect onto the slot', () => {
+    // A tray record holds one hex and nothing else, so a two-tone or glittery
+    // spool would draw as its base colour in the Print dialog's slot picker.
+    // The binding is the only place the rest of the colour exists.
+    const status = createPrinterStatus([
+      { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' }] },
+    ]);
+    const slotSpools = new Map([
+      [
+        0,
+        {
+          brand: 'eSUN',
+          material: 'PLA',
+          subtype: 'Multicolor',
+          color_name: 'Rainbow',
+          rgba: 'FF0000FF',
+          extra_colors: '00FF00,0000FF',
+          effect_type: null,
+        },
+      ],
+    ]);
+
+    const [slot] = buildLoadedFilaments(status, slotSpools);
+
+    expect(slot).toMatchObject({
+      extraColors: '00FF00,0000FF',
+      spoolSubtype: 'Multicolor',
+      colorName: 'Rainbow',
+    });
+    expect(slot.effectType).toBeUndefined();
+  });
+
+  it('leaves a slot with no binding to its own tray colour', () => {
+    // Configure Slot / set-on-the-printer slots have no inventory spool behind
+    // them. The firmware reports no stops and never will, so the picker draws a
+    // solid swatch -- which is the honest one, not a missing feature.
+    const status = createPrinterStatus([
+      { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: '00AAFFFF', tray_info_idx: 'GFA00' }] },
+    ]);
+
+    const [slot] = buildLoadedFilaments(status);
+
+    expect(slot.color).toBe('#00AAFF');
+    expect(slot.extraColors).toBeUndefined();
+    expect(slot.effectType).toBeUndefined();
+    expect(slot.spoolSubtype).toBeUndefined();
+  });
+
+  it('carries them for the external spool holder too', () => {
+    const status = createPrinterStatus(
+      [],
+      [{ id: 254, tray_type: 'PLA', tray_color: 'AABBCCFF', tray_info_idx: 'GFA00' }] as PrinterStatus['vt_tray'],
+    );
+    const slotSpools = new Map([
+      [
+        254,
+        {
+          brand: 'Devil Design',
+          material: 'PLA',
+          subtype: 'Galaxy',
+          color_name: 'Galaxy Black',
+          rgba: 'AABBCCFF',
+          extra_colors: null,
+          effect_type: 'Glitter',
+        },
+      ],
+    ]);
+
+    const [slot] = buildLoadedFilaments(status, slotSpools);
+
+    expect(slot.effectType).toBe('Glitter');
+    expect(slot.spoolSubtype).toBe('Galaxy');
+  });
+});
+
 describe('buildLoadedFilaments - nozzle awareness', () => {
   it('sets extruderId from ams_extruder_map', () => {
     const status = createPrinterStatus([

+ 9 - 0
frontend/src/api/client.ts

@@ -3730,6 +3730,15 @@ export interface SlotSpoolIdentity {
   subtype: string | null;
   color_name: string | null;
   rgba: string | null;
+  /** Comma-separated hex stops for a multi-colour spool, and the effect
+   *  overlay — the two things a tray record cannot carry, so a slot swatch
+   *  can be drawn the way the inventory row draws it (#3159).
+   *
+   *  Optional rather than required: the backend sends both keys on every
+   *  binding, but a frontend running against an older one gets neither, and
+   *  every reader here already falls back to a solid swatch. */
+  extra_colors?: string | null;
+  effect_type?: string | null;
 }
 
 export interface InventoryRemainResponse {

+ 47 - 30
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -5,8 +5,10 @@ import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palett
 import { api } from '../../api/client';
 import type { SlotSpoolIdentity } from '../../api/client';
 import { useFilamentMapping } from '../../hooks/useFilamentMapping';
-import { getGlobalTrayId, effectivePreferLowest, FTS_INLET_SIDE } from '../../utils/amsHelpers';
+import { getGlobalTrayId, effectivePreferLowest, FTS_INLET_SIDE, normalizeColorForCompare } from '../../utils/amsHelpers';
 import { disambiguateColorNames, getColorName } from '../../utils/colors';
+import { SlotPicker } from './SlotPicker';
+import { displayHex } from './displayHex';
 import { useFilamentLabels } from './useFilamentLabels';
 import { autoAssignRackPositions, rackOptionsForGroup } from '../../utils/nozzleRack';
 import type { FilamentMappingProps, RackGroupInfo } from './types';
@@ -490,11 +492,32 @@ export function FilamentMapping({
                 </span>
                 {/* Arrow */}
                 <span className="text-bambu-gray">→</span>
-                {/* Slot selector dropdown */}
-                <select
-                  value={item.loaded?.globalTrayId ?? ''}
-                  onChange={(e) => handleSlotChange(slotId, e.target.value)}
-                  className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
+                {/*
+                  Slot picker. Not a <select>: an <option> renders text only,
+                  and the colour of the slot is the whole point of this row
+                  (#3159) -- colour names come from the catalogue or from hue,
+                  so a third-party beige reads as "Orange" and the mismatch
+                  warning cannot be judged without seeing both swatches.
+
+                  #1722: every loaded slot is offered for every filament row,
+                  regardless of which extruder the slot is wired to. Before that
+                  change a slot was only listed when its extruder matched the
+                  filament's slicer-assigned nozzle (item.nozzle_id), which
+                  locked users out of cross-extruder picks even when they'd
+                  intentionally loaded the required filament into the "other"
+                  AMS. The L/R badge on the filament row still tells the user
+                  what the slicer planned; the picker trusts the user to pick
+                  based on their physical setup. Printer firmware accepts or
+                  rejects the ams_mapping at start-print -- failure is loud,
+                  not silent.
+                */}
+                <SlotPicker
+                  value={item.loaded?.globalTrayId != null ? String(item.loaded.globalTrayId) : ''}
+                  onChange={(next) => handleSlotChange(slotId, next)}
+                  placeholder={`-- ${t('printModal.selectSlot')} --`}
+                  ariaLabel={t('printModal.slotForFilament', { name: resolvedName })}
+                  exactMatchLabel={t('printModal.exactColorMatch')}
+                  className={`${
                     item.status === 'match'
                       ? 'border-bambu-green/50 text-bambu-green'
                       : item.status === 'type_only'
@@ -502,24 +525,7 @@ export function FilamentMapping({
                       : 'border-orange-500 dark:border-orange-400/50 text-orange-700 dark:text-orange-400'
                   } ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
                   title={item.isManual ? t('printModal.manuallySelected') : t('printModal.autoMatched')}
-                >
-                  <option value="" className="bg-bambu-dark text-bambu-gray">
-                    -- {t('printModal.selectSlot')} --
-                  </option>
-                  {/*
-                    #1722: every loaded slot is offered for every filament row,
-                    regardless of which extruder the slot is wired to. Before this
-                    change a slot was only listed when its extruder matched the
-                    filament's slicer-assigned nozzle (item.nozzle_id), which
-                    locked users out of cross-extruder picks even when they'd
-                    intentionally loaded the required filament into the "other"
-                    AMS. The L/R badge on the filament row still tells the user
-                    what the slicer planned; the dropdown now trusts the user to
-                    pick based on their physical setup. Printer firmware accepts
-                    or rejects the ams_mapping at start-print — failure is loud,
-                    not silent.
-                  */}
-                  {loadedFilaments.map((f) => {
+                  options={loadedFilaments.map((f) => {
                       const remainingWeight = trayRemainingWeightMap.get(f.globalTrayId);
                       const remainingLabel = remainingWeight != null
                         ? t('printModal.slotRemainingShort', {
@@ -536,13 +542,24 @@ export function FilamentMapping({
                       // two views agree. Not translated: L and R are the letters on
                       // the machine.
                       const ftsBadge = ftsInlet == null ? '' : ` [${FTS_INLET_SIDE[ftsInlet]}]`;
-                      return (
-                        <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
-                          {f.label}: {f.spoolName || f.traySubBrands || f.type} ({f.colorName}){remainingLabel}{ftsBadge}
-                        </option>
-                      );
+                      return {
+                        value: String(f.globalTrayId),
+                        label: `${f.label}: ${f.spoolName || f.traySubBrands || f.type} (${f.colorName})`,
+                        meta: `${remainingLabel}${ftsBadge}`,
+                        rgba: f.color,
+                        extraColors: f.extraColors,
+                        effectType: f.effectType,
+                        subtype: f.spoolSubtype,
+                        hex: displayHex(f.color),
+                        // The hex the slice asked for, against the hex the
+                        // printer reports -- the comparison the colour names
+                        // cannot be trusted to make.
+                        exactMatch:
+                          normalizeColorForCompare(item.color) !== '' &&
+                          normalizeColorForCompare(f.color) === normalizeColorForCompare(item.color),
+                      };
                   })}
-                </select>
+                />
                 {/* Status icon */}
                 {item.status === 'match' ? (
                   <Check className="w-3 h-3 text-bambu-green" />

+ 34 - 20
frontend/src/components/PrintModal/FilamentOverride.tsx

@@ -2,7 +2,9 @@ import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Circle, RotateCcw, Palette } from 'lucide-react';
 import { getColorName } from '../../utils/colors';
-import { canonicalFilamentType } from '../../utils/amsHelpers';
+import { canonicalFilamentType, normalizeColorForCompare } from '../../utils/amsHelpers';
+import { SlotPicker } from './SlotPicker';
+import { displayHex } from './displayHex';
 import { useFilamentLabels } from './useFilamentLabels';
 import type { FilamentReqsData } from './types';
 
@@ -123,30 +125,42 @@ export function FilamentOverride({
                 </span>
                 {/* Arrow */}
                 <span className="text-bambu-gray">→</span>
-                {/* Override dropdown — only compatible (same-type) filaments */}
-                <select
+                {/* Override picker — only compatible (same-type) filaments.
+                    Carries a swatch for the same reason the mapping row does
+                    (#3159): this is the model-mode half of the same choice,
+                    and leaving it text-only would make the two paths read
+                    differently for one decision. Model mode pools filaments
+                    across every printer of the model, so there is no slot
+                    binding behind an entry and the swatch is the plain tray
+                    colour -- solid, with no multi-colour stops to draw. */}
+                <SlotPicker
                   value={isOverridden ? `${override.type}|${override.color}` : ''}
-                  onChange={(e) => handleChange(req.slot_id, e.target.value)}
+                  onChange={(next) => handleChange(req.slot_id, next)}
+                  placeholder={`${t('printModal.originalFilament')}: ${resolvedName} (${colorLabel})`}
+                  placeholderOption={{ rgba: req.color, hex: displayHex(req.color) }}
                   disabled={compatible.length === 0}
-                  className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
+                  ariaLabel={t('printModal.overrideForFilament', { name: resolvedName })}
+                  exactMatchLabel={t('printModal.exactColorMatch')}
+                  className={
                     isOverridden
                       ? 'border-blue-500/50 dark:border-blue-400/50 text-blue-700 dark:text-blue-400'
                       : 'border-bambu-gray/30 text-bambu-gray'
-                  }`}
-                >
-                  <option value="" className="bg-bambu-dark text-bambu-gray">
-                    {t('printModal.originalFilament')}: {resolvedName} ({colorLabel})
-                  </option>
-                  {compatible.map((f, idx) => (
-                    <option
-                    key={`${f.type}-${f.color}-${f.tray_sub_brands}-${idx}`}
-                      value={`${f.type}|${f.color}`}
-                      className="bg-bambu-dark text-white"
-                    >
-                    {f.tray_sub_brands || f.type} ({getColorName(f.color, f.tray_sub_brands)})
-                    </option>
-                  ))}
-                </select>
+                  }
+                  options={compatible.map((f, idx) => ({
+                    // The value has to stay `TYPE|COLOR` -- `handleChange`
+                    // parses it -- but two spools of the same type and colour
+                    // on different printers collapse to one value, so the key
+                    // keeps the sub-brand and the index as it did before.
+                    value: `${f.type}|${f.color}`,
+                    key: `${f.type}-${f.color}-${f.tray_sub_brands}-${idx}`,
+                    label: `${f.tray_sub_brands || f.type} (${getColorName(f.color, f.tray_sub_brands)})`,
+                    rgba: f.color,
+                    hex: displayHex(f.color),
+                    exactMatch:
+                      normalizeColorForCompare(req.color) !== '' &&
+                      normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color),
+                  }))}
+                />
                 {/* Reset button */}
                 {isOverridden ? (
                   <button

+ 302 - 0
frontend/src/components/PrintModal/SlotPicker.tsx

@@ -0,0 +1,302 @@
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+import { ChevronDown, Check } from 'lucide-react';
+import { FilamentSwatch } from '../FilamentSwatch';
+
+/**
+ * One-line filament picker that can show a colour, which a `<select>` cannot.
+ *
+ * The Print / Schedule dialog is where the slicer's colour is compared against
+ * what is actually in the machine, and until now the right-hand side of that
+ * comparison was text only (#3159). Colour names are not a substitute: they are
+ * resolved from the Color Catalog or from hue, so a third-party beige reads as
+ * "Orange" and a "Color mismatch" warning gives no way to tell a real mismatch
+ * from two names for the same hex. `<option>` renders text and nothing else, so
+ * the swatch means this cannot be a native select.
+ *
+ * What is kept from the select it replaces: full keyboard operation (arrows,
+ * Home/End, Enter, Escape), `role="listbox"` semantics, and the caller's own
+ * border colouring, which encodes match / type-only / mismatch on the mapping
+ * row and overridden / original on the override row.
+ *
+ * Deliberately not a shared "Select": the rows are a swatch, a name, a hex and
+ * a badge, and this is the second such control in the app after
+ * `spool-form/PresetPicker` — which documents the same reasoning. A shared
+ * abstraction over two call sites with different row shapes would be guessing
+ * at the third.
+ */
+
+export interface SlotPickerOption {
+  /** The value handed back to `onChange`; mirrors the old `<option value>`. */
+  value: string;
+  /** Main text of the row. */
+  label: string;
+  /** RRGGBBAA (or RRGGBB) without `#`. Absent renders no swatch at all rather
+   *  than a grey one, so "no colour known" never looks like a grey spool. */
+  rgba?: string | null;
+  /** Comma-separated extra hex stops for a multi-colour spool. */
+  extraColors?: string | null;
+  /** Effect overlay name, as `FilamentSwatch` understands it. */
+  effectType?: string | null;
+  /** Filament subtype; `Multicolor` renders the stops as a conic sweep. */
+  subtype?: string | null;
+  /** Muted suffix after the label — remaining grams, FTS inlet badge. */
+  meta?: string;
+  /** Shown in the dropdown row so two similar colours can be told apart by
+   *  their hex rather than by two names that resolved the same way. */
+  hex?: string | null;
+  /** Marks the row whose colour is exactly the one the slice asked for. */
+  exactMatch?: boolean;
+  /** React key, when `value` is not unique. Model-mode pools filaments across
+   *  every printer of a model, so two spools of the same type and colour
+   *  collapse to one `TYPE|COLOR` value while still being two rows. */
+  key?: string;
+}
+
+interface SlotPickerProps {
+  /** Currently chosen option value, or '' for the placeholder row. */
+  value: string;
+  options: SlotPickerOption[];
+  /** What the '' row reads as. */
+  placeholder: string;
+  /** Colour for the '' row, when it has one (the override panel's '' row is
+   *  the 3MF's own filament, which does have a colour). */
+  placeholderOption?: Omit<SlotPickerOption, 'value' | 'label'>;
+  onChange: (value: string) => void;
+  disabled?: boolean;
+  ariaLabel: string;
+  /** Native tooltip on the trigger. */
+  title?: string;
+  /** Border / text classes the caller uses to encode status. */
+  className?: string;
+  /** Tooltip for the exact-match marker. */
+  exactMatchLabel?: string;
+}
+
+const SWATCH_CLASS = 'w-3 h-3';
+
+export function SlotPicker({
+  value,
+  options,
+  placeholder,
+  placeholderOption,
+  onChange,
+  disabled = false,
+  ariaLabel,
+  title,
+  className = '',
+  exactMatchLabel,
+}: SlotPickerProps) {
+  const [open, setOpen] = useState(false);
+  const [activeIndex, setActiveIndex] = useState(0);
+  const [coords, setCoords] = useState<{ top: number; left: number; width: number } | null>(null);
+  const triggerRef = useRef<HTMLButtonElement>(null);
+  const listRef = useRef<HTMLDivElement>(null);
+
+  // The placeholder is row 0, so an index addresses both it and the options
+  // with one number — which is what the arrow keys and aria-activedescendant
+  // need.
+  const rows: SlotPickerOption[] = [
+    { ...(placeholderOption ?? {}), value: '', label: placeholder },
+    ...options,
+  ];
+  const selectedIndex = Math.max(
+    0,
+    rows.findIndex((r) => r.value === value),
+  );
+  const selected = rows[selectedIndex];
+
+  const place = useCallback(() => {
+    const el = triggerRef.current;
+    if (!el) return;
+    const rect = el.getBoundingClientRect();
+    // Flip above when the row sits low enough that a downward list would run
+    // off the viewport. The dialog body scrolls, so a mapping row near its
+    // bottom is the ordinary case, not the edge one.
+    const below = window.innerHeight - rect.bottom;
+    const height = Math.min(listRef.current?.scrollHeight ?? 0, 240);
+    const flip = below < height + 8 && rect.top > below;
+    setCoords({
+      top: flip ? Math.max(8, rect.top - height - 4) : rect.bottom + 4,
+      left: rect.left,
+      width: rect.width,
+    });
+  }, []);
+
+  useLayoutEffect(() => {
+    if (open) place();
+  }, [open, place]);
+
+  useEffect(() => {
+    if (!open) return;
+    const onPointerDown = (e: MouseEvent) => {
+      const target = e.target as Node;
+      if (triggerRef.current?.contains(target) || listRef.current?.contains(target)) return;
+      setOpen(false);
+    };
+    // Escape closes this control WITHOUT closing the dialog around it, which a
+    // bare document listener would otherwise let happen — same guard
+    // `PresetPicker` documents.
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.key !== 'Escape') return;
+      e.stopPropagation();
+      setOpen(false);
+      triggerRef.current?.focus();
+    };
+    // The list is portaled to <body>, so it does not travel with the dialog's
+    // own scroll container: follow it rather than leaving it behind.
+    const reposition = () => place();
+    document.addEventListener('mousedown', onPointerDown);
+    document.addEventListener('keydown', onKeyDown, true);
+    window.addEventListener('scroll', reposition, true);
+    window.addEventListener('resize', reposition);
+    return () => {
+      document.removeEventListener('mousedown', onPointerDown);
+      document.removeEventListener('keydown', onKeyDown, true);
+      window.removeEventListener('scroll', reposition, true);
+      window.removeEventListener('resize', reposition);
+    };
+  }, [open, place]);
+
+  const choose = (index: number) => {
+    onChange(rows[index].value);
+    setOpen(false);
+    triggerRef.current?.focus();
+  };
+
+  const openAt = (index: number) => {
+    setActiveIndex(index);
+    setOpen(true);
+  };
+
+  const handleTriggerKey = (e: React.KeyboardEvent) => {
+    if (disabled) return;
+    if (!open) {
+      if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
+        e.preventDefault();
+        openAt(selectedIndex);
+      }
+      return;
+    }
+    if (e.key === 'ArrowDown') {
+      e.preventDefault();
+      setActiveIndex((i) => Math.min(rows.length - 1, i + 1));
+    } else if (e.key === 'ArrowUp') {
+      e.preventDefault();
+      setActiveIndex((i) => Math.max(0, i - 1));
+    } else if (e.key === 'Home') {
+      e.preventDefault();
+      setActiveIndex(0);
+    } else if (e.key === 'End') {
+      e.preventDefault();
+      setActiveIndex(rows.length - 1);
+    } else if (e.key === 'Enter' || e.key === ' ') {
+      e.preventDefault();
+      choose(activeIndex);
+    } else if (e.key === 'Tab') {
+      setOpen(false);
+    }
+  };
+
+  const listId = `slot-picker-${ariaLabel.replace(/\W+/g, '-')}`;
+
+  const renderSwatch = (row: SlotPickerOption) =>
+    row.rgba ? (
+      <FilamentSwatch
+        rgba={row.rgba}
+        extraColors={row.extraColors}
+        effectType={row.effectType}
+        subtype={row.subtype}
+        className={SWATCH_CLASS}
+        effectSize="table"
+      />
+    ) : null;
+
+  return (
+    <>
+      <button
+        ref={triggerRef}
+        type="button"
+        role="combobox"
+        aria-label={ariaLabel}
+        aria-haspopup="listbox"
+        aria-expanded={open}
+        aria-controls={open ? listId : undefined}
+        // On the combobox, not on the listbox: focus stays on the trigger
+        // while the list is open, and this is what tells a screen reader which
+        // row the arrow keys are on.
+        aria-activedescendant={open ? `${listId}-${activeIndex}` : undefined}
+        disabled={disabled}
+        title={title}
+        onClick={() => (open ? setOpen(false) : openAt(selectedIndex))}
+        onKeyDown={handleTriggerKey}
+        className={`flex-1 min-w-0 flex items-center gap-1.5 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary text-left focus:outline-none focus:ring-1 focus:ring-bambu-green disabled:opacity-50 ${className}`}
+      >
+        {renderSwatch(selected)}
+        <span className="flex-1 min-w-0 truncate">
+          {selected.label}
+          {selected.meta ? <span className="text-bambu-gray">{selected.meta}</span> : null}
+        </span>
+        <ChevronDown className="w-3 h-3 shrink-0 opacity-70" />
+      </button>
+
+      {open &&
+        createPortal(
+          <div
+            ref={listRef}
+            id={listId}
+            role="listbox"
+            aria-label={ariaLabel}
+            className="fixed z-[60] max-h-60 overflow-y-auto rounded-lg border border-bambu-dark-tertiary bg-bambu-dark-secondary p-1 shadow-xl"
+            style={{
+              top: coords?.top ?? -9999,
+              left: coords?.left ?? -9999,
+              width: coords?.width,
+              // Hide until placed, so there is no flash at (-9999,-9999) —
+              // same trick FilamentHoverCard uses.
+              visibility: coords ? 'visible' : 'hidden',
+            }}
+          >
+            {rows.map((row, index) => (
+              <button
+                key={row.key ?? `${row.value}-${index}`}
+                id={`${listId}-${index}`}
+                type="button"
+                role="option"
+                // What `<option value>` carried before this was a listbox: the
+                // identity of the choice, separate from how it reads.
+                data-value={row.value}
+                aria-selected={index === selectedIndex}
+                onMouseEnter={() => setActiveIndex(index)}
+                onClick={() => choose(index)}
+                className={`w-full flex items-center gap-1.5 text-left px-2 py-1.5 rounded-md text-xs ${
+                  index === selectedIndex
+                    ? 'bg-bambu-green/15 text-bambu-green'
+                    : index === activeIndex
+                      ? 'bg-bambu-dark text-white'
+                      : 'text-white'
+                }`}
+              >
+                {renderSwatch(row)}
+                <span className="flex-1 min-w-0 truncate" title={row.label}>
+                  {row.label}
+                  {row.meta ? <span className="text-bambu-gray">{row.meta}</span> : null}
+                </span>
+                {row.hex ? (
+                  <span className="shrink-0 font-mono text-[10px] uppercase text-bambu-gray">{row.hex}</span>
+                ) : null}
+                {row.exactMatch ? (
+                  <span title={exactMatchLabel} className="shrink-0 text-bambu-green">
+                    <Check className="w-3 h-3" />
+                  </span>
+                ) : null}
+              </button>
+            ))}
+          </div>,
+          document.body,
+        )}
+    </>
+  );
+}
+
+export default SlotPicker;

+ 19 - 0
frontend/src/components/PrintModal/displayHex.ts

@@ -0,0 +1,19 @@
+/**
+ * The hex to print next to a swatch in the slot picker (#3159).
+ *
+ * Shown because two slots whose colour names resolved the same way -- both
+ * "Orange", one a beige -- are otherwise indistinguishable in the list, and
+ * comparing them against the slice's colour is the reason the picker exists.
+ *
+ * Alpha is kept only when it carries information: a translucent spool prints
+ * all eight digits, an opaque one prints six, so a Clear filament is not shown
+ * as if it were solid. Returns null for anything unreadable, and the caller
+ * then prints no hex rather than a misleading one.
+ */
+export function displayHex(color: string | null | undefined): string | null {
+  if (!color) return null;
+  const clean = color.trim().replace(/^#/, '');
+  if (!/^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(clean)) return null;
+  const keepAlpha = clean.length === 8 && clean.substring(6, 8).toLowerCase() !== 'ff';
+  return `#${(keepAlpha ? clean : clean.substring(0, 6)).toUpperCase()}`;
+}

+ 16 - 0
frontend/src/hooks/useFilamentMapping.ts

@@ -85,6 +85,9 @@ export function buildLoadedFilaments(
           trayInfoIdx: tray.tray_info_idx || '',
           traySubBrands: tray.tray_sub_brands || '',
           spoolName: spoolDisplayName(identity) ?? undefined,
+          extraColors: identity?.extra_colors ?? undefined,
+          effectType: identity?.effect_type ?? undefined,
+          spoolSubtype: identity?.subtype ?? undefined,
           extruderId: amsExtruderMap?.[String(amsUnit.id)],
           remain: tray.remain ?? -1,
         });
@@ -114,6 +117,9 @@ export function buildLoadedFilaments(
         trayInfoIdx: extTray.tray_info_idx || '',
         traySubBrands: extTray.tray_sub_brands || '',
         spoolName: spoolDisplayName(identity) ?? undefined,
+        extraColors: identity?.extra_colors ?? undefined,
+        effectType: identity?.effect_type ?? undefined,
+        spoolSubtype: identity?.subtype ?? undefined,
         extruderId: hasDualNozzle ? (255 - trayId) : undefined,
         remain: extTray.remain ?? -1,
       });
@@ -184,6 +190,16 @@ export interface LoadedFilament {
   extruderId?: number;
   /** Remaining filament percentage (0-100), -1 = unknown */
   remain: number;
+  /** The bound spool's remaining colour stops and effect overlay, when the
+   *  slot has a binding (#3159). Display only, like `spoolName`: a tray record
+   *  carries one hex, so without these a two-tone spool draws as its base
+   *  colour. Absent for a slot configured on the printer -- the firmware has
+   *  nothing to report, and a solid swatch is then the honest one. */
+  extraColors?: string;
+  effectType?: string;
+  /** The bound spool's subtype ("PLA Silk", "Multicolor"), which decides
+   *  whether the swatch renders its stops as a conic sweep. */
+  spoolSubtype?: string;
 }
 
 /**

+ 3 - 0
frontend/src/i18n/locales/de.ts

@@ -5034,6 +5034,9 @@ export default {
     manuallySelected: 'Manuell ausgewählt',
     autoMatched: 'Automatisch zugeordnet',
     selectSlot: 'Slot wählen',
+    exactColorMatch: 'Exakte Farbübereinstimmung',
+    slotForFilament: 'Drucker-Slot für {{name}}',
+    overrideForFilament: 'Filament-Überschreibung für {{name}}',
     sameTypeDifferentColorDetail: 'Gleicher Typ, andere Farbe: benötigt {{required}}, im Slot ist {{loaded}}',
     requiredTypeNotInPrinter: 'Benötigter Filamenttyp im Drucker nicht gefunden.',
     whenToPrint: 'Wann drucken',

+ 3 - 0
frontend/src/i18n/locales/en.ts

@@ -5079,6 +5079,9 @@ export default {
     manuallySelected: 'Manually selected',
     autoMatched: 'Auto-matched',
     selectSlot: 'Select slot',
+    exactColorMatch: 'Exact colour match',
+    slotForFilament: 'Printer slot for {{name}}',
+    overrideForFilament: 'Filament override for {{name}}',
     sameTypeDifferentColorDetail: 'Same type, different color: needs {{required}}, slot has {{loaded}}',
     requiredTypeNotInPrinter: 'Required filament type not found in printer.',
     whenToPrint: 'When to print',

+ 3 - 0
frontend/src/i18n/locales/es.ts

@@ -5041,6 +5041,9 @@ export default {
     manuallySelected: 'Seleccionado manualmente',
     autoMatched: 'Emparejado automáticamente',
     selectSlot: 'Elegir ranura',
+    exactColorMatch: 'Coincidencia exacta de color',
+    slotForFilament: 'Ranura de impresora para {{name}}',
+    overrideForFilament: 'Anulación de filamento para {{name}}',
     sameTypeDifferentColorDetail: 'Mismo tipo, color distinto: necesita {{required}}, la ranura tiene {{loaded}}',
     requiredTypeNotInPrinter: 'El tipo de filamento requerido no se encuentra en la impresora.',
     whenToPrint: 'Cuándo imprimir',

+ 3 - 0
frontend/src/i18n/locales/fr.ts

@@ -5023,6 +5023,9 @@ export default {
     manuallySelected: 'Sélectionné manuellement',
     autoMatched: 'Associé automatiquement',
     selectSlot: 'Choisir un emplacement',
+    exactColorMatch: 'Correspondance exacte des couleurs',
+    slotForFilament: 'Emplacement imprimante pour {{name}}',
+    overrideForFilament: 'Remplacement de filament pour {{name}}',
     sameTypeDifferentColorDetail: 'Même type, couleur différente : requiert {{required}}, l’emplacement contient {{loaded}}',
     requiredTypeNotInPrinter: 'Type de filament requis introuvable dans l’imprimante.',
     whenToPrint: 'Quand imprimer',

+ 3 - 0
frontend/src/i18n/locales/it.ts

@@ -5022,6 +5022,9 @@ export default {
     manuallySelected: 'Selezionato manualmente',
     autoMatched: 'Abbinato automaticamente',
     selectSlot: 'Scegli slot',
+    exactColorMatch: 'Corrispondenza esatta del colore',
+    slotForFilament: 'Slot stampante per {{name}}',
+    overrideForFilament: 'Sostituzione filamento per {{name}}',
     sameTypeDifferentColorDetail: 'Stesso tipo, colore diverso: richiede {{required}}, lo slot ha {{loaded}}',
     requiredTypeNotInPrinter: 'Tipo di filamento richiesto non trovato nella stampante.',
     whenToPrint: 'Quando stampare',

+ 3 - 0
frontend/src/i18n/locales/ja.ts

@@ -5034,6 +5034,9 @@ export default {
     manuallySelected: '手動で選択',
     autoMatched: '自動で一致',
     selectSlot: 'スロットを選択',
+    exactColorMatch: '色が完全に一致',
+    slotForFilament: '{{name}} のプリンタースロット',
+    overrideForFilament: '{{name}} のフィラメントオーバーライド',
     sameTypeDifferentColorDetail: '同じ種類、異なる色: 必要な色は {{required}}、スロットは {{loaded}}',
     requiredTypeNotInPrinter: '必要なフィラメントタイプがプリンターに見つかりません。',
     whenToPrint: '印刷タイミング',

+ 3 - 0
frontend/src/i18n/locales/ko.ts

@@ -4804,6 +4804,9 @@ export default {
     manuallySelected: '수동으로 선택됨',
     autoMatched: '자동으로 일치됨',
     selectSlot: '슬롯 선택',
+    exactColorMatch: '색상 정확히 일치',
+    slotForFilament: '{{name}}의 프린터 슬롯',
+    overrideForFilament: '{{name}}의 필라멘트 재정의',
     sameTypeDifferentColorDetail: '같은 유형, 다른 색상: {{required}} 필요, 슬롯에는 {{loaded}}',
     requiredTypeNotInPrinter: '필요한 필라멘트 유형을 프린터에서 찾을 수 없습니다.',
     whenToPrint: '인쇄 시점',

+ 3 - 0
frontend/src/i18n/locales/nl.ts

@@ -5079,6 +5079,9 @@ export default {
     manuallySelected: 'Handmatig geselecteerd',
     autoMatched: 'Automatisch gekoppeld',
     selectSlot: 'Sleuf selecteren',
+    exactColorMatch: 'Exacte kleurovereenkomst',
+    slotForFilament: 'Printersleuf voor {{name}}',
+    overrideForFilament: 'Filament overschrijven voor {{name}}',
     sameTypeDifferentColorDetail: 'Zelfde type, andere kleur: vereist {{required}}, sleuf bevat {{loaded}}',
     requiredTypeNotInPrinter: 'Vereist filamenttype niet gevonden in printer.',
     whenToPrint: 'Wanneer afdrukken',

+ 3 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -5022,6 +5022,9 @@ export default {
     manuallySelected: 'Selecionado manualmente',
     autoMatched: 'Correspondido automaticamente',
     selectSlot: 'Escolher slot',
+    exactColorMatch: 'Correspondência exata de cor',
+    slotForFilament: 'Slot da impressora para {{name}}',
+    overrideForFilament: 'Substituição de filamento para {{name}}',
     sameTypeDifferentColorDetail: 'Mesmo tipo, cor diferente: precisa de {{required}}, o slot tem {{loaded}}',
     requiredTypeNotInPrinter: 'Tipo de filamento necessário não encontrado na impressora.',
     whenToPrint: 'Quando imprimir',

+ 3 - 0
frontend/src/i18n/locales/ru.ts

@@ -4793,6 +4793,9 @@ export default {
     manuallySelected: "Выбрано вручную",
     autoMatched: "Подобрано автоматически",
     selectSlot: "Выбрать слот",
+    exactColorMatch: "Точное совпадение цвета",
+    slotForFilament: "Слот принтера для {{name}}",
+    overrideForFilament: "Переопределение филамента для {{name}}",
     sameTypeDifferentColorDetail: "Тот же тип, другой цвет: нужен {{required}}, в слоте {{loaded}}",
     requiredTypeNotInPrinter: "Требуемый тип филамента не найден в принтере.",
     whenToPrint: "Когда печатать",

+ 3 - 0
frontend/src/i18n/locales/sv.ts

@@ -5079,6 +5079,9 @@ errors: {
     manuallySelected: 'Manuellt vald',
     autoMatched: 'Auto-matchad',
     selectSlot: 'Välj fack',
+    exactColorMatch: 'Exakt färgmatchning',
+    slotForFilament: 'Skrivarfack för {{name}}',
+    overrideForFilament: 'Åsidosätt filament för {{name}}',
     sameTypeDifferentColorDetail: 'Samma typ, annan färg: behöver {{required}}, facket har {{loaded}}',
     requiredTypeNotInPrinter: 'Krävd filamenttyp hittades inte i skrivaren.',
     whenToPrint: 'När utskriften ska starta',

+ 3 - 0
frontend/src/i18n/locales/tr.ts

@@ -5011,6 +5011,9 @@ export default {
     manuallySelected: 'Elle seçildi',
     autoMatched: 'Otomatik eşleşti',
     selectSlot: 'Yuva seç',
+    exactColorMatch: 'Tam renk eşleşmesi',
+    slotForFilament: '{{name}} için yazıcı yuvası',
+    overrideForFilament: '{{name}} için filament geçersiz kılma',
     sameTypeDifferentColorDetail: 'Aynı tür, farklı renk: {{required}} gerekiyor, yuvada {{loaded}} var',
     requiredTypeNotInPrinter: 'Gereken filament türü yazıcıda bulunamadı.',
     whenToPrint: 'Ne zaman yazdırılsın',

+ 3 - 0
frontend/src/i18n/locales/uk.ts

@@ -5076,6 +5076,9 @@ export default {
     manuallySelected: "Вибрано вручну",
     autoMatched: "Підібрано автоматично",
     selectSlot: "Вибрати слот",
+    exactColorMatch: "Точний збіг кольору",
+    slotForFilament: "Слот принтера для {{name}}",
+    overrideForFilament: "Перевизначення філаменту для {{name}}",
     sameTypeDifferentColorDetail: "Того самого типу, іншого кольору: потрібен {{required}}, у слоті {{loaded}}",
     requiredTypeNotInPrinter: "Потрібний тип філаменту не знайдено у принтері.",
     whenToPrint: "Коли друкувати",

+ 3 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -5022,6 +5022,9 @@ export default {
     manuallySelected: '手动选择',
     autoMatched: '自动匹配',
     selectSlot: '选择槽位',
+    exactColorMatch: '颜色完全匹配',
+    slotForFilament: '{{name}} 的打印机槽位',
+    overrideForFilament: '{{name}} 的耗材覆盖',
     sameTypeDifferentColorDetail: '相同类型,不同颜色:需要 {{required}},槽位为 {{loaded}}',
     requiredTypeNotInPrinter: '打印机中未找到所需的耗材类型。',
     whenToPrint: '何时打印',

+ 3 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -5022,6 +5022,9 @@ export default {
     manuallySelected: '手動選擇',
     autoMatched: '自動比對',
     selectSlot: '選擇插槽',
+    exactColorMatch: '顏色完全相符',
+    slotForFilament: '{{name}} 的印表機插槽',
+    overrideForFilament: '{{name}} 的耗材覆蓋',
     sameTypeDifferentColorDetail: '相同類型,不同顏色:需要 {{required}},插槽為 {{loaded}}',
     requiredTypeNotInPrinter: '印表機中找不到所需的耗材類型。',
     whenToPrint: '何時列印',

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CN5d-0KI.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/index-ChscM3lF.css


File diff suppressed because it is too large
+ 0 - 116
static/assets/process-schema-CzfeynAH.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/process-toggle-rules-DDBax3G5.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/process-ui-tree-BWrKRLV6.js


BIN
static/fonts/inter-latin-ext.woff2


BIN
static/fonts/inter-latin.woff2


BIN
static/icons/ams-ht.png


File diff suppressed because it is too large
+ 0 - 1
static/icons/ams-settings.svg


+ 0 - 9
static/icons/ams-wiring-center.svg

@@ -1,9 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 50">
-  <!-- Left wire: horizontal from left edge, then down to extruder left inlet -->
-  <line x1="0" y1="0" x2="10" y2="0" stroke="#909090" stroke-width="2" />
-  <line x1="10" y1="0" x2="10" y2="50" stroke="#909090" stroke-width="2" />
-
-  <!-- Right wire: horizontal from right edge, then down to extruder right inlet -->
-  <line x1="40" y1="0" x2="30" y2="0" stroke="#909090" stroke-width="2" />
-  <line x1="30" y1="0" x2="30" y2="50" stroke="#909090" stroke-width="2" />
-</svg>

+ 0 - 17
static/icons/ams-wiring-left.svg

@@ -1,17 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 50">
-  <!-- Vertical lines from slots down to horizontal bar -->
-  <line x1="28" y1="0" x2="28" y2="14" stroke="#909090" stroke-width="2" />
-  <line x1="82" y1="0" x2="82" y2="14" stroke="#909090" stroke-width="2" />
-  <line x1="138" y1="0" x2="138" y2="14" stroke="#909090" stroke-width="2" />
-  <line x1="192" y1="0" x2="192" y2="14" stroke="#909090" stroke-width="2" />
-
-  <!-- Horizontal bar across all slots -->
-  <line x1="28" y1="14" x2="192" y2="14" stroke="#909090" stroke-width="2" />
-
-  <!-- Center hub box -->
-  <rect x="96" y="8" width="28" height="12" rx="2" fill="#c0c0c0" stroke="#909090" stroke-width="1" />
-
-  <!-- Wire from hub: down, then right to edge (at same level as hub horizontal bar) -->
-  <line x1="110" y1="20" x2="110" y2="35" stroke="#909090" stroke-width="2" />
-  <line x1="110" y1="35" x2="220" y2="35" stroke="#909090" stroke-width="2" />
-</svg>

+ 0 - 17
static/icons/ams-wiring-right.svg

@@ -1,17 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 50">
-  <!-- Vertical lines from slots down to horizontal bar -->
-  <line x1="28" y1="0" x2="28" y2="14" stroke="#909090" stroke-width="2" />
-  <line x1="82" y1="0" x2="82" y2="14" stroke="#909090" stroke-width="2" />
-  <line x1="138" y1="0" x2="138" y2="14" stroke="#909090" stroke-width="2" />
-  <line x1="192" y1="0" x2="192" y2="14" stroke="#909090" stroke-width="2" />
-
-  <!-- Horizontal bar across all slots -->
-  <line x1="28" y1="14" x2="192" y2="14" stroke="#909090" stroke-width="2" />
-
-  <!-- Center hub box -->
-  <rect x="96" y="8" width="28" height="12" rx="2" fill="#c0c0c0" stroke="#909090" stroke-width="1" />
-
-  <!-- Wire from hub: down, then left to edge (at same level as hub horizontal bar) -->
-  <line x1="110" y1="20" x2="110" y2="35" stroke="#909090" stroke-width="2" />
-  <line x1="0" y1="35" x2="110" y2="35" stroke="#909090" stroke-width="2" />
-</svg>

BIN
static/icons/ams.png


+ 0 - 1
static/icons/chamber.svg

@@ -1 +0,0 @@
-<svg id="Layer_1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><path d="m15 12h-6c-1.103 0-2 .897-2 2v4c0 1.103.897 2 2 2h6c1.103 0 2-.897 2-2v-4c0-1.103-.897-2-2-2zm1 6c0 .552-.448 1-1 1h-6c-.551 0-1-.448-1-1v-4c0-.552.449-1 1-1h6c.552 0 1 .448 1 1zm-2.5-2.5c0 .276-.224.5-.5.5h-2c-.276 0-.5-.224-.5-.5s.224-.5.5-.5h2c.276 0 .5.224.5.5zm6-13.5h-1.5v-1.5c0-.276-.224-.5-.5-.5s-.5.224-.5.5v1.5h-10v-1.5c0-.276-.224-.5-.5-.5s-.5.224-.5.5v1.5h-1.5c-2.481 0-4.5 2.019-4.5 4.5v13c0 2.481 2.019 4.5 4.5 4.5h15c2.481 0 4.5-2.019 4.5-4.5v-13c0-2.481-2.019-4.5-4.5-4.5zm-15 1h15c1.93 0 3.5 1.57 3.5 3.5v1.5h-22v-1.5c0-1.93 1.57-3.5 3.5-3.5zm15 20h-15c-1.93 0-3.5-1.57-3.5-3.5v-10.5h22v10.5c0 1.93-1.57 3.5-3.5 3.5z"/></svg>

BIN
static/icons/dual-extruder-left.png


BIN
static/icons/dual-extruder-right.png


BIN
static/icons/dual-extruder-right_sav.png


BIN
static/icons/dual-extruder.png


BIN
static/icons/extruder-change-filament.png


BIN
static/icons/extruder-left-right.png


+ 0 - 51
static/icons/eye.svg

@@ -1,51 +0,0 @@
-<?xml version="1.0" encoding="iso-8859-1"?>
-<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
-<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
-	 viewBox="0 0 511.999 511.999" style="enable-background:new 0 0 511.999 511.999;" xml:space="preserve">
-<g>
-	<g>
-		<path d="M508.745,246.041c-4.574-6.257-113.557-153.206-252.748-153.206S7.818,239.784,3.249,246.035
-			c-4.332,5.936-4.332,13.987,0,19.923c4.569,6.257,113.557,153.206,252.748,153.206s248.174-146.95,252.748-153.201
-			C513.083,260.028,513.083,251.971,508.745,246.041z M255.997,385.406c-102.529,0-191.33-97.533-217.617-129.418
-			c26.253-31.913,114.868-129.395,217.617-129.395c102.524,0,191.319,97.516,217.617,129.418
-			C447.361,287.923,358.746,385.406,255.997,385.406z"/>
-	</g>
-</g>
-<g>
-	<g>
-		<path d="M255.997,154.725c-55.842,0-101.275,45.433-101.275,101.275s45.433,101.275,101.275,101.275
-			s101.275-45.433,101.275-101.275S311.839,154.725,255.997,154.725z M255.997,323.516c-37.23,0-67.516-30.287-67.516-67.516
-			s30.287-67.516,67.516-67.516s67.516,30.287,67.516,67.516S293.227,323.516,255.997,323.516z"/>
-	</g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-</svg>

+ 0 - 1
static/icons/heatbed.svg

@@ -1 +0,0 @@
-<svg id="Layer_1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><g fill="rgb(0,0,0)"><path d="m6.3 3.9c.54-.4 1.2-.9 1.2-1.9 0-.28-.22-.5-.5-.5s-.5.22-.5.5c0 .48-.29.71-.8 1.1-.53.4-1.2.9-1.2 1.9s.67 1.5 1.2 1.9c.51.38.8.62.8 1.1s-.29.72-.8 1.1c-.54.4-1.2.9-1.2 1.9 0 .28.22.5.5.5s.5-.22.5-.5c0-.48.29-.72.8-1.1.54-.4 1.2-.9 1.2-1.9s-.67-1.5-1.2-1.9c-.51-.38-.8-.62-.8-1.1s.29-.72.8-1.1z"/><path d="m12.3 3.9c.54-.4 1.2-.9 1.2-1.9 0-.28-.22-.5-.5-.5s-.5.22-.5.5c0 .48-.29.71-.8 1.1-.53.4-1.2.9-1.2 1.9s.67 1.5 1.2 1.9c.51.38.8.62.8 1.1s-.29.72-.8 1.1c-.54.4-1.2.9-1.2 1.9 0 .28.22.5.5.5s.5-.22.5-.5c0-.48.29-.72.8-1.1.54-.4 1.2-.9 1.2-1.9s-.67-1.5-1.2-1.9c-.51-.38-.8-.62-.8-1.1s.29-.72.8-1.1z"/><path d="m18.3 3.9c.54-.4 1.2-.9 1.2-1.9 0-.28-.22-.5-.5-.5s-.5.22-.5.5c0 .48-.29.71-.8 1.1-.53.4-1.2.9-1.2 1.9s.67 1.5 1.2 1.9c.51.38.8.62.8 1.1s-.29.72-.8 1.1c-.54.4-1.2.9-1.2 1.9 0 .28.22.5.5.5s.5-.22.5-.5c0-.48.29-.72.8-1.1.54-.4 1.2-.9 1.2-1.9s-.67-1.5-1.2-1.9c-.51-.38-.8-.62-.8-1.1s.29-.72.8-1.1z"/><path d="m22 13.5c-1.07 0-1.61.65-2.05 1.18-.44.52-.71.82-1.29.82s-.85-.3-1.29-.82c-.44-.53-.98-1.18-2.05-1.18s-1.61.65-2.05 1.18c-.44.52-.71.82-1.28.82s-.85-.3-1.28-.82c-.44-.53-.98-1.18-2.05-1.18s-1.61.65-2.05 1.18c-.44.52-.71.82-1.28.82s-.84-.3-1.28-.82c-.44-.53-.98-1.18-2.05-1.18-.28 0-.5.22-.5.5s.22.5.5.5c.57 0 .84.3 1.28.82.44.53.98 1.18 2.05 1.18s1.61-.65 2.05-1.18c.44-.52.71-.82 1.28-.82s.85.3 1.28.82c.44.53.98 1.18 2.05 1.18s1.61-.65 2.05-1.18c.44-.52.71-.82 1.28-.82s.85.3 1.29.82c.44.53.98 1.18 2.05 1.18s1.61-.65 2.05-1.18c.44-.52.71-.82 1.29-.82.28 0 .5-.22.5-.5s-.22-.5-.5-.5z"/><path d="m21 18.5h-18c-.83 0-1.5.67-1.5 1.5v1c0 .83.67 1.5 1.5 1.5h18c.83 0 1.5-.67 1.5-1.5v-1c0-.83-.67-1.5-1.5-1.5zm.5 2.5c0 .28-.22.5-.5.5h-18c-.28 0-.5-.22-.5-.5v-1c0-.28.22-.5.5-.5h18c.28 0 .5.22.5.5z"/></g></svg>

+ 0 - 44
static/icons/home.svg

@@ -1,44 +0,0 @@
-<?xml version="1.0" encoding="iso-8859-1"?>
-<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
-<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
-	 viewBox="0 0 476.912 476.912" style="enable-background:new 0 0 476.912 476.912;" xml:space="preserve">
-<g>
-	<g>
-		<path d="M461.776,209.408L249.568,4.52c-6.182-6.026-16.042-6.026-22.224,0L15.144,209.4c-3.124,3.015-4.888,7.17-4.888,11.512
-			c0,8.837,7.164,16,16,16h28.2v224c0,8.837,7.163,16,16,16h112c8.837,0,16-7.163,16-16v-128h80v128c0,8.837,7.163,16,16,16h112
-			c8.837,0,16-7.163,16-16v-224h28.2c4.338,0,8.489-1.761,11.504-4.88C468.301,225.678,468.129,215.549,461.776,209.408z
-			 M422.456,220.912c-8.837,0-16,7.163-16,16v224h-112v-128c0-8.837-7.163-16-16-16h-80c-8.837,0-16,7.163-16,16v128h-112v-224
-			c0-8.837-7.163-16-16-16h-28.2l212.2-204.88l212.28,204.88H422.456z"/>
-	</g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-</svg>

+ 0 - 6
static/icons/hotend.svg

@@ -1,6 +0,0 @@
-<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M12.4759 7.06822H13.7562L8.74263 11.2268C8.514 11.416 8.10431 11.416 7.87554 11.227C7.87545 11.2269 7.87536 11.2268 7.87528 11.2268L2.86167 7.06822H4.142C4.54877 7.06822 4.93613 6.94338 5.23267 6.71974C5.52893 6.49633 5.76004 6.14967 5.76004 5.72506V1.84316C5.76004 1.80403 5.78049 1.72911 5.88953 1.64688C5.99827 1.56488 6.16993 1.5 6.37809 1.5H10.2398C10.448 1.5 10.6196 1.56488 10.7284 1.64688C10.8374 1.72911 10.8579 1.80403 10.8579 1.84316V5.72506C10.8579 6.14967 11.089 6.49633 11.3852 6.71974C11.6818 6.94338 12.0691 7.06822 12.4759 7.06822ZM2.36979 7.09452C2.36773 7.09555 2.36658 7.096 2.36652 7.09597C2.36645 7.09594 2.36748 7.09542 2.36979 7.09452ZM14.2475 7.09456C14.2498 7.09545 14.2508 7.09596 14.2507 7.096C14.2507 7.09603 14.2495 7.09558 14.2475 7.09456Z" stroke="#6B6B6B"/>
-<path d="M3.80389 10.668C3.58699 10.7742 3.42822 10.9007 3.42822 11.0895C3.42822 11.673 4.95994 11.673 4.95994 12.2548C4.95994 12.8383 3.42822 12.8383 3.42822 13.42C3.42822 14.0035 4.95994 14.0035 4.95994 14.587C4.95994 15.1704 3.42822 15.1704 3.42822 15.7539" stroke="#6B6B6B" stroke-miterlimit="10" stroke-linecap="round"/>
-<path d="M8.63467 14.1348C8.88288 14.2477 9.07518 14.381 9.07518 14.5867C9.07518 15.1702 7.54346 15.1702 7.54346 15.7536" stroke="#6B6B6B" stroke-miterlimit="10" stroke-linecap="round"/>
-<path d="M11.893 11.4316C12.3223 11.7065 13.1899 11.8161 13.1899 12.2546C13.1899 12.838 11.6582 12.838 11.6582 13.4198C11.6582 14.0033 13.1899 14.0033 13.1899 14.5867C13.1899 15.1702 11.6582 15.1702 11.6582 15.7537" stroke="#6B6B6B" stroke-miterlimit="10" stroke-linecap="round"/>
-</svg>

+ 0 - 4
static/icons/humidity-empty.svg

@@ -1,4 +0,0 @@
-<svg width="36" height="54" viewBox="0 0 36 54" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M17.8131 0.00537678C18.4463 -0.150913 20.3648 3.14642 20.8264 3.84781C25.4187 10.816 35.3089 26.9368 35.9383 34.8694C37.4182 53.5822 11.882 61.3357 2.53721 45.3789C-1.73471 38.0791 0.016016 32.2049 3.178 25.0232C6.99221 16.3662 12.6411 7.90372 17.8131 0.00537678ZM18.3738 7.24807L17.5881 7.48441C14.4452 12.9431 10.917 18.2341 8.19369 23.9368C4.6808 31.29 1.18317 38.5479 7.69403 45.5657C17.3058 55.9228 34.9847 46.8808 31.4604 32.8681C29.2558 24.0969 22.4207 15.2913 18.3776 7.24807H18.3738Z" fill="#D0D0D0"/>
-<path d="M8 46C12 48 24 48 28 46C26 50 22 52 18 52C14 52 10 50 8 46Z" fill="#1F8FEB"/>
-</svg>

+ 0 - 4
static/icons/humidity-full.svg

@@ -1,4 +0,0 @@
-<svg width="36" height="54" viewBox="0 0 36 54" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M17.9625 4.48059L4.77216 26.3154L2.08228 40.2175L10.0224 50.8414H23.1594L33.3246 42.1693V30.2455L17.9625 4.48059Z" fill="#1F8FEB"/>
-<path d="M17.7948 0.00537678C18.4273 -0.150913 20.3438 3.14642 20.8048 3.84781C25.3921 10.816 35.2715 26.9368 35.9001 34.8694C37.3784 53.5822 11.8702 61.3357 2.53562 45.3789C-1.73163 38.0829 0.0133678 32.2087 3.1757 25.027C6.98574 16.3662 12.6284 7.90372 17.7948 0.00537678ZM18.3549 7.24807L17.57 7.48441C14.4306 12.9431 10.9063 18.2341 8.1859 23.9368C4.67686 31.29 1.18305 38.5479 7.68679 45.5657C17.2881 55.9228 34.9476 46.8808 31.4271 32.8681C29.2249 24.0969 22.3974 15.2913 18.3587 7.24807H18.3549Z" fill="#D0D0D0"/>
-</svg>

+ 0 - 4
static/icons/humidity-half.svg

@@ -1,4 +0,0 @@
-<svg width="35" height="53" viewBox="0 0 35 53" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M17.3165 0.00379674C17.932 -0.149588 19.7971 3.08645 20.2458 3.77481C24.7103 10.6135 34.3251 26.4346 34.937 34.2198C36.3757 52.5848 11.5505 60.1942 2.46584 44.534C-1.68714 37.3735 0.0148377 31.6085 3.08879 24.5603C6.79681 16.0605 12.2884 7.75907 17.3165 0.00379674ZM17.8615 7.11561L17.0977 7.34755C14.0423 12.7048 10.6124 17.8974 7.96483 23.4941C4.54975 30.7107 1.14949 37.8337 7.47908 44.721C16.8233 54.8856 34.01 46.0117 30.5838 32.2595C28.4405 23.6512 21.7957 15.0093 17.8652 7.11561H17.8615Z" fill="#D0D0D0"/>
-<path d="M5.03547 30.112C9.64453 30.4936 11.632 35.7985 16.4154 35.791C19.6339 35.7873 20.2161 33.2283 22.3853 31.6197C31.6776 24.7286 33.5835 37.4894 27.9881 44.4254C18.1878 56.5653 -1.16063 44.6013 5.03917 30.1158L5.03547 30.112Z" fill="#1F8FEB"/>
-</svg>

BIN
static/icons/jogpad.png


File diff suppressed because it is too large
+ 0 - 5
static/icons/jogpad.svg


+ 0 - 4
static/icons/lamp.svg

@@ -1,4 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
-  <path d="m6.443,4.08L4.304.567,5.157.048l2.14,3.513-.854.52Zm13.557,7.92c0,2.323-1.01,4.528-2.771,6.051-.781.674-1.229,1.641-1.229,2.653v3.296h-8v-3.295c0-1.007-.456-1.982-1.252-2.675-2.062-1.796-3.058-4.497-2.661-7.227.512-3.521,3.457-6.36,7.003-6.753,2.307-.256,4.527.45,6.245,1.987,1.693,1.517,2.665,3.689,2.665,5.962Zm-5,8.704c0-.239.04-.471.077-.704h-6.156c.038.233.078.467.078.705v2.295h6v-2.296Zm4-8.704c0-1.988-.85-3.89-2.332-5.217-1.502-1.344-3.438-1.964-5.469-1.738-3.1.343-5.675,2.825-6.122,5.903-.348,2.391.522,4.757,2.327,6.328.553.481.963,1.076,1.234,1.724h2.861v-5.551c-1.14-.232-2-1.242-2-2.449h1c0,.827.673,1.5,1.5,1.5s1.5-.673,1.5-1.5h1c0,1.208-.86,2.217-2,2.449v5.551h2.856c.268-.645.672-1.234,1.218-1.706,1.542-1.332,2.426-3.262,2.426-5.294Zm.696-11.433l-.854-.52-2.14,3.513.854.52,2.14-3.513Zm3.86,4.342l-3.536,1.597.412.912,3.536-1.597-.412-.912ZM.031,5.821l3.536,1.597.412-.912L.443,4.909l-.412.912Z"/>
-</svg>

+ 0 - 12
static/icons/micro-sd.svg

@@ -1,12 +0,0 @@
-<?xml version='1.0' encoding='utf-8'?>
-<!-- Generator: imaengine 6.0   -->
-<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" viewBox="0,0,512,512" style="enable-background:new 0 0 512 512;" version="1.1">
-<defs/>
-<g id="layer0">
-<path d="M-0.00100857,91.001L-0.000996768,361.001C-0.000995864,381.679 16.821,398.501 37.499,398.501L154.393,398.501L212.196,456.305C213.603,457.711 215.51,458.501 217.5,458.501L277.5,458.501C281.643,458.501 285,455.144 285,451.001L285,428.501L334.394,428.501L362.197,456.305C363.604,457.711 365.511,458.501 367.501,458.501L474.501,458.501C495.179,458.501 512.001,441.679 512.001,421.001L512.001,91.001C512.001,70.323 495.179,53.501 474.501,53.501L37.501,53.501C16.821,53.501 -0.00100947,70.323 -0.00100857,91.001L-0.00100857,91.001ZM496.999,91.001L496.999,421.001C496.999,433.407 486.905,443.501 474.499,443.501L436.999,443.501L436.999,121.001C436.999,116.858 433.642,113.501 429.499,113.501C425.356,113.501 421.999,116.858 421.999,121.001L421.999,443.501L370.605,443.501L342.802,415.697C341.395,414.291 339.488,413.501 337.498,413.501L277.498,413.501C273.355,413.501 269.998,416.858 269.998,421.001L269.998,443.501L220.604,443.501L162.801,385.697C161.394,384.291 159.487,383.501 157.497,383.501L37.497,383.501C25.091,383.501 14.997,373.407 14.997,361.001L14.997,91.001C14.997,78.595 25.091,68.501 37.497,68.501L421.999,68.501L421.999,91.001C421.999,95.144 425.356,98.501 429.499,98.501C433.642,98.501 436.999,95.144 436.999,91.001L436.999,68.501L474.499,68.501C486.905,68.501 496.999,78.595 496.999,91.001L496.999,91.001Z" fill="#000000"/>
-<path d="M29.999,316.001L29.999,361.001C29.999,365.144 33.356,368.501 37.499,368.501L157.499,368.501C161.642,368.501 164.999,365.144 164.999,361.001L164.999,316.001C164.999,311.858 161.642,308.501 157.499,308.501L37.499,308.501C33.356,308.501 29.999,311.858 29.999,316.001L29.999,316.001ZM149.999,323.501L149.999,353.501L44.999,353.501L44.999,323.501L149.999,323.501Z" fill="#000000"/>
-<path d="M29.999,241.001L29.999,286.001C29.999,290.144 33.356,293.501 37.499,293.501L157.499,293.501C161.642,293.501 164.999,290.144 164.999,286.001L164.999,241.001C164.999,236.858 161.642,233.501 157.499,233.501L37.499,233.501C33.356,233.501 29.999,236.858 29.999,241.001L29.999,241.001ZM149.999,248.501L149.999,278.501L44.999,278.501L44.999,248.501L149.999,248.501Z" fill="#000000"/>
-<path d="M29.999,166.001L29.999,211.001C29.999,215.144 33.356,218.501 37.499,218.501L157.499,218.501C161.642,218.501 164.999,215.144 164.999,211.001L164.999,166.001C164.999,161.858 161.642,158.501 157.499,158.501L37.499,158.501C33.356,158.501 29.999,161.858 29.999,166.001L29.999,166.001ZM149.999,173.501L149.999,203.501L44.999,203.501L44.999,173.501L149.999,173.501Z" fill="#000000"/>
-<path d="M157.499,83.501L37.499,83.501C33.356,83.501 29.999,86.858 29.999,91.001L29.999,136.001C29.999,140.144 33.356,143.501 37.499,143.501L157.499,143.501C161.642,143.501 164.999,140.144 164.999,136.001L164.999,91.001C164.999,86.858 161.642,83.501 157.499,83.501L157.499,83.501ZM149.999,98.501L149.999,128.501L44.999,128.501L44.999,98.501L149.999,98.501Z" fill="#000000"/>
-</g>
-</svg>

+ 0 - 1
static/icons/reload.svg

@@ -1 +0,0 @@
-<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m22 11a1 1 0 0 0 -1 1 9 9 0 1 1 -9-9 8.9 8.9 0 0 1 4.42 1.166l-1.127 1.127a1 1 0 0 0 .707 1.707h4a1 1 0 0 0 1-1v-4a1 1 0 0 0 -1.707-.707l-1.411 1.407a10.9 10.9 0 0 0 -5.882-1.7 11 11 0 1 0 11 11 1 1 0 0 0 -1-1z"/></svg>

File diff suppressed because it is too large
+ 0 - 0
static/icons/settings.svg


BIN
static/icons/single-extruder1.png


BIN
static/icons/single-extruder2.png


+ 0 - 1
static/icons/skip-objects.svg

@@ -1 +0,0 @@
-<svg id="Layer_1" height="512" viewBox="0 0 32 32" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><path d="m30 17-3 3-3-3h2c0-6.0654-4.9355-11-11-11s-11 4.9346-11 11h-2c0-7.168 5.832-13 13-13s13 5.832 13 13zm0 5h-6v2h6zm-10 0h-2v2h2zm-16 0h-2v2h2zm4 0h-2v2h2zm4 0h-2v2h2zm4 0h-2v2h2z"/></svg>

+ 0 - 53
static/icons/snowflake.svg

@@ -1,53 +0,0 @@
-<?xml version="1.0" encoding="iso-8859-1"?>
-<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
-<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
-	 viewBox="0 0 412.8 412.8" style="enable-background:new 0 0 412.8 412.8;" xml:space="preserve">
-<g>
-	<g>
-		<path d="M378.4,225.6L304,251.2L274,234v-27.6v-27.2l30-17.2l74.4,25.6c5.2,2,11.2-1.2,12.8-6.4c2-5.2-1.2-11.2-6.4-12.8
-			l-57.6-19.6l54-31.2c4.8-2.8,6.4-9.2,3.6-14c-2.8-4.8-9.2-6.4-14-3.6l-54,31.2l11.6-59.6c1.2-5.6-2.4-10.8-8-12
-			c-5.6-1.2-10.8,2.4-12,8l-15.2,77.2l-30,17.2l-22.8-13.2l-0.4-0.4l-23.2-13.6v-34.4L276,48.8c4.4-3.6,4.8-10,0.8-14.4
-			c-3.6-4.4-10-4.8-14.4-0.8l-45.6,40V10.4c0-5.6-4.4-10.4-10.4-10.4C200.8,0,196,4.4,196,10.4v62.4l-45.6-39.6
-			C146,29.6,139.6,30,136,34c-3.6,4.4-3.2,10.8,0.8,14.4L196,100v34.4L172.8,148l-23.2,13.6l-30-17.2l-15.2-77.2
-			c-1.2-5.6-6.4-9.2-12-8c-5.6,1.2-9.2,6.4-8,12L96,130.8L42,99.6c-4.8-2.8-11.2-1.2-14,3.6s-1.2,11.2,3.6,14l54,31.2L28,168
-			c-5.2,2-8.4,7.6-6.4,12.8s7.6,8.4,12.8,6.4l74.4-25.6l30,17.2v27.6v27.2h0.4l-30,17.2l-74.4-25.6c-5.2-2-11.2,1.2-12.8,6.4
-			c-2,5.2,1.2,11.2,6.4,12.8L86,264l-54,31.2c-4.8,2.8-6.4,9.2-3.6,14c2.8,4.8,9.2,6.4,14,3.6l54-31.2l-11.6,59.6
-			c-1.2,5.6,2.4,10.8,8,12c5.6,1.2,10.8-2.4,12-8L120,268l30-17.2l23.6,13.6l23.2,13.6v34.4L137.6,364c-4.4,3.6-4.8,10-0.8,14.4
-			c3.6,4.4,10,4.8,14.4,0.8l45.6-40v63.2c0,5.6,4.4,10.4,10.4,10.4c5.6,0,10.4-4.4,10.4-10.4V340l45.6,40c4.4,3.6,10.8,3.2,14.4-0.8
-			c3.6-4.4,3.2-10.8-0.8-14.4l-60-52v-34.4l23.2-13.6l23.2-13.6l30,17.2l15.2,77.2c1.2,5.6,6.4,9.2,12,8c5.6-1.2,9.2-6.4,8-12
-			L316.8,282l54,31.2c4.8,2.8,11.2,1.2,14-3.6c2.8-4.8,1.2-11.2-3.6-14l-54-31.2l57.6-19.6c5.2-2,8.4-7.6,6.4-12.8
-			C389.2,226.8,383.6,223.6,378.4,225.6z M252.4,206.4v27.2l-23.2,13.6l-22.8,13.2l-23.6-13.6l-23.2-13.6v-26.8v-27.2l23.2-13.6
-			L206,152l23.2,13.6l0.4,0.4l22.8,13.2V206.4z"/>
-	</g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-</svg>

+ 0 - 6
static/icons/speed.svg

@@ -1,6 +0,0 @@
-<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M17.4005 12.7763C17.649 12.8443 17.907 12.6982 17.9606 12.4463C18.1963 11.3405 18.1993 10.1959 17.9674 9.08583C17.7037 7.8233 17.1438 6.64152 16.3337 5.63787C15.5236 4.63422 14.4866 3.83744 13.3082 3.3132C12.1298 2.78897 10.8436 2.55228 9.55579 2.62265C8.26794 2.69302 7.01524 3.06843 5.90095 3.71795C4.78665 4.36746 3.84266 5.27248 3.14678 6.35842C2.45089 7.44436 2.02303 8.68012 1.89846 9.96387C1.78893 11.0926 1.91663 12.23 2.27134 13.3036C2.35217 13.5482 2.62452 13.6653 2.8641 13.5706V13.5706C3.10368 13.4759 3.21959 13.2053 3.14058 12.9601C2.83915 12.0245 2.73177 11.0355 2.82702 10.054C2.93731 8.91737 3.31613 7.82324 3.93226 6.86177C4.54839 5.9003 5.38418 5.09901 6.37076 4.52394C7.35733 3.94887 8.46645 3.61649 9.60669 3.55418C10.7469 3.49188 11.8857 3.70144 12.929 4.16559C13.9724 4.62974 14.8905 5.33519 15.6077 6.2238C16.3249 7.11242 16.8207 8.15875 17.0542 9.27658C17.2558 10.2419 17.2569 11.2367 17.0592 12.1995C17.0073 12.4519 17.152 12.7083 17.4005 12.7763V12.7763Z" fill="#323A3D"/>
-<path d="M15.4157 8.78647C15.593 8.71313 15.6782 8.50941 15.5948 8.33658C15.1923 7.50231 14.6033 6.76987 13.8715 6.19699C13.0484 5.5526 12.0726 5.13199 11.0389 4.97602C10.0053 4.82004 8.94883 4.93399 7.9722 5.30681C7.10396 5.63825 6.32502 6.16427 5.6943 6.84264C5.56365 6.98317 5.58494 7.20296 5.73272 7.32535V7.32535C5.88051 7.44775 6.09873 7.42629 6.23043 7.28674C6.78398 6.70015 7.46373 6.2447 8.22002 5.956C9.08471 5.62591 10.0201 5.52502 10.9353 5.66312C11.8504 5.80122 12.7144 6.17362 13.4432 6.74415C14.0806 7.24316 14.5957 7.8789 14.9515 8.60271C15.0362 8.77491 15.2383 8.85981 15.4157 8.78647V8.78647Z" fill="#323A3D"/>
-<path d="M13.7871 9.75159L10.6357 14.0831" stroke="#323A3D" stroke-width="1.04628" stroke-linecap="round"/>
-<circle cx="10.1926" cy="14.5735" r="1.32935" fill="#323A3D"/>
-</svg>

+ 0 - 4
static/icons/temperature.svg

@@ -1,4 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
-  <path d="m12.5,15.051V5h-1v10.051c-1.14.232-2,1.242-2,2.449,0,1.379,1.121,2.5,2.5,2.5s2.5-1.121,2.5-2.5c0-1.208-.86-2.217-2-2.449Zm-.5,3.949c-.827,0-1.5-.673-1.5-1.5s.673-1.5,1.5-1.5,1.5.673,1.5,1.5-.673,1.5-1.5,1.5Zm4.5-6.181V4.5c0-2.481-2.019-4.5-4.5-4.5s-4.5,2.019-4.5,4.5v8.319c-1.627,1.561-2.32,3.805-1.859,6.049.508,2.472,2.506,4.476,4.972,4.987.459.096.92.143,1.376.143,1.495,0,2.942-.503,4.111-1.454,1.525-1.241,2.4-3.08,2.4-5.044,0-1.763-.727-3.456-2-4.681Zm-1.031,8.949c-1.292,1.05-2.989,1.454-4.653,1.108-2.081-.432-3.767-2.124-4.194-4.21-.405-1.968.235-3.933,1.713-5.258l.166-.148V4.5c0-1.93,1.57-3.5,3.5-3.5s3.5,1.57,3.5,3.5v8.761l.166.148c1.166,1.046,1.834,2.537,1.834,4.091,0,1.662-.74,3.218-2.031,4.269Z"/>
-</svg>

+ 0 - 6
static/icons/ventilation.svg

@@ -1,6 +0,0 @@
-<svg width="19" height="18" viewBox="0 0 19 18" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M11.6444 8.72819C12.3737 7.93152 12.8358 6.88333 12.8815 5.7196C12.9537 3.86391 11.9465 2.21641 10.4205 1.38122C10.2905 1.31022 10.1557 1.24523 10.0186 1.18506C9.74417 1.07796 9.44933 1.01417 9.13884 1.00214C7.64057 0.943171 6.37816 2.1105 6.3204 3.60878C6.2867 4.48608 6.67301 5.28155 7.29879 5.80264C7.34813 5.83754 7.39507 5.87485 7.442 5.91336C7.92097 6.31169 8.25913 6.87129 8.37587 7.5043C8.67793 7.40923 9.00045 7.3635 9.335 7.37673C10.317 7.41765 11.1642 7.95077 11.6444 8.72819Z" stroke="#00AE42" stroke-miterlimit="10" stroke-linecap="round"/>
-<path d="M6.3842 10.1074C6.39743 9.77525 6.46723 9.45995 6.58276 9.16752C5.57669 8.98339 4.50443 9.12179 3.52844 9.63445C1.88455 10.4997 0.961517 12.1954 1.00123 13.9343C1.00484 14.0823 1.01567 14.2316 1.03252 14.3808C1.07705 14.6708 1.16971 14.9597 1.31412 15.234C2.01212 16.5602 3.65481 17.0705 4.98099 16.3713C5.75721 15.9621 6.25422 15.2304 6.39142 14.4265C6.39743 14.3664 6.40586 14.3062 6.41669 14.2472C6.53583 13.5564 6.91972 12.9174 7.52024 12.4926C6.79938 11.9523 6.34689 11.0774 6.3842 10.1074Z" stroke="#00AE42" stroke-miterlimit="10" stroke-linecap="round"/>
-<path d="M16.7327 11.0132C15.9902 10.545 15.1081 10.4813 14.3427 10.7641C14.2874 10.7881 14.232 10.811 14.1754 10.8327C13.4895 11.0854 12.7097 11.0601 12.0213 10.7147C11.8083 11.9254 10.8347 12.8592 9.62646 13.0313C9.94778 14.0747 10.6277 15.0134 11.6242 15.6416C13.1959 16.632 15.1262 16.5839 16.6124 15.6801C16.7388 15.6031 16.8627 15.5188 16.9831 15.4298C17.2129 15.2457 17.4163 15.0218 17.5812 14.7595C18.3815 13.4898 18.0012 11.8135 16.7327 11.0132Z" stroke="#00AE42" stroke-miterlimit="10" stroke-linecap="round"/>
-<path d="M9.20391 11.3717C9.76553 11.3717 10.2208 10.9164 10.2208 10.3548C10.2208 9.79317 9.76553 9.33789 9.20391 9.33789C8.64229 9.33789 8.18701 9.79317 8.18701 10.3548C8.18701 10.9164 8.64229 11.3717 9.20391 11.3717Z" stroke="#00AE42" stroke-miterlimit="10" stroke-linecap="round"/>
-</svg>

+ 0 - 1
static/icons/video-camera.svg

@@ -1 +0,0 @@
-<svg height="472pt" viewBox="0 -87 472 472" width="472pt" xmlns="http://www.w3.org/2000/svg"><path d="m467.101562 26.527344c-3.039062-1.800782-6.796874-1.871094-9.898437-.179688l-108.296875 59.132813v-35.480469c-.03125-27.601562-22.398438-49.96875-50-50h-248.90625c-27.601562.03125-49.96875 22.398438-50 50v197.421875c.03125 27.601563 22.398438 49.96875 50 50h248.90625c27.601562-.03125 49.96875-22.398437 50-50v-34.835937l108.300781 59.132812c3.097657 1.691406 6.859375 1.625 9.894531-.175781 3.039063-1.804688 4.898438-5.074219 4.898438-8.601563v-227.816406c0-3.53125-1.863281-6.796875-4.898438-8.597656zm-138.203124 220.898437c-.015626 16.5625-13.4375 29.980469-30 30h-248.898438c-16.5625-.019531-29.980469-13.4375-30-30v-197.425781c.019531-16.558594 13.4375-29.980469 30-30h248.90625c16.558594.019531 29.980469 13.441406 30 30zm123.101562-1.335937-103.09375-56.289063v-81.535156l103.09375-56.285156zm0 0"/></svg>

+ 0 - 2
static/icons/water.svg

@@ -1,2 +0,0 @@
-<?xml version="1.0"?>
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="512" height="512"><g id="Water"><path d="M24,46A16.0183,16.0183,0,0,1,8,30C8,16.0942,22.708,2.8125,23.3345,2.2539a.9983.9983,0,0,1,1.331,0C25.292,2.8125,40,16.0942,40,30A16.0183,16.0183,0,0,1,24,46ZM24,4.3721C21.1333,7.1372,10,18.6118,10,30a14,14,0,0,0,28,0C38,18.6118,26.8667,7.1372,24,4.3721Z"/><path d="M18.4976,40.5273a.9946.9946,0,0,1-.5-.1342A12.0449,12.0449,0,0,1,12,30a1,1,0,0,1,2,0,10.0373,10.0373,0,0,0,5,8.6616,1,1,0,0,1-.5019,1.8657Z"/></g></svg>

+ 0 - 73
static/icons/webcam.svg

@@ -1,73 +0,0 @@
-<?xml version="1.0" encoding="iso-8859-1"?>
-<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
-<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
-	 viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
-<g>
-	<g>
-		<path d="M256,40c-5.52,0-10,4.48-10,10s4.48,10,10,10s10-4.48,10-10S261.52,40,256,40z"/>
-	</g>
-</g>
-<g>
-	<g>
-		<path d="M466,210C466,94.206,371.794,0,256,0S46,94.206,46,210c0,96.488,66.579,180.855,159.516,203.859
-			c-1.591,14.119-6.958,31.441-13.568,38.051l-0.131,0.131c-18.899,0.353-32.638,3.149-42.999,8.73
-			C133.677,468.949,126,482.82,126,502c0,5.522,4.478,10,10,10h240c5.522,0,10-4.478,10-10c0-19.187-7.68-33.058-22.824-41.229
-			c-10.344-5.58-24.082-8.378-42.992-8.731l-0.132-0.132c-6.61-6.609-11.977-23.931-13.568-38.05
-			C399.423,390.853,466,306.486,466,210z M316,472c33.23,0,45.303,7.689,48.794,20H147.226c2.172-7.762,6.862-11.345,11.087-13.626
-			C166.274,474.085,178.603,472,196,472H316z M215.517,452c5.068-10.601,8.238-23.466,9.638-34.27
-			C235.326,419.232,245.658,420,256,420c10.342,0,20.674-0.768,30.845-2.27c1.401,10.804,4.57,23.67,9.638,34.27H215.517z
-			 M294.015,396.179c-0.019,0.004-0.037,0.007-0.056,0.011c-24.788,5.056-51.127,5.057-75.922-0.001
-			c-0.017-0.004-0.035-0.007-0.052-0.01C129.918,378.227,66,299.929,66,210c0-104.767,85.233-190,190-190s190,85.233,190,190
-			C446,299.929,382.082,378.227,294.015,396.179z"/>
-	</g>
-</g>
-<g>
-	<g>
-		<path d="M389.606,104.994c-23.072-29.303-55.544-50.505-91.434-59.701c-5.355-1.374-10.799,1.855-12.17,7.205
-			c-1.37,5.35,1.855,10.798,7.205,12.169c31.66,8.112,60.314,26.828,80.686,52.7c3.426,4.352,9.716,5.077,14.043,1.67
-			C392.275,115.621,393.023,109.333,389.606,104.994z"/>
-	</g>
-</g>
-<g>
-	<g>
-		<path d="M256,100c-60.654,0-110,49.346-110,110s49.346,110,110,110s110-49.346,110-110S316.654,100,256,100z M256,300
-			c-49.626,0-90-40.374-90-90c0-49.626,40.374-90,90-90c49.626,0,90,40.374,90,90C346,259.626,305.626,300,256,300z"/>
-	</g>
-</g>
-<g>
-	<g>
-		<path d="M256,140c-38.598,0-70,31.402-70,70c0,38.598,31.402,70,70,70c38.598,0,70-31.402,70-70C326,171.402,294.598,140,256,140z
-			 M256,260c-27.57,0-50-22.43-50-50s22.43-50,50-50s50,22.43,50,50S283.57,260,256,260z"/>
-	</g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-<g>
-</g>
-</svg>

BIN
static/img/android-chrome-192x192.png


BIN
static/img/android-chrome-512x512.png


BIN
static/img/apple-touch-icon.png


BIN
static/img/bambuddy_logo_dark.png


BIN
static/img/bambuddy_logo_dark_transparent.png


BIN
static/img/bambuddy_logo_light.png


BIN
static/img/bed/bed_cool.png


BIN
static/img/bed/bed_cool_supertack.png


BIN
static/img/bed/bed_engineering.png


BIN
static/img/bed/bed_high_templ.png


BIN
static/img/bed/bed_pei.png


BIN
static/img/bed/bed_pei_cool.png


BIN
static/img/favicon-16x16.png


BIN
static/img/favicon-32x32.png


BIN
static/img/favicon.png


BIN
static/img/printers/a1.png


BIN
static/img/printers/a1f.png


BIN
static/img/printers/a1mini.png


BIN
static/img/printers/a2l.png


BIN
static/img/printers/default.png


BIN
static/img/printers/h2c.png


BIN
static/img/printers/h2d.png


BIN
static/img/printers/h2dpro.png


BIN
static/img/printers/o1c.png


BIN
static/img/printers/o1e.png


BIN
static/img/printers/o1s.png


Some files were not shown because too many files changed in this diff