UI/multi color handling (#1205)

This commit is contained in:
Martha Augsburger
2026-05-05 09:34:16 +02:00
committed by GitHub
parent 3bb99759d2
commit 7baf1caf75
10 changed files with 463 additions and 95 deletions
@@ -13,7 +13,7 @@ import { buildFilamentBackground } from '../../components/filamentSwatchHelpers'
describe('FilamentSwatch', () => {
it('renders a solid swatch when only rgba is set', () => {
render(<FilamentSwatch rgba="ff0000ff" />);
render(<FilamentSwatch rgba="ff0000ff" effectSize='table' />);
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(<FilamentSwatch />);
render(<FilamentSwatch effectSize='table' />);
const el = screen.getByTestId('filament-swatch');
expect(el.style.backgroundImage.toLowerCase()).toContain('#808080');
});
it('renders a linear gradient when extraColors has multiple stops', () => {
render(<FilamentSwatch rgba="ff0000ff" extraColors="ec984c,6cd4bc,a66eb9,d87694" />);
render(<FilamentSwatch rgba="ff0000ff" extraColors="ec984c,6cd4bc,a66eb9,d87694" effectSize='table' />);
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(<FilamentSwatch extraColors="ec984c,6cd4bc,a66eb9" effectType="multicolor" />);
render(<FilamentSwatch extraColors="ec984c,6cd4bc,a66eb9" effectType="multicolor" effectSize='table' />);
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(<FilamentSwatch rgba="ff0000ff" effectType="sparkle" />);
render(<FilamentSwatch rgba="ff0000ff" effectType="sparkle" effectSize='table' />);
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(<FilamentSwatch rgba="ff0000ff" effectType="silk" />);
render(<FilamentSwatch rgba="ff0000ff" effectType="silk" effectSize='table' />);
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(<FilamentSwatch rgba="ff0000ff" effectType="gradient" />);
render(<FilamentSwatch rgba="ff0000ff" effectType="gradient" effectSize='table' />);
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(<FilamentSwatch rgba="ff0000ff" effectType="not-a-real-variant" />);
render(<FilamentSwatch rgba="ff0000ff" effectType="not-a-real-variant" effectSize='table' />);
const el = screen.getByTestId('filament-swatch');
expect(el.style.backgroundImage).not.toMatch(/radial-gradient/);
});
it('renders a checkerboard underneath so alpha is visible', () => {
render(<FilamentSwatch rgba="ff000080" />);
render(<FilamentSwatch rgba="ff000080" effectSize='table' />);
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(<FilamentSwatch extraColors="ff0000,not-hex,00ff00" />);
render(<FilamentSwatch extraColors="ff0000,not-hex,00ff00" effectSize='table' />);
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(<FilamentSwatch extraColors="ff0000,00ff00" />);
render(<FilamentSwatch extraColors="ff0000,00ff00" effectSize='table' />);
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(<FilamentSwatch rgba="ff0000ff" effectType="sparkle" />);
// 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(<FilamentSwatch rgba="ff0000ff" effectType="sparkle" effectSize="card" />);
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/);
});
@@ -640,6 +640,7 @@ export function ColorCatalogSettings() {
className="w-8 h-8"
shape="square"
title={entry.hex_color}
effectSize="table"
/>
</td>
<td className="px-3 py-2 text-white">{entry.manufacturer}</td>
+11 -25
View File
@@ -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';
@@ -1,3 +1,5 @@
import { hash_fnv1a32, random_mulberry32 } from '../utils/random';
/* Enhanced filament-colour rendering helpers (#1154).
*
* Pure (non-component) exports that drive `<FilamentSwatch>` 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<Record<SwatchType, {
dotCount: number;
dotScale: number;
}>> = {
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<Record<FilamentEffect, string>> = {
// 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<FilamentEffect, (effectSeed?: number, effectSize?: SwatchType) => 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
* <image>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 });
@@ -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"
/>
+3
View File
@@ -189,6 +189,7 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
rgba={spool.rgba}
extraColors={spool.extra_colors}
effectType={spool.effect_type}
effectSize="table"
subtype={spool.subtype}
/>
</div>
@@ -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 (
<div
+80
View File
@@ -0,0 +1,80 @@
const FNV1A_32_OFFSET_BASIS = 0x811c9dc5;
const FNV1A_32_PRIME = 0x01000193;
/**
* Computes a fast 32-bit FNV-1a hash for deterministic, non-security tasks.
* Accepts any number of string/nullable-string inputs, takes measurements
* to avoid collisions, and combines them into a 32-bit hash.
* Not cryptographically secure; use only for non-security-related use cases.
*/
export function hash_fnv1a32(...input: Array<string | null | undefined>): 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));
},
};
}
+204
View File
@@ -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()
-11
View File
@@ -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 ..
-7
View File
@@ -1,7 +0,0 @@
#!/bin/sh
cd frontend
npx tsc
npm run lint
npm run test:run
cd ..