mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-29 18:51:43 +02:00
UI/multi color handling (#1205)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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 ..
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
cd frontend
|
||||
npx tsc
|
||||
npm run lint
|
||||
npm run test:run
|
||||
cd ..
|
||||
Reference in New Issue
Block a user