Przeglądaj źródła

Paint SpoolBuddy spools with their extra colours and effect (issue #3033)

maziggy 4 dni temu
rodzic
commit
63e987e591

Plik diff jest za duży
+ 1 - 0
CHANGELOG.md


+ 6 - 0
backend/app/api/routes/spoolbuddy.py

@@ -427,6 +427,10 @@ async def nfc_tag_scanned(
                             # the colour catalog over "Silk+" (#3090).
                             "color_name_is_synthesized": mapped["color_name_is_synthesized"],
                             "rgba": mapped["rgba"],
+                            # The kiosk paints the disc from these, as the
+                            # Filament page does (#3033).
+                            "extra_colors": mapped["extra_colors"],
+                            "effect_type": mapped["effect_type"],
                             "brand": mapped["brand"],
                             "label_weight": mapped["label_weight"],
                             "core_weight": mapped["core_weight"],
@@ -494,6 +498,8 @@ async def nfc_tag_scanned(
                         # set, and nothing else — never a stand-in (#3090).
                         "color_name_is_synthesized": False,
                         "rgba": spool.rgba,
+                        "extra_colors": spool.extra_colors,
+                        "effect_type": spool.effect_type,
                         "brand": spool.brand,
                         "label_weight": spool.label_weight,
                         "core_weight": spool.core_weight,

+ 101 - 0
backend/tests/integration/test_spoolbuddy_spool_colours_3033.py

@@ -0,0 +1,101 @@
+"""The kiosk paints a scanned spool with its extra colours and effect (#3033).
+
+The tag-matched broadcast is the only place the kiosk learns about a scanned
+spool, and it carried `rgba` alone, so a dual-colour or marble spool arrived as
+one flat colour however the frontend drew it.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.settings import Settings
+
+SPOOLBUDDY_API = "/api/v1/spoolbuddy"
+
+
+@pytest.fixture
+async def spoolman_enabled(db_session: AsyncSession):
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    db_session.add(Settings(key="spoolman_url", value="http://spoolman.local:7912"))
+    await db_session.commit()
+
+
+@pytest.fixture
+async def spoolman_disabled(db_session: AsyncSession):
+    db_session.add(Settings(key="spoolman_enabled", value="false"))
+    await db_session.commit()
+
+
+async def _scan(async_client: AsyncClient) -> dict:
+    with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+        mock_ws.broadcast = AsyncMock()
+        resp = await async_client.post(
+            f"{SPOOLBUDDY_API}/nfc/tag-scanned",
+            json={"device_id": "sb-test", "tag_uid": "AABB1122334455FF", "tray_uuid": None},
+        )
+    assert resp.status_code == 200
+    mock_ws.broadcast.assert_called_once()
+    return mock_ws.broadcast.call_args[0][0]
+
+
+class TestTheScanBroadcastCarriesTheSpoolsColours:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_local_dual_colour_spool(self, async_client: AsyncClient, spoolman_disabled):
+        """The reporter's spool #23, Inland PLA Silk Blue-Yellow."""
+        spool = MagicMock()
+        spool.id = 23
+        spool.material = "PLA"
+        spool.subtype = "Silk"
+        spool.color_name = "Blue-Yellow"
+        spool.rgba = "044482FF"
+        spool.extra_colors = "044482,f8d008"
+        spool.effect_type = "dual-color"
+        spool.brand = "Inland"
+        spool.label_weight = 1000
+        spool.core_weight = 250
+        spool.weight_used = 0
+
+        with patch("backend.app.api.routes.spoolbuddy.get_spool_by_tag", new_callable=AsyncMock, return_value=spool):
+            msg = await _scan(async_client)
+
+        assert msg["type"] == "spoolbuddy_tag_matched"
+        assert msg["spool"]["extra_colors"] == "044482,f8d008"
+        assert msg["spool"]["effect_type"] == "dual-color"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_spoolman_spool_sends_its_stops_and_no_effect(self, async_client: AsyncClient, spoolman_enabled):
+        """Spoolman keeps the stops in `multi_color_hexes` and has no effect
+        field, so the effect stays None rather than being guessed."""
+        sm_spool = {
+            "id": 23,
+            "filament": {
+                "material": "PLA",
+                "name": "PLA Silk",
+                "color_hex": "044482",
+                "multi_color_hexes": "044482,F8D008",
+                "weight": 1000.0,
+                "vendor": {"name": "Inland"},
+            },
+            "used_weight": 0.0,
+            "archived": False,
+            "registered": "2024-01-01T00:00:00Z",
+        }
+        client = MagicMock()
+        client.base_url = "http://spoolman.local:7912"
+        client.get_spools = AsyncMock(return_value=[sm_spool])
+        client.find_spool_by_tag = AsyncMock(return_value=sm_spool)
+        client.merge_spool_extra = AsyncMock(return_value={})
+        with (
+            patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)),
+            patch("backend.app.services.spoolman.init_spoolman_client", AsyncMock(return_value=client)),
+        ):
+            msg = await _scan(async_client)
+
+        assert msg["type"] == "spoolbuddy_tag_matched"
+        assert msg["spool"]["extra_colors"] == "044482,F8D008"
+        assert msg["spool"]["effect_type"] is None

+ 46 - 0
frontend/src/__tests__/components/spoolbuddy/SpoolIcon.test.tsx

@@ -53,4 +53,50 @@ describe('SpoolIcon', () => {
     expect(div.style.width).toBe('48px');
     expect(div.style.height).toBe('48px');
   });
+
+  describe('a spool with extra colours or an effect (#3033)', () => {
+    const dualColour = { rgba: '044482FF', extra_colors: '044482,f8d008', effect_type: 'dual-color' };
+
+    it('paints the disc with the Filament page background instead of a flat fill', () => {
+      const { container, getAllByTestId, getByTestId } = render(
+        <SpoolIcon color="#044482" isEmpty={false} size={100} spool={dualColour} />,
+      );
+      expect(container.querySelector('svg')).toBeNull();
+      // Painted once, so the colour bands run straight through both rings.
+      const paint = getAllByTestId('spool-paint');
+      expect(paint).toHaveLength(1);
+      // Hard-split bars, both colours present.
+      expect(paint[0].style.backgroundImage).toContain('linear-gradient(to right');
+      expect(paint[0].style.backgroundImage).toContain('#f8d008');
+      // The inner ring keeps the SVG disc's darker depth.
+      expect(getByTestId('spool-shade').style.backgroundColor).toBe('rgba(0, 0, 0, 0.15)');
+      const box = container.firstElementChild as HTMLElement;
+      expect(box.style.width).toBe('100px');
+    });
+
+    it('paints an effect on a single-colour spool', () => {
+      const { getAllByTestId } = render(
+        <SpoolIcon color="#111111" isEmpty={false} spool={{ rgba: '111111FF', effect_type: 'galaxy' }} />,
+      );
+      const outer = getAllByTestId('spool-paint')[0];
+      // Effect overlay on top of the colour layer on top of the checkerboard.
+      expect(outer.style.backgroundImage.split('gradient(').length).toBeGreaterThan(3);
+    });
+
+    it('keeps the flat SVG disc for a plain spool', () => {
+      const { container, queryAllByTestId } = render(
+        <SpoolIcon color="#00AE42" isEmpty={false} spool={{ rgba: '00AE42FF', extra_colors: null, effect_type: null }} />,
+      );
+      expect(queryAllByTestId('spool-paint')).toHaveLength(0);
+      expect(container.querySelector('circle')!.getAttribute('fill')).toBe('#00AE42');
+    });
+
+    it('still shows the empty state for an empty slot', () => {
+      const { container, queryAllByTestId } = render(
+        <SpoolIcon color="#044482" isEmpty={true} spool={dualColour} />,
+      );
+      expect(queryAllByTestId('spool-paint')).toHaveLength(0);
+      expect((container.firstElementChild as HTMLElement).className).toContain('border-dashed');
+    });
+  });
 });

