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 ..