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

This commit is contained in:
maziggy
2026-09-30 16:19:09 +02:00
17 changed files with 369 additions and 14 deletions
+1
View File
@@ -45,6 +45,7 @@ All notable changes to Bambuddy will be documented in this file.
- **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
### Fixed
- **SpoolBuddy showed a multi-colour or effect spool as one flat colour (#3033, reported by @Sawtaytoes)** — The Filament page paints a spool's extra colours and effect: a blue-and-yellow split, a marble swirl, a galaxy sheen. SpoolBuddy drew every spool from its main colour alone, so a blue-and-yellow Silk spool was a blue disc, and three Galaxy Black spools were three identical black discs, on the screen used at the printer to pick the spool to load. The spool discs on the Inventory cards, the spool details, the tag-scanned and link-tag screens, and the small colour dots on the Inventory cards, the AMS slot menu, the Assign to AMS screen and the Write Tag screen now use the same painter as the Filament page whenever a spool has extra colours or an effect. A plain spool looks as before. A scanned tag now also sends the spool's extra colours and effect to SpoolBuddy, which it did not before. Spoolman spools show their extra colours; Spoolman has no effect field, so they show no effect.
- **HMS faults show Bambu's own description and the right level, and faults Bambu publishes no text for no longer vanish (#2728, reported by @gzimbric)** — Two problems in how printer faults were shown, both in the same path. **No description for most faults.** A fault from the printer's `hms[]` list is identified by a 16-digit code, but Bambuddy's table of descriptions only had the 8-digit codes of the other kind of fault (`print_error`), and the lookup cut the 16-digit code down to a shape that could never match a real one. So those faults never got text, and the printer card only showed them if they happened to offer action buttons. On the reporter's P2S, three faults the printer was holding showed nothing at all. Descriptions now come from Bambu Studio's own HMS files: about 6,400 codes, with the text for your printer model where models differ (`0300_8001` means "paused by the user" on some models and "paused by a pause command in the file" on others). The 24 texts Bambuddy had that Bambu Studio doesn't are kept. `scripts/generate_hms_catalog.py` regenerates the table from a Bambu Studio checkout. **Wrong level.** The level was read from the part of the code that names the printer part, not from the level the printer sends, so a fault that paused the print could show as blue "Info" and a mere notice as red "Serious". It now uses the printer's level, the way Bambu Studio reads it: **Error** (print stopped), **Warning** (print paused), **Notice** (print carries on). The printer card turns red for Error and Warning, amber for Notice only. A `print_error` has no level of its own, so it takes one from its error number (4xxx Error, 8xxx Warning, Cxxx Notice) instead of always showing Warning. **What counts.** A fault counts toward the printer card, the problem badge and the Camera Wall when it offers action buttons, or when Bambu publishes text for it, except an `hms[]` Notice with no buttons: those are things like "The top cover is open" or "the chamber is hot, fan speed increased" that a printer can hold through a whole print. A `print_error` prompt at the Notice level still counts, as before. Faults that don't count are no longer hidden: the error modal lists them, with their text where Bambu has one, collapsed under **Also reported, not counted**, and **Clear Errors** shows whenever the printer holds any fault. **Notifications and the MQTT relay** follow the same rule. Printer-error notifications now also go out for `hms[]` faults that count and have a description, which before this almost never happened, and the relay's error topic carries the faults that count; the H2S cancel echo, which the old check dropped only by accident, stays out. The old check that was meant to skip minor messages read the wrong field, and on the real level it would have skipped the faults that stop a print (spotted by @ojimpo). A fault already notified was recognised by the printer part alone, so a second fault on the same part, such as the two #1840's H2C held at once (`0500-0600-0002-0005` and `-0006`), was never notified; each fault is now recognised by its full code. **Other changes.** The queue's failure message labels an `hms[]` fault with its full code, as the printer screen shows it, such as `0500-0300-0002-000E`, instead of a short code nobody can look up. The status response, WebSocket and Camera Wall carry `description` and the new `severity` values; anything that reads `severity` through the API or the MQTT relay will see different numbers for the same fault. The level labels and the new modal text are translated in all 15 locales.
- **A print the printer's AI camera stopped for spaghetti is archived with a failure reason (#2946, reported and contributed by @ojimpo in #2954)** — When the onboard AI print monitor halts a print for spaghetti or a model coming off the plate, the printer raises HMS code `0300_8003`. That code was missing from the list Bambuddy uses to name a failed print's reason, so the archive was saved with no reason at all, even though its neighbour `0300_8004` (filament runout) was on the list. `0300_8003` and `0C00_8042`, the same AI detection reported from the motion-controller module, now archive as **Spaghetti / Detached**, the option the archive editor already offers, shown in your language. Two nearby codes are left out on purpose: `0C00_C004` ("Possible spaghetti failure was detected") is a warning while the print keeps running, and `0300_800A` is a filament pile-up in the waste chute, not a failed print. Archives saved before this keep their empty reason and can be set by hand. New tests also check that the failure reasons the backend accepts match the ones the archive editor offers, and that every one has English text.
- **A drying command the AMS never started no longer shows as an active cycle, or holds the queue forever (#2896, reported and contributed by @M2ABRAMSTANK in #3096)** — A printer can accept an AMS drying command and set its timer without ever starting the cycle. On the reporter's H2D, mid-print, two AMS-HT units were counting down and heating while a third sat at 720 minutes, cold, and its card still showed the amber drying badge. Bambuddy now notices when a unit's countdown hasn't moved for 150 seconds while the AMS reports no drying phase. A live countdown moves once a minute, so a real cycle is never mistaken for one. The card then shows a grey **Drying not running** badge instead, without the countdown and with Stop still available. It says "not running" rather than "not started" because a cycle paused partway looks the same. If the AMS does report checking, drying or cooling, it always counts as drying. The same timer also never reaches zero, so everything that waited for drying to end waited for good. With **Block queue until drying finishes** on, the queue held that printer indefinitely. A scheduled drying run stayed "running" forever, and the AMS temperature alarm stayed muted on a unit that wasn't heating. Now a printer whose only timers are parked doesn't hold the queue, and doesn't make a scheduled run wait. A scheduled run whose timer parks goes back to the queue if a print is running. On an idle printer it fails with the reason "the printer accepted the command, but the AMS did not start drying". In both cases the timer is left on the printer, since it may still start once power frees up. The temperature alarm treats a parked unit as not drying, and the printer card no longer counts one when deciding whether a dropped file prints now or queues. A failed scheduled drying run now shows its reason in your language instead of in English. Translated in all locales.
+6
View File
@@ -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,
@@ -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
@@ -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');
});
});
});
@@ -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();
});
});
@@ -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());
@@ -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 (
<>
@@ -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"
@@ -102,6 +102,7 @@ export function LinkSpoolModal({
>
<SpoolIcon
color={spoolColorString(spool.rgba)}
spool={spool}
isEmpty={false}
size={40}
/>
@@ -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 */}
@@ -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"
@@ -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"
@@ -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
View File
@@ -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,
@@ -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">
@@ -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 || '-'}
@@ -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">