+ 35 - 0
frontend/src/__tests__/components/spoolbuddy/spoolPaint.test.ts

@@ -0,0 +1,35 @@
+/**
+ * The SpoolBuddy screens paint a spool the way the Filament page does (#3033).
+ */
+
+import { describe, it, expect } from 'vitest';
+import { hasFilamentPaint, spoolSwatchStyle } from '../../../components/spoolbuddy/spoolPaint';
+
+describe('hasFilamentPaint', () => {
+  it('is true for extra colour stops or an effect', () => {
+    expect(hasFilamentPaint({ rgba: '044482FF', extra_colors: '044482,f8d008' })).toBe(true);
+    expect(hasFilamentPaint({ rgba: '111111FF', effect_type: 'marble' })).toBe(true);
+  });
+
+  it('is false for a plain spool, a missing spool, or stops that do not parse', () => {
+    expect(hasFilamentPaint({ rgba: '00AE42FF', extra_colors: null, effect_type: null })).toBe(false);
+    expect(hasFilamentPaint({ rgba: '00AE42FF', extra_colors: '', effect_type: '' })).toBe(false);
+    expect(hasFilamentPaint({ rgba: '00AE42FF', extra_colors: 'nothex' })).toBe(false);
+    expect(hasFilamentPaint(null)).toBe(false);
+    expect(hasFilamentPaint(undefined)).toBe(false);
+  });
+});
+
+describe('spoolSwatchStyle', () => {
+  it('returns the layered background for a dual-colour spool', () => {
+    const style = spoolSwatchStyle({ rgba: '044482FF', extra_colors: '044482,f8d008', effect_type: 'dual-color' });
+    expect(style).not.toBeNull();
+    expect(String(style!.backgroundImage)).toContain('#044482');
+    expect(String(style!.backgroundImage)).toContain('#f8d008');
+    expect(style!.backgroundSize).toBeTruthy();
+  });
+
+  it('returns null for a plain spool so the caller keeps its own style', () => {
+    expect(spoolSwatchStyle({ rgba: '00AE42FF' })).toBeNull();
+  });
+});

