diff --git a/frontend/src/__tests__/components/FilamentSwatch.test.tsx b/frontend/src/__tests__/components/FilamentSwatch.test.tsx
index c8f92ebd7..7ec3222ba 100644
--- a/frontend/src/__tests__/components/FilamentSwatch.test.tsx
+++ b/frontend/src/__tests__/components/FilamentSwatch.test.tsx
@@ -13,7 +13,7 @@ import { buildFilamentBackground } from '../../components/filamentSwatchHelpers'
describe('FilamentSwatch', () => {
it('renders a solid swatch when only rgba is set', () => {
- render();
+ render();
const el = screen.getByTestId('filament-swatch');
// Solid swatches are emitted as a 1-stop linear-gradient so the
// checkerboard layer below is still visible through alpha.
@@ -23,13 +23,13 @@ describe('FilamentSwatch', () => {
});
it('falls back to grey when nothing is set', () => {
- render();
+ render();
const el = screen.getByTestId('filament-swatch');
expect(el.style.backgroundImage.toLowerCase()).toContain('#808080');
});
it('renders a linear gradient when extraColors has multiple stops', () => {
- render();
+ render();
const el = screen.getByTestId('filament-swatch');
const bg = el.style.backgroundImage.toLowerCase();
// Linear (not conic) for non-Multicolor subtype.
@@ -46,6 +46,7 @@ describe('FilamentSwatch', () => {
rgba="ff0000ff"
extraColors="ec984c,6cd4bc,a66eb9"
subtype="Multicolor"
+ effectSize='table'
/>,
);
const el = screen.getByTestId('filament-swatch');
@@ -55,13 +56,13 @@ describe('FilamentSwatch', () => {
it('also uses conic-gradient when effectType is multicolor (catalog path)', () => {
// Catalog entries don't have a `subtype`, so the multicolor effect_type
// value also has to trigger conic rendering for parity with the spool path.
- render();
+ render();
const el = screen.getByTestId('filament-swatch');
expect(el.style.backgroundImage.toLowerCase()).toMatch(/conic-gradient/);
});
it('layers an effect overlay on top of the colour layer for sparkle', () => {
- render();
+ render();
const el = screen.getByTestId('filament-swatch');
// Sparkle overlay is built from radial-gradient layers — confirm at least
// one is in the composed background, ahead of the colour layer.
@@ -70,7 +71,7 @@ describe('FilamentSwatch', () => {
it('renders an overlay for silk variant', () => {
// Silk gets a soft sheen overlay (added in #1154 follow-up).
- render();
+ render();
const el = screen.getByTestId('filament-swatch');
expect(el.style.backgroundImage).toMatch(/linear-gradient/);
});
@@ -78,7 +79,7 @@ describe('FilamentSwatch', () => {
it('treats categorical-only variants (gradient/dual-color) as labels without an overlay', () => {
// No extra_colors set → swatch falls back to the solid colour layer; the
// categorical effect value alone does not paint a sheen overlay.
- render();
+ render();
const el = screen.getByTestId('filament-swatch');
// No radial-gradient (sparkle/glow) and no rainbow/sheen overlay either —
// gradient/dual-color/tri-color are pure labels until extra_colors is set.
@@ -86,13 +87,13 @@ describe('FilamentSwatch', () => {
});
it('ignores unknown effect types instead of throwing', () => {
- render();
+ render();
const el = screen.getByTestId('filament-swatch');
expect(el.style.backgroundImage).not.toMatch(/radial-gradient/);
});
it('renders a checkerboard underneath so alpha is visible', () => {
- render();
+ render();
const el = screen.getByTestId('filament-swatch');
// The component always appends a checkerboard layer last so semi-
// transparent rgba values actually look transparent to the user.
@@ -100,7 +101,7 @@ describe('FilamentSwatch', () => {
});
it('skips invalid hex tokens in extraColors instead of throwing', () => {
- render();
+ render();
const el = screen.getByTestId('filament-swatch');
const bg = el.style.backgroundImage.toLowerCase();
// The two valid stops survive; the garbage token is dropped.
@@ -110,7 +111,7 @@ describe('FilamentSwatch', () => {
});
it('uses extra_colors for the title fallback when provided', () => {
- render();
+ render();
const el = screen.getByTestId('filament-swatch');
// Tooltip should show the comma-joined hex stops, not the (unset) rgba.
expect(el.title.toLowerCase()).toContain('#ff0000');
@@ -132,6 +133,7 @@ describe('dual-color / tri-color hard-split bars (#1154 follow-up)', () => {
const bg = buildFilamentBackground({
extraColors: '7f3696,006ec9',
effectType: 'dual-color',
+ effectSize: 'table',
});
const lower = bg.backgroundImage.toLowerCase();
// Hard split direction — ``to right`` (or ``90deg``), never ``135deg``.
@@ -150,6 +152,7 @@ describe('dual-color / tri-color hard-split bars (#1154 follow-up)', () => {
const bg = buildFilamentBackground({
extraColors: 'ff0000,00ff00,0000ff',
effectType: 'tri-color',
+ effectSize: 'table',
});
const lower = bg.backgroundImage.toLowerCase();
expect(lower).toContain('to right');
@@ -163,6 +166,7 @@ describe('dual-color / tri-color hard-split bars (#1154 follow-up)', () => {
const bg = buildFilamentBackground({
extraColors: '7f3696,006ec9',
effectType: 'gradient',
+ effectSize: 'table',
});
const lower = bg.backgroundImage.toLowerCase();
// Original visual preserved for non-dual / non-tri stops.
@@ -182,22 +186,23 @@ describe('dual-color / tri-color hard-split bars (#1154 follow-up)', () => {
const dual = buildFilamentBackground({
extraColors: '7f3696,006ec9',
effectType: 'dual-color',
+ effectSize: 'table',
});
const grad = buildFilamentBackground({
extraColors: '7f3696,006ec9',
effectType: 'gradient',
+ effectSize: 'table',
});
expect(dual.backgroundImage).not.toBe(grad.backgroundImage);
});
});
describe('Sparkle prominence + checkerboard density (#1154 follow-up cosmetic)', () => {
- it('renders Sparkle with at least 10 distinct dots so it reads on card-sized swatches', () => {
+ it('renders dense sparkle on card preset (at least 10 dots)', () => {
// The original Sparkle pattern was 4 dots — too subtle on a 200×60px
- // banner. The fix bumps it to 13 mixed-size flecks. Pin the contract
- // at "at least 10" so future tweaks have headroom without the test
- // breaking on every adjustment.
- render();
+ // banner. Now we use situation-aware dot counts: more dots for larger presets.
+ // Verify the card preset produces a dense pattern with at least 10 dots.
+ render();
const el = screen.getByTestId('filament-swatch');
const radialCount = (el.style.backgroundImage.match(/radial-gradient/g) ?? []).length;
expect(radialCount).toBeGreaterThanOrEqual(10);
@@ -208,12 +213,76 @@ describe('Sparkle prominence + checkerboard density (#1154 follow-up cosmetic)',
// gradient over the whole element and a card-sized banner only showed
// 4 huge cells. Verify the checker layer carries an explicit pixel
// tile size.
- const bg = buildFilamentBackground({ rgba: 'ff0000ff' });
+ const bg = buildFilamentBackground({ rgba: 'ff0000ff', effectSize: 'table' });
const sizes = bg.backgroundSize.split(',').map((s) => s.trim());
// Last layer is the checker; should be a fixed pixel tile, not 'cover'.
expect(sizes[sizes.length - 1]).toMatch(/^\d+px(\s+\d+px)?$/);
expect(sizes[sizes.length - 1]).not.toContain('cover');
});
+
+ it('limits sparkle dot count per size preset (table/card/bar)', () => {
+ const tableBg = buildFilamentBackground({
+ rgba: 'ff0000ff',
+ effectType: 'sparkle',
+ effectSize: 'table',
+ });
+ const cardBg = buildFilamentBackground({
+ rgba: 'ff0000ff',
+ effectType: 'sparkle',
+ effectSize: 'card',
+ });
+ const barBg = buildFilamentBackground({
+ rgba: 'ff0000ff',
+ effectType: 'sparkle',
+ effectSize: 'bar',
+ });
+
+ const countRadial = (css: string) => (css.match(/radial-gradient/g) ?? []).length;
+ expect(countRadial(tableBg.backgroundImage)).toBe(5);
+ expect(countRadial(cardBg.backgroundImage)).toBe(40);
+ expect(countRadial(barBg.backgroundImage)).toBe(20);
+ });
+
+ it('scales sparkle dot radii by size preset while keeping seeded output deterministic', () => {
+ const tableBg = buildFilamentBackground({
+ rgba: 'ff0000ff',
+ effectType: 'sparkle',
+ effectSize: 'table',
+ });
+ const barBg = buildFilamentBackground({
+ rgba: 'ff0000ff',
+ effectType: 'sparkle',
+ effectSize: 'bar',
+ });
+
+ const tableBgRepeat = buildFilamentBackground({
+ rgba: 'ff0000ff',
+ effectType: 'sparkle',
+ effectSize: 'table',
+ });
+
+ const tableBgOther = buildFilamentBackground({
+ rgba: '00ff00ff',
+ effectType: 'sparkle',
+ effectSize: 'table',
+ });
+
+ // Same seed must produce byte-identical overlay output.
+ expect(tableBg.backgroundImage).toBe(tableBgRepeat.backgroundImage);
+
+ // Different seeds must produce different overlay output.
+ expect(tableBg.backgroundImage).not.toBe(tableBgOther.backgroundImage);
+
+ // Radius grows for bar preset, preventing sparse-looking large banners.
+ // Extract the first radius from each CSS string by looking for "0 Xpx, transparent Ypx"
+ const tableR = tableBg.backgroundImage.match(/0[ ]+(\d+\.?\d*)px[,][ ]*transparent[ ]+(\d+\.?\d*)px/);
+ const barR = barBg.backgroundImage.match(/0[ ]+(\d+\.?\d*)px[,][ ]*transparent[ ]+(\d+\.?\d*)px/);
+ if (!tableR || !barR) {
+ throw new Error(`Failed to extract radii: tableR=${tableR}, barR=${barR}`);
+ }
+ expect(Number(tableR[1])).toBeLessThan(Number(barR[1]));
+ expect(Number(tableR[2])).toBeLessThan(Number(barR[2]));
+ });
});
describe('buildFilamentBackground', () => {
@@ -222,6 +291,7 @@ describe('buildFilamentBackground', () => {
rgba: 'ff0000ff',
extraColors: 'aabbcc,ddeeff',
effectType: 'matte',
+ effectSize: 'table',
});
// Effect overlay → colour layer → checkerboard, in that order.
expect(bg.backgroundImage).toMatch(/linear-gradient/);
@@ -237,7 +307,7 @@ describe('buildFilamentBackground', () => {
});
it('returns a usable solid background when only rgba is provided', () => {
- const bg = buildFilamentBackground({ rgba: '00ff00ff' });
+ const bg = buildFilamentBackground({ rgba: '00ff00ff', effectSize: 'table' });
expect(bg.backgroundImage.toLowerCase()).toContain('#00ff00ff');
expect(bg.backgroundImage).toMatch(/repeating-conic-gradient/);
});
diff --git a/frontend/src/components/ColorCatalogSettings.tsx b/frontend/src/components/ColorCatalogSettings.tsx
index d079b5579..1ecfe9ded 100644
--- a/frontend/src/components/ColorCatalogSettings.tsx
+++ b/frontend/src/components/ColorCatalogSettings.tsx
@@ -640,6 +640,7 @@ export function ColorCatalogSettings() {
className="w-8 h-8"
shape="square"
title={entry.hex_color}
+ effectSize="table"
/>
{entry.manufacturer} |
diff --git a/frontend/src/components/FilamentSwatch.tsx b/frontend/src/components/FilamentSwatch.tsx
index 83e58b159..b9b120f5f 100644
--- a/frontend/src/components/FilamentSwatch.tsx
+++ b/frontend/src/components/FilamentSwatch.tsx
@@ -1,11 +1,9 @@
import React, { useMemo } from 'react';
import {
- CHECKERBOARD_BG,
- CHECKERBOARD_TILE_SIZE,
- EFFECT_OVERLAYS,
- buildColorLayer,
parseStops,
+ buildFilamentBackground,
type FilamentEffect,
+ type SwatchType,
} from './filamentSwatchHelpers';
/** Shared filament-colour swatch. See `filamentSwatchHelpers.ts` for the
@@ -28,6 +26,8 @@ export interface FilamentSwatchProps {
style?: React.CSSProperties;
/** Native title attribute for hover tooltip. */
title?: string;
+ /** Tune effect appearance based on target div size. */
+ effectSize: SwatchType;
}
export function FilamentSwatch({
@@ -39,30 +39,16 @@ export function FilamentSwatch({
shape = 'circle',
style,
title,
+ effectSize,
}: FilamentSwatchProps) {
const stops = useMemo(() => parseStops(extraColors), [extraColors]);
- const colorLayer = useMemo(
- () => buildColorLayer(rgba, stops, subtype, effectType),
- [rgba, stops, subtype, effectType],
+
+ const filamentBackground = useMemo(
+ () => buildFilamentBackground({ effectSize, rgba, extraColors, effectType, subtype }),
+ [effectSize, rgba, extraColors, effectType, subtype]
);
-
- const effectKey =
- typeof effectType === 'string' && effectType in EFFECT_OVERLAYS
- ? (effectType as FilamentEffect)
- : null;
- const effectLayer = effectKey ? EFFECT_OVERLAYS[effectKey] ?? null : null;
-
- // Layer order (top → bottom): effect overlay → colour layer → checkerboard.
- // Per-layer background-size: 'cover' on the painted layers, fixed tile on
- // the checkerboard so its cell density doesn't scale with element size
- // (a card-sized swatch with `cover` checker would render only 4 huge
- // cells; #1154 follow-up).
- const layers: { image: string; size: string }[] = [];
- if (effectLayer) layers.push({ image: effectLayer, size: 'cover' });
- layers.push({ image: colorLayer, size: 'cover' });
- layers.push({ image: CHECKERBOARD_BG, size: CHECKERBOARD_TILE_SIZE });
- const backgroundImage = layers.map((l) => l.image).join(', ');
- const backgroundSize = layers.map((l) => l.size).join(', ');
+ const backgroundImage = filamentBackground.backgroundImage;
+ const backgroundSize = filamentBackground.backgroundSize;
const shapeClass =
shape === 'circle' ? 'rounded-full' : shape === 'pill' ? 'rounded-full' : 'rounded';
diff --git a/frontend/src/components/filamentSwatchHelpers.ts b/frontend/src/components/filamentSwatchHelpers.ts
index 60808d121..43dadd9ff 100644
--- a/frontend/src/components/filamentSwatchHelpers.ts
+++ b/frontend/src/components/filamentSwatchHelpers.ts
@@ -1,3 +1,5 @@
+import { hash_fnv1a32, random_mulberry32 } from '../utils/random';
+
/* Enhanced filament-colour rendering helpers (#1154).
*
* Pure (non-component) exports that drive `` and any caller
@@ -37,6 +39,22 @@ export type FilamentEffect =
| 'tri-color'
| 'multicolor';
+/** Intended target swatch type for effect rendering. */
+export type SwatchType = 'table' | 'preview' | 'card' | 'bar' | 'groupheader';
+export type EffectLayer = string | string[];
+
+/** Presets for the different swatch types */
+export const SWATCH_TYPE_PRESETS: Readonly> = {
+ table: { dotCount: 5, dotScale: 1 },
+ preview: { dotCount: 8, dotScale: 1.5 },
+ card: { dotCount: 40, dotScale: 2 },
+ bar: { dotCount: 20, dotScale: 2 },
+ groupheader: { dotCount: 80, dotScale: 2 },
+};
+
/** Public list of all known effect/variant values, in display order. Shared
* by the spool form's ColorSection dropdown and the colour-catalog editor
* so the two stay in lockstep. Each value pairs with an i18n key under
@@ -76,7 +94,7 @@ export const FILAMENT_EFFECT_OPTIONS: ReadonlyArray<{
// follow-up reporter feedback). Per-layer sizing is supported by every
// modern browser via comma-separated ``background-size``.
export const CHECKERBOARD_BG =
- 'repeating-conic-gradient(#cbcbcb 0% 25%, #f5f5f5 0% 50%)';
+ 'repeating-conic-gradient(#979797 0% 25%, #f5f5f5 0% 50%)';
export const CHECKERBOARD_TILE_SIZE = '12px 12px';
/** Optional CSS overlay layer for variants that have a visual treatment.
@@ -84,48 +102,47 @@ export const CHECKERBOARD_TILE_SIZE = '12px 12px';
* an overlay, just sit in the data. `multicolor` is special: its visual
* effect is to switch the colour layer to a conic-gradient (see
* `buildColorLayer`), not to add an overlay layer. */
-export const EFFECT_OVERLAYS: Partial> = {
- // Sparkle: bright flecks scattered across the swatch. The original 4-dot
- // pattern was too subtle on card-sized swatches (#1154 follow-up); 13
- // dots in mixed sizes (1px / 1.5px / 2px) and opacities give depth and
- // make the variant clearly distinguishable from solid + multicolor.
- sparkle:
- 'radial-gradient(circle at 12% 18%, rgba(255,255,255,0.95) 0 1.5px, transparent 2px), ' +
- 'radial-gradient(circle at 28% 42%, rgba(255,255,255,0.85) 0 1px, transparent 1.5px), ' +
- 'radial-gradient(circle at 38% 78%, rgba(255,255,255,0.95) 0 1.5px, transparent 2px), ' +
- 'radial-gradient(circle at 52% 12%, rgba(255,255,255,0.80) 0 1px, transparent 1.5px), ' +
- 'radial-gradient(circle at 58% 55%, rgba(255,255,255,1) 0 2px, transparent 2.5px), ' +
- 'radial-gradient(circle at 68% 28%, rgba(255,255,255,0.75) 0 1px, transparent 1.5px), ' +
- 'radial-gradient(circle at 75% 88%, rgba(255,255,255,0.85) 0 1px, transparent 1.5px), ' +
- 'radial-gradient(circle at 82% 48%, rgba(255,255,255,0.95) 0 1.5px, transparent 2px), ' +
- 'radial-gradient(circle at 88% 18%, rgba(255,255,255,0.80) 0 1px, transparent 1.5px), ' +
- 'radial-gradient(circle at 92% 70%, rgba(255,255,255,0.85) 0 1px, transparent 1.5px), ' +
- 'radial-gradient(circle at 18% 62%, rgba(255,255,255,0.75) 0 1px, transparent 1.5px), ' +
- 'radial-gradient(circle at 45% 32%, rgba(255,255,255,0.65) 0 0.8px, transparent 1.2px), ' +
- 'radial-gradient(circle at 65% 72%, rgba(255,255,255,0.65) 0 0.8px, transparent 1.2px)',
+export const EFFECT_OVERLAYS: Partial<
+ Record EffectLayer>
+> = {
+ // Sparkle: bright flecks — positions seeded from spool color+extracolors+subtype+effectType.
+ // to give identical spools the same sparkle pattern while different spools get different patterns.
+ sparkle: (spoolSeed = 0, effectSize = 'table') => {
+ const rand = random_mulberry32(spoolSeed);
+ const preset = SWATCH_TYPE_PRESETS[effectSize] ?? SWATCH_TYPE_PRESETS.table;
+ const sparks: string[] = [];
+ for (let i = 0; i < preset.dotCount; i++) {
+ const x = rand.intBetween(1, 99);
+ const y = rand.intBetween(1, 99);
+ const s = rand.floatBetween(1.0, preset.dotScale);
+ const a = rand.floatBetween(0.65, 1.0);
+ sparks.push(`radial-gradient(circle at ${x}% ${y}%, rgba(255,248,220,${a}) 0 ${s/2}px, transparent ${s}px)`);
+ }
+ return sparks;
+ },
// Wood: subtle horizontal banding to mimic grain.
- wood:
+ wood: () =>
'repeating-linear-gradient(90deg, ' +
'rgba(0,0,0,0.18) 0 1px, transparent 1px 6px, ' +
'rgba(0,0,0,0.08) 6px 7px, transparent 7px 12px)',
// Marble: soft diagonal swirls.
- marble:
+ marble: () =>
'repeating-linear-gradient(135deg, rgba(255,255,255,0.18) 0 2px, transparent 2px 8px), ' +
'repeating-linear-gradient(45deg, rgba(0,0,0,0.10) 0 1px, transparent 1px 7px)',
// Glow: bright center fade — visual hint for glow-in-the-dark filaments.
- glow:
+ glow: () =>
'radial-gradient(circle at 50% 50%, rgba(255,255,255,0.35) 0%, rgba(255,255,255,0) 70%)',
// Matte: very subtle inset shadow to flatten the highlight.
- matte:
+ matte: () =>
'linear-gradient(180deg, rgba(0,0,0,0.10) 0%, rgba(0,0,0,0) 50%, rgba(0,0,0,0.10) 100%)',
// Silk / Galaxy: diagonal sheen to suggest the lustrous finish those
// filaments have. Galaxy uses a slightly stronger highlight.
- silk:
+ silk: () =>
'linear-gradient(110deg, rgba(255,255,255,0) 30%, rgba(255,255,255,0.30) 50%, rgba(255,255,255,0) 70%)',
- galaxy:
+ galaxy: () =>
'linear-gradient(110deg, rgba(255,255,255,0) 25%, rgba(255,255,255,0.40) 50%, rgba(255,255,255,0) 75%)',
// Metal: brushed-metal look via tight horizontal striations + soft sheen.
- metal:
+ metal: () =>
'repeating-linear-gradient(90deg, rgba(255,255,255,0.10) 0 1px, transparent 1px 3px), ' +
'linear-gradient(180deg, rgba(255,255,255,0.18) 0%, rgba(0,0,0,0.18) 100%)',
};
@@ -174,7 +191,15 @@ export function buildColorLayer(
const subtypeLower = (subtype ?? '').toLowerCase();
const effectLower = (effectType ?? '').toLowerCase();
if (subtypeLower === 'multicolor' || effectLower === 'multicolor') {
- return `conic-gradient(from 0deg, ${allStops.join(', ')}, ${allStops[0]})`;
+ const n = allStops.length;
+ const segments = allStops
+ .map((c, i) => {
+ const start = ((i / n) * 360).toFixed(3);
+ const end = (((i + 1) / n) * 360).toFixed(3);
+ return `${c} ${start}deg ${end}deg`;
+ })
+ .join(', ');
+ return `conic-gradient(from 0deg, ${segments})`;
}
if (effectLower === 'dual-color' || effectLower === 'tri-color') {
// Equal-width hard-split bars: each stop occupies its own contiguous
@@ -194,6 +219,16 @@ export function buildColorLayer(
return `linear-gradient(135deg, ${allStops.join(', ')})`;
}
+/** Resolve the CSS overlay string for an effect key. */
+export function resolveEffectOverlay(
+ effectKey: string,
+ effectSize: SwatchType,
+ effectSeed?: number,
+): EffectLayer | null {
+ const fn = EFFECT_OVERLAYS[effectKey as FilamentEffect];
+ return fn ? fn(effectSeed, effectSize) : null;
+}
+
/** Public helper: produce a CSS background-image value (list of layered
* s) for a filament, for callers that want to paint a banner or
* large area instead of using the swatch element. Returns a
@@ -203,6 +238,7 @@ export function buildColorLayer(
* card-sized banner only shows 4 huge checker cells.
*/
export function buildFilamentBackground(opts: {
+ effectSize: SwatchType;
rgba?: string | null;
extraColors?: string | null;
effectType?: FilamentEffect | string | null;
@@ -210,18 +246,22 @@ export function buildFilamentBackground(opts: {
}): { backgroundImage: string; backgroundSize: string } {
const stops = parseStops(opts.extraColors);
const colorLayer = buildColorLayer(opts.rgba, stops, opts.subtype, opts.effectType);
- const effectKey =
- typeof opts.effectType === 'string' && opts.effectType in EFFECT_OVERLAYS
- ? (opts.effectType as FilamentEffect)
+ const effectSeed = hash_fnv1a32(opts.rgba, opts.extraColors, opts.subtype, opts.effectType);
+ const effectLayer =
+ typeof opts.effectType === 'string'
+ ? resolveEffectOverlay(opts.effectType, opts.effectSize, effectSeed)
: null;
- const effectLayer = effectKey ? EFFECT_OVERLAYS[effectKey] ?? null : null;
-
// Layer order (top → bottom): effect overlay → colour layer → checkerboard.
// Per-layer background-size: 'cover' on the painted layers (so they fill
// the element) and the fixed tile size on the checkerboard so the cell
// count scales with the element rather than the element scaling the cells.
const layers: { image: string; size: string }[] = [];
- if (effectLayer) layers.push({ image: effectLayer, size: 'cover' });
+ if (effectLayer) {
+ const effectImages = Array.isArray(effectLayer) ? effectLayer : [effectLayer];
+ effectImages.forEach((image) => {
+ layers.push({ image, size: 'cover' });
+ });
+ }
layers.push({ image: colorLayer, size: 'cover' });
layers.push({ image: CHECKERBOARD_BG, size: CHECKERBOARD_TILE_SIZE });
diff --git a/frontend/src/components/spool-form/ColorSection.tsx b/frontend/src/components/spool-form/ColorSection.tsx
index b8bfeeb9d..1ac788fd4 100644
--- a/frontend/src/components/spool-form/ColorSection.tsx
+++ b/frontend/src/components/spool-form/ColorSection.tsx
@@ -193,6 +193,7 @@ export function ColorSection({
extraColors: formData.extra_colors,
effectType: formData.effect_type,
subtype: formData.subtype,
+ effectSize: 'bar',
}),
[formData.rgba, formData.extra_colors, formData.effect_type, formData.subtype],
);
@@ -443,6 +444,7 @@ export function ColorSection({
extraColors={formData.extra_colors}
effectType={formData.effect_type}
subtype={formData.subtype}
+ effectSize="preview"
className="w-10 h-10"
shape="square"
/>
diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx
index 70af18f28..ee940df94 100644
--- a/frontend/src/pages/InventoryPage.tsx
+++ b/frontend/src/pages/InventoryPage.tsx
@@ -189,6 +189,7 @@ const columnCells: Record ReactNode> = {
rgba={spool.rgba}
extraColors={spool.extra_colors}
effectType={spool.effect_type}
+ effectSize="table"
subtype={spool.subtype}
/>
@@ -1300,6 +1301,7 @@ function InventoryPage() {
extraColors: rep.extra_colors,
effectType: rep.effect_type,
subtype: rep.subtype,
+ effectSize: 'groupheader',
});
const isExpanded = expandedGroups.has(key);
return (
@@ -1682,6 +1684,7 @@ function SpoolCard({
extraColors: spool.extra_colors,
effectType: spool.effect_type,
subtype: spool.subtype,
+ effectSize: 'card',
});
return (
): number {
+ let hash = FNV1A_32_OFFSET_BASIS;
+ const textEncoder = new TextEncoder();
+ const emptyElement = textEncoder.encode('__|');
+ for (const element of input) {
+ if (typeof element === 'string') {
+ hash = fnv1a32_update(hash, textEncoder.encode(element+'|'));
+ } else if (element === null || element === undefined) {
+ hash = fnv1a32_update(hash, emptyElement);
+ }
+ }
+ return hash >>> 0;
+}
+
+
+function fnv1a32_update(hash: number, value: Uint8Array): number {
+ for (const byte of value) {
+ hash ^= byte;
+ hash = Math.imul(hash, FNV1A_32_PRIME) >>> 0;
+ }
+ return hash;
+}
+
+export interface Mulberry32Sequence {
+ next(): number;
+ intBetween(from: number, to: number): number;
+ floatBetween(from: number, to: number): number;
+}
+
+/**
+ * Creates a fast deterministic PRNG sequence using Mulberry32.
+ * Same seed will always produce the same sequence.
+ * Not cryptographically secure; use only for non-security-related use cases.
+ */
+export function random_mulberry32(seed: number): Mulberry32Sequence {
+ const nextUint32 = (): number => {
+ seed |= 0;
+ seed = seed + 0x6D2B79F5 | 0;
+ let imul = Math.imul(seed ^ seed >>> 15, 1 | seed);
+ imul = imul + Math.imul(imul ^ imul >>> 7, 61 | imul) ^ imul;
+ return (imul ^ imul >>> 14) >>> 0;
+ };
+
+ const nextNormalized = (from: number, to: number): number => {
+ if (!Number.isFinite(from) || !Number.isFinite(to)) {
+ throw new RangeError('from and to must be finite numbers');
+ }
+ if (from > to) {
+ throw new RangeError('from must be less than or equal to to');
+ }
+ if (from === to) {
+ return from;
+ }
+ return from + nextUint32() / 0xFFFFFFFF * (to - from);
+ };
+
+ return {
+ next: () => {
+ return nextUint32() / 0xFFFFFFFF;
+ },
+ floatBetween: (from: number, to: number): number => {
+ return nextNormalized(from, to);
+ },
+ intBetween: (from: number, to: number): number => {
+ if (!Number.isInteger(from) || !Number.isInteger(to)) {
+ throw new RangeError('from and to must be integers');
+ }
+ return Math.round(nextNormalized(from, to));
+ },
+ };
+}
diff --git a/scripts/fill_spool_effects.py b/scripts/fill_spool_effects.py
new file mode 100644
index 000000000..aa8848df3
--- /dev/null
+++ b/scripts/fill_spool_effects.py
@@ -0,0 +1,204 @@
+#!/usr/bin/env python3
+"""Seed Bambuddy with a quick visual set of filament effect spools.
+
+Usage:
+ python scripts/fill_spool_effects.py --bambuddy-url http://localhost:8000
+ python scripts/fill_spool_effects.py --bambuddy-url http://localhost:8000 --api-key YOUR_KEY
+
+This script creates stock spools for every effect type defined in the `SPOOLS` list below,
+using the bulk endpoint for creation:
+ POST /api/v1/inventory/spools/bulk
+"""
+
+from __future__ import annotations
+
+import argparse
+import sys
+from dataclasses import dataclass
+
+import requests
+
+
+API_PATH_BULK_CREATE = "/api/v1/inventory/spools/bulk"
+
+@dataclass(frozen=True)
+class TestSpool:
+ "Class representing a spool definition and count for the test spool set"
+ effect_type: str
+ colors: dict[str, str]
+ quantity: int = 1
+
+
+SPOOLS: list[TestSpool] = [
+ TestSpool(
+ effect_type="sparkle",
+ colors={"dodger blue": "1E90FFFF"},
+ quantity=2,
+ ),
+ TestSpool(
+ effect_type="sparkle",
+ colors={"dark red": "8B0000FF"},
+ ),
+ TestSpool(
+ effect_type="wood",
+ colors={"brown": "A47251FF"},
+ ),
+ TestSpool(
+ effect_type="marble",
+ colors={"slate": "4F5D75FF"},
+ ),
+ TestSpool(
+ effect_type="glow",
+ colors={"mint-pop": "6CD4BCFF"},
+ ),
+ TestSpool(
+ effect_type="matte",
+ colors={"charcoal": "2B2D42FF"},
+ ),
+ TestSpool(
+ effect_type="silk",
+ colors={"rose": "FF8FA3FF"},
+ ),
+ TestSpool(
+ effect_type="galaxy",
+ colors={"indigo": "4361EEFF"},
+ ),
+ TestSpool(
+ effect_type="rainbow",
+ colors={"amber": "FFBF69FF"},
+ ),
+ TestSpool(
+ effect_type="metal",
+ colors={"petrol-blue": "2D9CDBFF"},
+ ),
+ TestSpool(
+ effect_type="translucent",
+ colors={"cream": "FFF3E2AA"},
+ ),
+ TestSpool(
+ effect_type="gradient",
+ colors={
+ "dark olive green": "556B2FFF",
+ "goldenrod": "DAA520FF",
+ },
+ ),
+ TestSpool(
+ effect_type="dual-color",
+ colors={
+ "plum": "7B2CBFFF",
+ "saffron": "F2C94CFF",
+ },
+ ),
+ TestSpool(
+ effect_type="tri-color",
+ colors={
+ "coral": "FF6B6BFF",
+ "seafoam": "80ED99FF",
+ "indigo": "4361EEFF",
+ },
+ ),
+ TestSpool(
+ effect_type="multicolor",
+ colors={
+ "sunset-orange": "EC984CFF",
+ "mint-pop": "6CD4BCFF",
+ "violet-bloom": "A66EB9FF",
+ "raspberry-dawn": "D87694FF",
+ },
+ ),
+ TestSpool(
+ effect_type="multicolor",
+ colors={
+ "deep-navy": "1B1F3BFF",
+ "cream": "FFF3E2FF",
+ "rose": "FF8FA3FF",
+ "teal": "2EC4B6FF",
+ "amber": "FFBF69FF",
+ },
+ ),
+]
+
+
+def build_spool_payload(variant: TestSpool) -> dict:
+ "Function to build the payload for a single spool variant"
+ color_values = list(variant.colors.values())
+ color_names = list(variant.colors.keys())
+ return {
+ "material": "PLA",
+ "subtype": variant.effect_type,
+ "brand": "Generic",
+ "color_name": ", ".join(color_names),
+ "rgba": color_values[0],
+ "extra_colors": ",".join(color_values),
+ "effect_type": variant.effect_type,
+ "label_weight": 1000,
+ "core_weight": 250,
+ "weight_used": 0,
+ "core_weight_catalog_id": None,
+ "slicer_filament": None,
+ "slicer_filament_name": None,
+ "nozzle_temp_min": None,
+ "nozzle_temp_max": None,
+ "note": "Dev effect overview seed",
+ "cost_per_kg": None,
+ "category": None,
+ "low_stock_threshold_pct": None,
+ }
+
+
+def create_bulk_spools(
+ bambuddy_url: str,
+ spool_data: dict,
+ quantity: int,
+ api_key: str | None,
+ timeout: int,
+) -> list[dict]:
+ "Function that creates multiple spools using the bulk API endpoint and returns the list of created spools"
+ url = f"{bambuddy_url.rstrip('/')}{API_PATH_BULK_CREATE}"
+ headers: dict[str, str] = {}
+ if api_key:
+ headers["X-API-Key"] = api_key
+ payload = {"spool": spool_data, "quantity": quantity}
+ resp = requests.post(url, json=payload, headers=headers, timeout=timeout)
+ resp.raise_for_status()
+ data = resp.json()
+ if not isinstance(data, list):
+ raise ValueError("Bulk endpoint returned unexpected response format: expected a list of created spools")
+ return data
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Create development stock spools for every effect type")
+ parser.add_argument("--bambuddy-url", required=True, help="Bambuddy URL (e.g. http://localhost:8000)")
+ parser.add_argument("--api-key", help="Bambuddy API key (required if auth is enabled)")
+ parser.add_argument("--timeout", type=int, default=30, help="HTTP timeout in seconds (default: 30)")
+ args = parser.parse_args()
+
+ created = 0
+ failed = 0
+
+ for variant in SPOOLS:
+ payload = build_spool_payload(variant)
+ try:
+ created_spools = create_bulk_spools(
+ args.bambuddy_url,
+ payload,
+ quantity=variant.quantity,
+ api_key=args.api_key,
+ timeout=args.timeout,
+ )
+ ids = [str(item.get("id", "?")) for item in created_spools]
+ print(
+ f" Created effect={variant.effect_type:<11} qty={len(created_spools)} "
+ f"ids={','.join(ids)}"
+ )
+ created += len(created_spools)
+ except (requests.RequestException, ValueError) as exc:
+ print(f" FAILED effect={variant.effect_type}: {exc}", file=sys.stderr)
+ failed += variant.quantity
+
+ print(f"\nDone: {created} created, {failed} failed")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/test_backend.sh b/test_backend.sh
deleted file mode 100755
index 48e575416..000000000
--- a/test_backend.sh
+++ /dev/null
@@ -1,11 +0,0 @@
-#!/bin/sh
-
-cd backend
-ruff check && ruff format --check
-
-#if [ "$1" = "--full" ]; then
-../venv/bin/python3 -m pytest tests/ -v -n 30
-#else
-#../venv/bin/python3 -m pytest tests/ -v -n 30 --ignore=tests/unit/services/test_bambu_ftp.py
-#fi
-#cd ..
diff --git a/test_frontend.sh b/test_frontend.sh
deleted file mode 100755
index 3a7d85e67..000000000
--- a/test_frontend.sh
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/bin/sh
-
-cd frontend
-npx tsc
-npm run lint
-npm run test:run
-cd ..