mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
Paint an AMS slot card with the spool's colours, not the tray's (issue #2967)
A Ziro "Colorful Mist" -- yellow, cyan and pink, effect Tri Color -- hovered on the printer card as a single flat pink rectangle. A printer reports exactly one tray_color hex per tray and nothing else, so telemetry cannot describe a gradient or a surface effect and never will. The header now paints the bound spool's own swatch whenever that spool declares extra colour stops or an effect, through buildFilamentBackground -- the builder the Inventory swatches already use, so the two surfaces cannot drift apart. A plain single-colour spool keeps the flat backgroundColor it has always had, and a slot with nothing bound is untouched, so the common case goes nowhere near the gradient path. The gate is "any stop at all", not "more than one". buildColorLayer ignores rgba the moment stops exist, so a one-stop spool renders that stop rather than the slot hex; skipping it would leave this card showing a different colour from the Inventory row for the same spool, which is the class of disagreement the shared builder exists to prevent. isLightColor now tests the colour actually on screen. Once the spool's swatch is painted the base is no longer the slot hex -- a single stop replaces it outright, and an effect-only spool paints the spool's own rgba -- so testing the slot hex would pick the text colour for a background that is not there. Above one band no single hex can decide legibility, and the name sits dead centre where a multi-stop background is likeliest to change under it. So a genuinely multi-band header puts the name on the same scrim the vendor badge already uses. One stop, or an effect over one colour, still vendor badge already uses. One stop, or an effect over one colour, still leaves a real base colour to test and keeps the contrast rule it had. Spoolman mode gains the gradient in the process. Spoolman has held the stops in filament.multi_color_hexes all along and the label renderer has been reading them for releases, but _map_spoolman_spool never returned them -- so the identical roll registered in Spoolman rendered flat while the internally-managed one did not. Both now share one parser rather than reading the same field two ways. What stays asymmetric is Spoolman's own limitation, and it is pinned by a test rather than left to be rediscovered: Spoolman has no field for a surface effect at all. Its only neighbouring field, multi_color_direction, says how the stops are laid out, not that the roll is silk or glitter. effect_type is therefore None for a Spoolman spool instead of guessed at, and silk/sparkle/wood remain internal-only. The other two halves of the report -- the header naming the colour "White" instead of "Colorful Mist", and the print dialog offering "A3: PLA (White)" -- were already fixed on dev by #2875 and by the slot-naming change that landed the day after this was filed. Neither is in 1.2.5.3, which is what the reporter is running.
This commit is contained in:
@@ -731,3 +731,154 @@ describe('FilamentHoverCard assigned spool name', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The header swatch (#2967).
|
||||
*
|
||||
* A tray record carries one `tray_color` hex and nothing else, so telemetry can
|
||||
* never describe a gradient or a surface effect. The reporter's Ziro "Colorful
|
||||
* Mist" -- yellow, cyan and pink, effect Tri Color -- painted the header as one
|
||||
* flat band of the single hex the slot happened to be configured with. The
|
||||
* spool knows better, and the header now paints the spool's own swatch through
|
||||
* the same builder the Inventory swatches use.
|
||||
*/
|
||||
describe('FilamentHoverCard — assigned spool swatch (#2967)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
const triColorSpool = {
|
||||
id: 191,
|
||||
material: 'PLA',
|
||||
subtype: 'Matte',
|
||||
brand: 'Ziro',
|
||||
color_name: 'Colorful Mist',
|
||||
rgba: 'FFB6C1FF',
|
||||
extra_colors: 'ffff00,00ffff,ffb6c1',
|
||||
effect_type: 'tri-color',
|
||||
};
|
||||
|
||||
// The swatch header is the card's first child: a fixed-height block carrying
|
||||
// the colour and the name. Queried off `document` rather than the render
|
||||
// container because the card is portaled into document.body.
|
||||
function header(): HTMLElement {
|
||||
const el = document.querySelector('.h-12') as HTMLElement | null;
|
||||
expect(el, 'header block not found').not.toBeNull();
|
||||
return el as HTMLElement;
|
||||
}
|
||||
|
||||
it('paints the gradient for a multi-colour spool', async () => {
|
||||
renderWithHover(
|
||||
<FilamentHoverCard data={baseFilamentData} inventory={{ assignedSpool: triColorSpool }}>
|
||||
<div>trigger</div>
|
||||
</FilamentHoverCard>
|
||||
);
|
||||
vi.advanceTimersByTime(100);
|
||||
await waitFor(() => expect(screen.getByText('Colorful Mist')).toBeInTheDocument());
|
||||
expect(header().style.backgroundImage).toContain('gradient');
|
||||
});
|
||||
|
||||
it('carries every stop the spool declared', async () => {
|
||||
renderWithHover(
|
||||
<FilamentHoverCard data={baseFilamentData} inventory={{ assignedSpool: triColorSpool }}>
|
||||
<div>trigger</div>
|
||||
</FilamentHoverCard>
|
||||
);
|
||||
vi.advanceTimersByTime(100);
|
||||
await waitFor(() => expect(screen.getByText('Colorful Mist')).toBeInTheDocument());
|
||||
const image = header().style.backgroundImage.toLowerCase();
|
||||
expect(image).toContain('#ffff00');
|
||||
expect(image).toContain('#00ffff');
|
||||
expect(image).toContain('#ffb6c1');
|
||||
});
|
||||
|
||||
it('leaves a plain single-colour spool on the flat slot colour', async () => {
|
||||
// The common case must not go anywhere near the gradient machinery.
|
||||
renderWithHover(
|
||||
<FilamentHoverCard
|
||||
data={baseFilamentData}
|
||||
inventory={{
|
||||
assignedSpool: { ...triColorSpool, extra_colors: null, effect_type: null },
|
||||
}}
|
||||
>
|
||||
<div>trigger</div>
|
||||
</FilamentHoverCard>
|
||||
);
|
||||
vi.advanceTimersByTime(100);
|
||||
await waitFor(() => expect(screen.getByText('Colorful Mist')).toBeInTheDocument());
|
||||
expect(header().style.backgroundImage).toBe('');
|
||||
});
|
||||
|
||||
it('paints the swatch for an effect with no extra colours', async () => {
|
||||
// A silk roll is one colour with a surface the hex cannot express.
|
||||
renderWithHover(
|
||||
<FilamentHoverCard
|
||||
data={baseFilamentData}
|
||||
inventory={{ assignedSpool: { ...triColorSpool, extra_colors: null, effect_type: 'silk' } }}
|
||||
>
|
||||
<div>trigger</div>
|
||||
</FilamentHoverCard>
|
||||
);
|
||||
vi.advanceTimersByTime(100);
|
||||
await waitFor(() => expect(screen.getByText('Colorful Mist')).toBeInTheDocument());
|
||||
expect(header().style.backgroundImage).not.toBe('');
|
||||
});
|
||||
|
||||
it('puts the name on a scrim when the background has several bands', async () => {
|
||||
// One hex cannot decide legibility across yellow, cyan and pink, and the
|
||||
// label sits dead centre where the background is most likely to change.
|
||||
renderWithHover(
|
||||
<FilamentHoverCard data={baseFilamentData} inventory={{ assignedSpool: triColorSpool }}>
|
||||
<div>trigger</div>
|
||||
</FilamentHoverCard>
|
||||
);
|
||||
vi.advanceTimersByTime(100);
|
||||
await waitFor(() => expect(screen.getByText('Colorful Mist')).toBeInTheDocument());
|
||||
expect(screen.getByText('Colorful Mist').className).toContain('bg-black/60');
|
||||
});
|
||||
|
||||
it('does not scrim a single-colour spool', async () => {
|
||||
renderWithHover(
|
||||
<FilamentHoverCard
|
||||
data={baseFilamentData}
|
||||
inventory={{
|
||||
assignedSpool: { ...triColorSpool, extra_colors: null, effect_type: null },
|
||||
}}
|
||||
>
|
||||
<div>trigger</div>
|
||||
</FilamentHoverCard>
|
||||
);
|
||||
vi.advanceTimersByTime(100);
|
||||
await waitFor(() => expect(screen.getByText('Colorful Mist')).toBeInTheDocument());
|
||||
expect(screen.getByText('Colorful Mist').className).not.toContain('bg-black/60');
|
||||
});
|
||||
|
||||
it('keeps a slot with no assigned spool exactly as it was', async () => {
|
||||
renderWithHover(
|
||||
<FilamentHoverCard data={baseFilamentData} inventory={{ assignedSpool: null }}>
|
||||
<div>trigger</div>
|
||||
</FilamentHoverCard>
|
||||
);
|
||||
vi.advanceTimersByTime(100);
|
||||
await waitFor(() => expect(screen.getByText('Red')).toBeInTheDocument());
|
||||
expect(header().style.backgroundImage).toBe('');
|
||||
});
|
||||
|
||||
it('tolerates a spool that predates the swatch fields', async () => {
|
||||
// Older callers send neither rgba nor extra_colors; the optional fields
|
||||
// must not turn that into a blank or a crash.
|
||||
renderWithHover(
|
||||
<FilamentHoverCard
|
||||
data={baseFilamentData}
|
||||
inventory={{
|
||||
assignedSpool: { id: 1, material: 'PLA', subtype: null, brand: 'Ziro', color_name: 'Mist' },
|
||||
}}
|
||||
>
|
||||
<div>trigger</div>
|
||||
</FilamentHoverCard>
|
||||
);
|
||||
vi.advanceTimersByTime(100);
|
||||
await waitFor(() => expect(screen.getByText('Mist')).toBeInTheDocument());
|
||||
expect(header().style.backgroundImage).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Droplets, Copy, Check, Settings2, Package, Unlink } from 'lucide-react';
|
||||
import { isLightColor, resolveSpoolColorName } from '../utils/colors';
|
||||
import { buildFilamentBackground, parseStops } from './filamentSwatchHelpers';
|
||||
|
||||
interface FilamentData {
|
||||
vendor: 'Bambu Lab' | 'Generic';
|
||||
@@ -36,7 +37,20 @@ interface InventoryConfig {
|
||||
// `subtype` is part of the spool's name, not decoration: "PLA" and "PLA Wood"
|
||||
// are different filaments, and a card that prints only the material tells the
|
||||
// user their wood-filled roll is plain PLA (the display-side half of #2902).
|
||||
assignedSpool?: { id: number; material: string; subtype: string | null; brand: string | null; color_name: string | null; remainingWeightGrams?: number | null } | null;
|
||||
// `rgba` / `extra_colors` / `effect_type` are the spool's own swatch. The
|
||||
// slot's telemetry colour is a single hex and can never describe a gradient
|
||||
// or a surface effect, so a tri-colour roll read as one flat band (#2967).
|
||||
assignedSpool?: {
|
||||
id: number;
|
||||
material: string;
|
||||
subtype: string | null;
|
||||
brand: string | null;
|
||||
color_name: string | null;
|
||||
rgba?: string | null;
|
||||
extra_colors?: string | null;
|
||||
effect_type?: string | null;
|
||||
remainingWeightGrams?: number | null;
|
||||
} | null;
|
||||
isAssigned?: boolean;
|
||||
}
|
||||
|
||||
@@ -205,6 +219,52 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
|
||||
const displayColorName = assignedColorName || data.colorName;
|
||||
const assignedRemainingWeight = inventory?.assignedSpool?.remainingWeightGrams ?? null;
|
||||
|
||||
// The header paints the spool's own swatch whenever the spool describes more
|
||||
// than one colour, or any surface effect (#2967). Telemetry cannot: a tray
|
||||
// record carries a single `tray_color` hex, so a Tri Color roll of yellow,
|
||||
// cyan and pink read as one flat band of whichever hex the slot was
|
||||
// configured with.
|
||||
//
|
||||
// Only when the spool actually says something extra. A plain single-colour
|
||||
// spool keeps the flat `backgroundColor` it has always had, so the common
|
||||
// case is untouched and the gradient machinery cannot regress it.
|
||||
const assignedSwatch = inventory?.assignedSpool ?? null;
|
||||
const swatchStops = parseStops(assignedSwatch?.extra_colors);
|
||||
const hasSwatchEffect = Boolean(assignedSwatch?.effect_type);
|
||||
// Any stop at all, not just two: `buildColorLayer` ignores `rgba` the moment
|
||||
// stops exist, so a one-stop spool renders that stop's colour and not the
|
||||
// slot hex. Honouring it here is what keeps this header agreeing with the
|
||||
// Inventory swatch, which is the whole point of sharing the builder.
|
||||
const useSpoolSwatch = Boolean(assignedSwatch) && (swatchStops.length > 0 || hasSwatchEffect);
|
||||
// Built from the spool end to end when used. Mixing the spool's stops over
|
||||
// the slot's base hex would render a gradient the user never configured if
|
||||
// the two ever disagreed.
|
||||
const spoolSwatchStyle = useSpoolSwatch
|
||||
? buildFilamentBackground({
|
||||
effectSize: 'card',
|
||||
rgba: assignedSwatch?.rgba ?? colorHex,
|
||||
extraColors: assignedSwatch?.extra_colors,
|
||||
effectType: assignedSwatch?.effect_type,
|
||||
subtype: assignedSwatch?.subtype,
|
||||
})
|
||||
: null;
|
||||
// A single hex cannot decide legibility across several bands, and the header
|
||||
// label sits dead centre where a multi-stop background is most likely to
|
||||
// change under it. So a genuinely multi-band swatch puts the name on the same
|
||||
// scrim the vendor badge already uses rather than betting on one of the
|
||||
// stops. One stop, or an effect over one colour, leaves the base colour
|
||||
// intact -- the contrast test still has a real answer there, and scrimming
|
||||
// every effect spool would put a black pill on cards that never needed one.
|
||||
const swatchNeedsScrim = swatchStops.length > 1;
|
||||
// Which colour the contrast test should actually run against. Not always the
|
||||
// slot hex any more: once the spool's swatch is painted, a single stop
|
||||
// replaces the base entirely, and an effect-only spool paints the spool's own
|
||||
// rgba rather than the slot's. Testing the slot hex in either case would pick
|
||||
// the text colour for a background that is no longer on screen.
|
||||
const contrastBaseHex = useSpoolSwatch
|
||||
? (swatchStops.length === 1 ? swatchStops[0] : assignedSwatch?.rgba ?? colorHex)
|
||||
: colorHex;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={triggerRef}
|
||||
@@ -243,20 +303,28 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
|
||||
{/* Color swatch header - the hero element */}
|
||||
<div
|
||||
className="h-12 relative overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: colorHex || '#3d3d3d',
|
||||
}}
|
||||
style={
|
||||
spoolSwatchStyle
|
||||
? { ...spoolSwatchStyle, backgroundColor: colorHex || '#3d3d3d' }
|
||||
: { backgroundColor: colorHex || '#3d3d3d' }
|
||||
}
|
||||
>
|
||||
{/* Subtle gradient overlay for depth */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-white/10 to-transparent" />
|
||||
|
||||
{/* Color name on swatch */}
|
||||
<div className={`
|
||||
absolute inset-0 flex items-center justify-center
|
||||
font-semibold text-sm tracking-wide
|
||||
${isLightColor(colorHex) ? 'text-black/80' : 'text-white/90'}
|
||||
`}>
|
||||
{displayColorName}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span
|
||||
className={
|
||||
swatchNeedsScrim
|
||||
? 'px-2 py-0.5 rounded bg-black/60 text-white font-semibold text-sm tracking-wide'
|
||||
: `font-semibold text-sm tracking-wide ${
|
||||
isLightColor(contrastBaseHex) ? 'text-black/80' : 'text-white/90'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{displayColorName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Vendor badge - solid background for visibility on any color */}
|
||||
|
||||
@@ -5645,6 +5645,12 @@ function PrinterCard({
|
||||
subtype: spoolmanSpool.subtype,
|
||||
brand: spoolmanSpool.brand ?? null,
|
||||
color_name: spoolmanSpool.color_name ?? null,
|
||||
// The spool's own swatch (#2967). Spoolman carries the
|
||||
// extra stops but has no effect field at all, so those
|
||||
// rolls gradient and never shimmer.
|
||||
rgba: spoolmanSpool.rgba ?? null,
|
||||
extra_colors: spoolmanSpool.extra_colors ?? null,
|
||||
effect_type: spoolmanSpool.effect_type ?? null,
|
||||
remainingWeightGrams: spoolmanSpool.label_weight
|
||||
? Math.max(0, Math.round(spoolmanSpool.label_weight - spoolmanSpool.weight_used))
|
||||
: undefined,
|
||||
@@ -5673,6 +5679,10 @@ function PrinterCard({
|
||||
subtype: assignment.spool.subtype,
|
||||
brand: assignment.spool.brand,
|
||||
color_name: assignment.spool.color_name,
|
||||
// The spool's own swatch (#2967).
|
||||
rgba: assignment.spool.rgba ?? null,
|
||||
extra_colors: assignment.spool.extra_colors ?? null,
|
||||
effect_type: assignment.spool.effect_type ?? null,
|
||||
remainingWeightGrams: Math.max(0, Math.round(assignment.spool.label_weight - assignment.spool.weight_used)),
|
||||
} : null,
|
||||
onAssignSpool: () => setAssignSpoolModal({
|
||||
@@ -6035,6 +6045,12 @@ function PrinterCard({
|
||||
subtype: spoolmanSpool.subtype,
|
||||
brand: spoolmanSpool.brand ?? null,
|
||||
color_name: spoolmanSpool.color_name ?? null,
|
||||
// The spool's own swatch (#2967). Spoolman carries the
|
||||
// extra stops but has no effect field at all, so those
|
||||
// rolls gradient and never shimmer.
|
||||
rgba: spoolmanSpool.rgba ?? null,
|
||||
extra_colors: spoolmanSpool.extra_colors ?? null,
|
||||
effect_type: spoolmanSpool.effect_type ?? null,
|
||||
remainingWeightGrams: spoolmanSpool.label_weight
|
||||
? Math.max(0, Math.round(spoolmanSpool.label_weight - spoolmanSpool.weight_used))
|
||||
: undefined,
|
||||
@@ -6063,6 +6079,10 @@ function PrinterCard({
|
||||
subtype: assignment.spool.subtype,
|
||||
brand: assignment.spool.brand,
|
||||
color_name: assignment.spool.color_name,
|
||||
// The spool's own swatch (#2967).
|
||||
rgba: assignment.spool.rgba ?? null,
|
||||
extra_colors: assignment.spool.extra_colors ?? null,
|
||||
effect_type: assignment.spool.effect_type ?? null,
|
||||
remainingWeightGrams: Math.max(0, Math.round(assignment.spool.label_weight - assignment.spool.weight_used)),
|
||||
} : null,
|
||||
onAssignSpool: () => setAssignSpoolModal({
|
||||
@@ -6310,6 +6330,12 @@ function PrinterCard({
|
||||
subtype: spoolmanSpool.subtype,
|
||||
brand: spoolmanSpool.brand ?? null,
|
||||
color_name: spoolmanSpool.color_name ?? null,
|
||||
// The spool's own swatch (#2967). Spoolman carries the
|
||||
// extra stops but has no effect field at all, so those
|
||||
// rolls gradient and never shimmer.
|
||||
rgba: spoolmanSpool.rgba ?? null,
|
||||
extra_colors: spoolmanSpool.extra_colors ?? null,
|
||||
effect_type: spoolmanSpool.effect_type ?? null,
|
||||
remainingWeightGrams: spoolmanSpool.label_weight
|
||||
? Math.max(0, Math.round(spoolmanSpool.label_weight - spoolmanSpool.weight_used))
|
||||
: undefined,
|
||||
@@ -6338,6 +6364,10 @@ function PrinterCard({
|
||||
subtype: assignment.spool.subtype,
|
||||
brand: assignment.spool.brand,
|
||||
color_name: assignment.spool.color_name,
|
||||
// The spool's own swatch (#2967).
|
||||
rgba: assignment.spool.rgba ?? null,
|
||||
extra_colors: assignment.spool.extra_colors ?? null,
|
||||
effect_type: assignment.spool.effect_type ?? null,
|
||||
remainingWeightGrams: Math.max(0, Math.round(assignment.spool.label_weight - assignment.spool.weight_used)),
|
||||
} : null,
|
||||
onAssignSpool: () => setAssignSpoolModal({
|
||||
|
||||
Reference in New Issue
Block a user