+ 21 - 0
frontend/src/__tests__/hooks/useSpoolBuddyState.test.ts

@@ -113,6 +113,27 @@ describe('useSpoolBuddyState', () => {
     expect(result.current.unknownTagUid).toBeNull();
   });
 
+  it('TAG_MATCHED keeps the extra colours and effect the disc is painted with (#3033)', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+    const spool = { id: 23, material: 'PLA', rgba: '044482FF', label_weight: 1000, core_weight: 250, weight_used: 0 };
+
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-tag-matched', {
+        tag_uid: 'AA',
+        spool: { ...spool, extra_colors: '044482,f8d008', effect_type: 'dual-color' },
+      });
+    });
+    expect(result.current.matchedSpool!.extra_colors).toBe('044482,f8d008');
+    expect(result.current.matchedSpool!.effect_type).toBe('dual-color');
+
+    // A backend from before #3033 sends neither field.
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-tag-matched', { tag_uid: 'BB', spool });
+    });
+    expect(result.current.matchedSpool!.extra_colors).toBeNull();
+    expect(result.current.matchedSpool!.effect_type).toBeNull();
+  });
+
   it('UNKNOWN_TAG sets unknownTagUid and clears matchedSpool', () => {
     const { result } = renderHook(() => useSpoolBuddyState());
 

+ 2 - 1
frontend/src/components/spoolbuddy/AssignToAmsModal.tsx

@@ -8,6 +8,7 @@ import { AmsUnitCard, NozzleBadge } from './AmsUnitCard';
 import type { AmsThresholds } from './AmsUnitCard';
 import { getFillBarColor } from '../../utils/amsHelpers';
 import { getSwatchStyle, resolveSpoolColorName } from '../../utils/colors';
+import { spoolSwatchStyle } from './spoolPaint';
 
 function getAmsName(id: number): string {
   if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
@@ -366,7 +367,7 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
 
   if (!isOpen) return null;
 
-  const colorStyle = getSwatchStyle(spool.rgba);
+  const colorStyle = spoolSwatchStyle(spool) ?? getSwatchStyle(spool.rgba);
 
   return (
     <>

+ 1 - 1
frontend/src/components/spoolbuddy/InventorySpoolInfoCard.tsx

@@ -142,7 +142,7 @@ export function InventorySpoolInfoCard({
     <div className={`flex flex-col items-center space-y-4 max-w-md ${className ?? ''}`}>
       <div className="flex items-start gap-5">
         <div className="relative shrink-0">
-          <SpoolIcon color={colorHex} isEmpty={false} size={100} />
+          <SpoolIcon color={colorHex} isEmpty={false} size={100} spool={spool} />
           {fillPercent !== null && (
             <div
               className="absolute -bottom-2 -right-2 px-2 py-0.5 rounded-full text-xs font-bold text-white shadow-lg"

+ 1 - 0
frontend/src/components/spoolbuddy/LinkSpoolModal.tsx

@@ -102,6 +102,7 @@ export function LinkSpoolModal({
                 >
                   <SpoolIcon
                     color={spoolColorString(spool.rgba)}
+                    spool={spool}
                     isEmpty={false}
                     size={40}
                   />

+ 82 - 1
frontend/src/components/spoolbuddy/SpoolIcon.tsx

@@ -1,10 +1,76 @@
+import { useMemo } from 'react';
+import { buildFilamentBackground, type SwatchType } from '../filamentSwatchHelpers';
+import { hasFilamentPaint, type SpoolPaintSource } from './spoolPaint';
+
+/** A spool disc painted with the spool's colours, absolutely placed inside a
+ *  square box. `inset` is the fraction of the box left clear on each side. */
+export function SpoolPaint({
+  spool,
+  inset,
+  size,
+  border,
+}: {
+  spool: SpoolPaintSource;
+  inset: number;
+  size: number;
+  border?: string;
+}) {
+  // The effect overlays are tuned per swatch size; a kiosk disc of 80px and
+  // up reads like a card banner, anything smaller like the preview swatch.
+  const effectSize: SwatchType = size >= 80 ? 'card' : 'preview';
+  const background = useMemo(
+    () =>
+      buildFilamentBackground({
+        effectSize,
+        rgba: spool.rgba,
+        extraColors: spool.extra_colors,
+        effectType: spool.effect_type,
+        subtype: spool.subtype,
+      }),
+    [effectSize, spool.rgba, spool.extra_colors, spool.effect_type, spool.subtype],
+  );
+  const offset = `${inset * 100}%`;
+  return (
+    <div
+      data-testid="spool-paint"
+      className="absolute rounded-full box-border"
+      style={{
+        top: offset,
+        left: offset,
+        right: offset,
+        bottom: offset,
+        ...background,
+        backgroundPosition: 'center',
+        border,
+      }}
+    />
+  );
+}
+
+/** The darker inner ring of a disc. A 15% black overlay is what
+ *  `brightness(0.85)` does to the flat disc, and it lets the colours run
+ *  continuously under both rings instead of repeating each ring's own copy. */
+export function SpoolShade({ inset }: { inset: number }) {
+  const offset = `${inset * 100}%`;
+  return (
+    <div
+      data-testid="spool-shade"
+      className="absolute rounded-full"
+      style={{ top: offset, left: offset, right: offset, bottom: offset, backgroundColor: 'rgba(0, 0, 0, 0.15)' }}
+    />
+  );
+}
+
 interface SpoolIconProps {
   color: string;
   isEmpty: boolean;
   size?: number;
+  /** The spool itself. When it has extra colours or an effect, the disc is
+   *  painted with them instead of the flat `color`. */
+  spool?: SpoolPaintSource | null;
 }
 
-export function SpoolIcon({ color, isEmpty, size = 32 }: SpoolIconProps) {
+export function SpoolIcon({ color, isEmpty, size = 32, spool }: SpoolIconProps) {
   if (isEmpty) {
     return (
       <div
@@ -16,6 +82,21 @@ export function SpoolIcon({ color, isEmpty, size = 32 }: SpoolIconProps) {
     );
   }
 
+  if (hasFilamentPaint(spool)) {
+    // Same geometry as the SVG below (viewBox 32: outer r=14, inner r=11).
+    return (
+      <div className="relative shrink-0" style={{ width: size, height: size }}>
+        <SpoolPaint
+          spool={spool}
+          size={size}
+          inset={2 / 32}
+          border={`${(1.5 * size) / 32}px solid rgba(255, 255, 255, 0.7)`}
+        />
+        <SpoolShade inset={5 / 32} />
+      </div>
+    );
+  }
+
   return (
     <svg width={size} height={size} viewBox="0 0 32 32">
       {/* Outer ring with white stroke for visibility */}

+ 1 - 1
frontend/src/components/spoolbuddy/SpoolInfoCard.tsx

@@ -93,7 +93,7 @@ export function SpoolInfoCard({ spool, scaleWeight, onClose, onSyncWeight, onAss
       <div className="flex items-start gap-5">
         {/* Spool visualization */}
         <div className="relative shrink-0">
-          <SpoolIcon color={colorHex} isEmpty={false} size={100} />
+          <SpoolIcon color={colorHex} isEmpty={false} size={100} spool={spool} />
           {fillPercent !== null && (
             <div
               className="absolute -bottom-2 -right-2 px-2 py-0.5 rounded-full text-xs font-bold text-white shadow-lg"

+ 1 - 1
frontend/src/components/spoolbuddy/TagDetectedModal.tsx

@@ -180,7 +180,7 @@ function KnownSpoolView({ spool, scaleWeight, weightStable, syncing, synced, onS
       {/* Spool info */}
       <div className="flex items-start gap-5 mb-5">
         <div className="relative shrink-0">
-          <SpoolIcon color={colorHex} isEmpty={false} size={100} />
+          <SpoolIcon color={colorHex} isEmpty={false} size={100} spool={spool} />
           {fillPercent !== null && (
             <div
               className="absolute -bottom-2 -right-2 px-2 py-0.5 rounded-full text-xs font-bold text-white shadow-lg"

+ 35 - 0
frontend/src/components/spoolbuddy/spoolPaint.ts

@@ -0,0 +1,35 @@
+import type { CSSProperties } from 'react';
+import { buildFilamentBackground, parseStops, type SwatchType } from '../filamentSwatchHelpers';
+
+/** The colour data a spool carries, as the Filament page reads it. */
+export interface SpoolPaintSource {
+  rgba: string | null;
+  extra_colors?: string | null;
+  effect_type?: string | null;
+  subtype?: string | null;
+}
+
+/** True when the spool has something a flat fill cannot show: extra colour
+ *  stops or a surface effect. Plain spools keep the flat SVG disc (#3033). */
+export function hasFilamentPaint(spool: SpoolPaintSource | null | undefined): spool is SpoolPaintSource {
+  return !!spool && (parseStops(spool.extra_colors).length > 0 || !!spool.effect_type);
+}
+
+/** Background for a flat colour dot, painted with the spool's extra colours
+ *  and effect; `null` for a plain spool, so the caller keeps its own style. */
+export function spoolSwatchStyle(
+  spool: SpoolPaintSource | null | undefined,
+  effectSize: SwatchType = 'table',
+): CSSProperties | null {
+  if (!hasFilamentPaint(spool)) return null;
+  return {
+    ...buildFilamentBackground({
+      effectSize,
+      rgba: spool.rgba,
+      extraColors: spool.extra_colors,
+      effectType: spool.effect_type,
+      subtype: spool.subtype,
+    }),
+    backgroundPosition: 'center',
+  };
+}

+ 6 - 0
frontend/src/hooks/useSpoolBuddyState.ts

@@ -10,6 +10,10 @@ export interface MatchedSpool {
   // instead — Spoolman-backed inventory only, which has no such field (#3090).
   color_name_is_synthesized?: boolean;
   rgba: string | null;
+  // Gradient stops and surface effect, so the kiosk disc can paint what the
+  // Filament page paints (#3033). Optional: an older backend omits them.
+  extra_colors?: string | null;
+  effect_type?: string | null;
   brand: string | null;
   label_weight: number;
   core_weight: number;
@@ -127,6 +131,8 @@ export function useSpoolBuddyState() {
           color_name: spool.color_name ?? null,
           color_name_is_synthesized: spool.color_name_is_synthesized ?? false,
           rgba: spool.rgba ?? null,
+          extra_colors: spool.extra_colors ?? null,
+          effect_type: spool.effect_type ?? null,
           brand: spool.brand ?? null,
           label_weight: spool.label_weight ?? 0,
           core_weight: spool.core_weight ?? 0,

+ 3 - 2
frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx

@@ -8,6 +8,7 @@ import { api } from '../../api/client';
 import type { PrinterStatus, AMSTray, SpoolAssignment } from '../../api/client';
 import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, formatSlotLabel, isBambuLabSpool, resolveSlotNozzleDiameter } from '../../utils/amsHelpers';
 import { getSwatchStyle, resolveSpoolColorName } from '../../utils/colors';
+import { spoolSwatchStyle } from '../../components/spoolbuddy/spoolPaint';
 
 /**
  * " - Candy Red", or nothing when the colour has no name we can show.
@@ -768,7 +769,7 @@ export function SpoolBuddyAmsPage() {
                       {assignment.spool.rgba && (
                         <span
                           className="w-3 h-3 rounded-full border border-black/20 flex-shrink-0"
-                          style={getSwatchStyle(assignment.spool.rgba)}
+                          style={spoolSwatchStyle(assignment.spool) ?? getSwatchStyle(assignment.spool.rgba)}
                         />
                       )}
                       <span className="text-sm text-white">
@@ -802,7 +803,7 @@ export function SpoolBuddyAmsPage() {
                       {spoolmanAssignedSpool.rgba && (
                         <span
                           className="w-3 h-3 rounded-full border border-black/20 flex-shrink-0"
-                          style={getSwatchStyle(spoolmanAssignedSpool.rgba)}
+                          style={spoolSwatchStyle(spoolmanAssignedSpool) ?? getSwatchStyle(spoolmanAssignedSpool.rgba)}
                         />
                       )}
                       <span className="text-sm text-white">

+ 20 - 3
frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx

@@ -6,6 +6,8 @@ import { Search, X, Package } from 'lucide-react';
 import { api } from '../../api/client';
 import type { InventorySpool } from '../../api/client';
 import { resolveSpoolColorName, getSwatchStyle, spoolColorString } from '../../utils/colors';
+import { SpoolPaint, SpoolShade } from '../../components/spoolbuddy/SpoolIcon';
+import { hasFilamentPaint, spoolSwatchStyle } from '../../components/spoolbuddy/spoolPaint';
 import { formatSlotLabel } from '../../utils/amsHelpers';
 import { filterSpoolsByQuery } from '../../utils/inventorySearch';
 import { useColorCatalogVersion } from '../../hooks/useColorCatalogVersion';
@@ -44,7 +46,22 @@ function assignmentLabel(a: SlotInfo): string {
 }
 
 /* Spool circle — same style as AMS page tray slots */
-function SpoolCircle({ color, size = 56 }: { color: string; size?: number }) {
+function SpoolCircle({ color, spool, size = 56 }: { color: string; spool?: InventorySpool; size?: number }) {
+  if (hasFilamentPaint(spool)) {
+    // Extra colours or an effect: paint the two rings with the Filament page's
+    // background and keep the shine and hub from the SVG on top (#3033).
+    return (
+      <div className="relative shrink-0" style={{ width: size, height: size }}>
+        <SpoolPaint spool={spool} size={size} inset={2 / 56} />
+        <SpoolShade inset={8 / 56} />
+        <svg className="absolute inset-0" width={size} height={size} viewBox="0 0 56 56">
+          <ellipse cx="20" cy="20" rx="6" ry="4" fill="white" opacity="0.3" />
+          <circle cx="28" cy="28" r="8" fill="#2d2d2d" />
+          <circle cx="28" cy="28" r="5" fill="#1a1a1a" />
+        </svg>
+      </div>
+    );
+  }
   return (
     <svg width={size} height={size} viewBox="0 0 56 56">
       <circle cx="28" cy="28" r="26" fill={color} />
@@ -323,7 +340,7 @@ function CatalogCard({ spool, assignment, onClick }: {
       className="bg-bambu-dark-secondary rounded-xl p-3 flex flex-col items-center text-center gap-1.5 border border-transparent hover:border-bambu-green/50 transition-colors"
     >
       {/* Spool icon */}
-      <SpoolCircle color={color} size={56} />
+      <SpoolCircle color={color} spool={spool} size={56} />
 
       {/* Material + Subtype */}
       <p className="text-xs font-semibold text-white leading-tight truncate w-full">
@@ -334,7 +351,7 @@ function CatalogCard({ spool, assignment, onClick }: {
       <div className="flex items-center gap-1 min-w-0 max-w-full">
         <span
           className="w-2.5 h-2.5 rounded-full shrink-0 border border-white/10"
-          style={getSwatchStyle(spool.rgba)}
+          style={spoolSwatchStyle(spool) ?? getSwatchStyle(spool.rgba)}
         />
         <span className="text-[11px] text-white/50 truncate">
           {colorName || '-'}

+ 7 - 4
frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx

@@ -15,6 +15,7 @@ import {
 } from '../../api/client';
 import { getCurrencySymbol } from '../../utils/currency';
 import { getSwatchStyle, resolveSpoolColorName } from '../../utils/colors';
+import { spoolSwatchStyle } from '../../components/spoolbuddy/spoolPaint';
 import { useColorCatalogVersion } from '../../hooks/useColorCatalogVersion';
 
 /**
@@ -412,7 +413,7 @@ function SpoolListItem({ spool, selected, showTag, onClick }: {
           a checkerboard instead of collapsing to solid black (#1545). */}
       <div
         className="w-8 h-8 rounded-full shrink-0 border border-white/10"
-        style={spool.rgba ? getSwatchStyle(spool.rgba) : { backgroundColor: '#666' }}
+        style={spoolSwatchStyle(spool) ?? (spool.rgba ? getSwatchStyle(spool.rgba) : { backgroundColor: '#666' })}
       />
 
       {/* Info */}
@@ -830,7 +831,7 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
           <div className="flex flex-col items-center justify-center h-full p-6 text-center bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
             <div
               className="w-12 h-12 rounded-full mb-4 border border-white/10"
-              style={selectedSpool.rgba ? getSwatchStyle(selectedSpool.rgba) : { backgroundColor: '#666' }}
+              style={spoolSwatchStyle(selectedSpool, 'preview') ?? (selectedSpool.rgba ? getSwatchStyle(selectedSpool.rgba) : { backgroundColor: '#666' })}
             />
             <p className="text-white font-medium">
               {selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}
@@ -1023,7 +1024,7 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
         <div className="flex flex-col items-center justify-center p-4 text-center bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
           <div
             className="w-12 h-12 rounded-full mb-4 border border-white/10"
-            style={selectedSpool.rgba ? getSwatchStyle(selectedSpool.rgba) : { backgroundColor: '#666' }}
+            style={spoolSwatchStyle(selectedSpool, 'preview') ?? (selectedSpool.rgba ? getSwatchStyle(selectedSpool.rgba) : { backgroundColor: '#666' })}
           />
           <p className="text-white font-medium">
             {selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}
@@ -1144,7 +1145,9 @@ function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader,
   // Spool selected — show summary + write button. Use getSwatchStyle so
   // transparent (Clear) spools render a checkerboard rather than collapsing
   // to solid black (#1545).
-  const spoolColorStyle = selectedSpool.rgba ? getSwatchStyle(selectedSpool.rgba) : { backgroundColor: '#666' };
+  const spoolColorStyle =
+    spoolSwatchStyle(selectedSpool, 'preview') ??
+    (selectedSpool.rgba ? getSwatchStyle(selectedSpool.rgba) : { backgroundColor: '#666' });
 
   return (
     <div className="flex flex-col items-center text-center space-y-4 w-full">

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików