filamentSwatchHelpers.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. import { hash_fnv1a32, random_mulberry32 } from '../utils/random';
  2. /* Enhanced filament-colour rendering helpers (#1154).
  3. *
  4. * Pure (non-component) exports that drive `<FilamentSwatch>` and any caller
  5. * that needs the same composed background as a CSS string. Lives in its own
  6. * file so `FilamentSwatch.tsx` can stay component-only and satisfy the
  7. * `react-refresh/only-export-components` ESLint rule.
  8. *
  9. * Inputs the swatch composes:
  10. * 1. `rgba` — RRGGBBAA hex (the Bambu/AMS canonical form)
  11. * 2. `extraColors` — comma-separated 6/8-char hex stops; turns the swatch
  12. * into a gradient. Conic when either `subtype` or
  13. * `effectType` is `multicolor`, otherwise linear.
  14. * 3. `effectType` — visual variant. Some carry a CSS overlay (sparkle,
  15. * wood, marble, glow, matte, silk, galaxy, metal),
  16. * others are categorical labels only.
  17. *
  18. * Alpha < 0xFF on any layer is shown against a checkerboard so the user can
  19. * actually see the transparency they configured.
  20. */
  21. export type FilamentEffect =
  22. // Surface effects with their own CSS overlay
  23. | 'sparkle'
  24. | 'wood'
  25. | 'marble'
  26. | 'glow'
  27. | 'matte'
  28. // Sheen / finish variants (categorical labels; some carry an overlay)
  29. | 'silk'
  30. | 'galaxy'
  31. | 'rainbow'
  32. | 'metal'
  33. | 'translucent'
  34. // Multi-colour structures (mostly drive the colour-layer choice)
  35. | 'gradient'
  36. | 'dual-color'
  37. | 'tri-color'
  38. | 'multicolor';
  39. /** Intended target swatch type for effect rendering. */
  40. export type SwatchType = 'table' | 'preview' | 'card' | 'bar' | 'groupheader';
  41. export type EffectLayer = string | string[];
  42. /** Presets for the different swatch types */
  43. export const SWATCH_TYPE_PRESETS: Readonly<Record<SwatchType, {
  44. dotCount: number;
  45. dotScale: number;
  46. }>> = {
  47. table: { dotCount: 5, dotScale: 1 },
  48. preview: { dotCount: 8, dotScale: 1.5 },
  49. card: { dotCount: 40, dotScale: 2 },
  50. bar: { dotCount: 20, dotScale: 2 },
  51. groupheader: { dotCount: 80, dotScale: 2 },
  52. };
  53. /** Public list of all known effect/variant values, in display order. Shared
  54. * by the spool form's ColorSection dropdown and the colour-catalog editor
  55. * so the two stay in lockstep. Each value pairs with an i18n key under
  56. * `inventory.colorEffect.<value>` (kebab → camel: `dualColor`/`triColor`). */
  57. export const FILAMENT_EFFECT_OPTIONS: ReadonlyArray<{
  58. value: '' | FilamentEffect;
  59. labelKey: string;
  60. }> = [
  61. { value: '', labelKey: 'inventory.colorEffect.none' },
  62. // Surface effects
  63. { value: 'sparkle', labelKey: 'inventory.colorEffect.sparkle' },
  64. { value: 'wood', labelKey: 'inventory.colorEffect.wood' },
  65. { value: 'marble', labelKey: 'inventory.colorEffect.marble' },
  66. { value: 'glow', labelKey: 'inventory.colorEffect.glow' },
  67. { value: 'matte', labelKey: 'inventory.colorEffect.matte' },
  68. // Sheen / finish
  69. { value: 'silk', labelKey: 'inventory.colorEffect.silk' },
  70. { value: 'galaxy', labelKey: 'inventory.colorEffect.galaxy' },
  71. { value: 'rainbow', labelKey: 'inventory.colorEffect.rainbow' },
  72. { value: 'metal', labelKey: 'inventory.colorEffect.metal' },
  73. { value: 'translucent', labelKey: 'inventory.colorEffect.translucent' },
  74. // Multi-colour structures
  75. { value: 'gradient', labelKey: 'inventory.colorEffect.gradient' },
  76. { value: 'dual-color', labelKey: 'inventory.colorEffect.dualColor' },
  77. { value: 'tri-color', labelKey: 'inventory.colorEffect.triColor' },
  78. { value: 'multicolor', labelKey: 'inventory.colorEffect.multicolor' },
  79. ];
  80. // Checkerboard pattern shown beneath the colour layer so alpha < FF is
  81. // actually visible to the user. Kept as a pure gradient (no position/size)
  82. // so the value parses cleanly inside `background-image:` everywhere.
  83. //
  84. // Density is controlled by ``CHECKERBOARD_TILE_SIZE`` applied as
  85. // ``background-size`` on this layer specifically — without that,
  86. // ``backgroundSize: 'cover'`` would stretch the gradient to the whole
  87. // element and a card-sized swatch would only show 4 huge cells (#1154
  88. // follow-up reporter feedback). Per-layer sizing is supported by every
  89. // modern browser via comma-separated ``background-size``.
  90. export const CHECKERBOARD_BG =
  91. 'repeating-conic-gradient(#979797 0% 25%, #f5f5f5 0% 50%)';
  92. export const CHECKERBOARD_TILE_SIZE = '12px 12px';
  93. /** Optional CSS overlay layer for variants that have a visual treatment.
  94. * Variants without an entry are categorical labels only — they don't paint
  95. * an overlay, just sit in the data. `multicolor` is special: its visual
  96. * effect is to switch the colour layer to a conic-gradient (see
  97. * `buildColorLayer`), not to add an overlay layer. */
  98. export const EFFECT_OVERLAYS: Partial<
  99. Record<FilamentEffect, (effectSeed?: number, effectSize?: SwatchType) => EffectLayer>
  100. > = {
  101. // Sparkle: bright flecks — positions seeded from spool color+extracolors+subtype+effectType.
  102. // to give identical spools the same sparkle pattern while different spools get different patterns.
  103. sparkle: (spoolSeed = 0, effectSize = 'table') => {
  104. const rand = random_mulberry32(spoolSeed);
  105. const preset = SWATCH_TYPE_PRESETS[effectSize] ?? SWATCH_TYPE_PRESETS.table;
  106. const sparks: string[] = [];
  107. for (let i = 0; i < preset.dotCount; i++) {
  108. const x = rand.intBetween(1, 99);
  109. const y = rand.intBetween(1, 99);
  110. const s = rand.floatBetween(1.0, preset.dotScale);
  111. const a = rand.floatBetween(0.65, 1.0);
  112. sparks.push(`radial-gradient(circle at ${x}% ${y}%, rgba(255,248,220,${a}) 0 ${s/2}px, transparent ${s}px)`);
  113. }
  114. return sparks;
  115. },
  116. // Wood: subtle horizontal banding to mimic grain.
  117. wood: () =>
  118. 'repeating-linear-gradient(90deg, ' +
  119. 'rgba(0,0,0,0.18) 0 1px, transparent 1px 6px, ' +
  120. 'rgba(0,0,0,0.08) 6px 7px, transparent 7px 12px)',
  121. // Marble: soft diagonal swirls.
  122. marble: () =>
  123. 'repeating-linear-gradient(135deg, rgba(255,255,255,0.18) 0 2px, transparent 2px 8px), ' +
  124. 'repeating-linear-gradient(45deg, rgba(0,0,0,0.10) 0 1px, transparent 1px 7px)',
  125. // Glow: bright center fade — visual hint for glow-in-the-dark filaments.
  126. glow: () =>
  127. 'radial-gradient(circle at 50% 50%, rgba(255,255,255,0.35) 0%, rgba(255,255,255,0) 70%)',
  128. // Matte: very subtle inset shadow to flatten the highlight.
  129. matte: () =>
  130. 'linear-gradient(180deg, rgba(0,0,0,0.10) 0%, rgba(0,0,0,0) 50%, rgba(0,0,0,0.10) 100%)',
  131. // Silk / Galaxy: diagonal sheen to suggest the lustrous finish those
  132. // filaments have. Galaxy uses a slightly stronger highlight.
  133. silk: () =>
  134. 'linear-gradient(110deg, rgba(255,255,255,0) 30%, rgba(255,255,255,0.30) 50%, rgba(255,255,255,0) 70%)',
  135. galaxy: () =>
  136. 'linear-gradient(110deg, rgba(255,255,255,0) 25%, rgba(255,255,255,0.40) 50%, rgba(255,255,255,0) 75%)',
  137. // Metal: brushed-metal look via tight horizontal striations + soft sheen.
  138. metal: () =>
  139. 'repeating-linear-gradient(90deg, rgba(255,255,255,0.10) 0 1px, transparent 1px 3px), ' +
  140. 'linear-gradient(180deg, rgba(255,255,255,0.18) 0%, rgba(0,0,0,0.18) 100%)',
  141. };
  142. /** Normalize a hex token (with or without `#`, 6 or 8 chars) → CSS hex string. */
  143. export function toCssHex(token: string): string | null {
  144. const t = token.trim().replace(/^#/, '');
  145. if (t.length !== 6 && t.length !== 8) return null;
  146. if (!/^[0-9a-fA-F]+$/.test(t)) return null;
  147. return `#${t}`;
  148. }
  149. /** Parse extra_colors string into an array of CSS hex strings. */
  150. export function parseStops(extra: string | null | undefined): string[] {
  151. if (!extra) return [];
  152. return extra
  153. .split(',')
  154. .map((s) => toCssHex(s))
  155. .filter((s): s is string => Boolean(s));
  156. }
  157. /** Build the colour layer (gradient or solid) given rgba + stops + subtype/effect.
  158. * - ``multicolor`` (subtype OR effect): conic gradient — the swatch reads as
  159. * a colour wheel pie, distinct from a stripe.
  160. * - ``dual-color`` / ``tri-color`` (effect): hard-split horizontal bars with
  161. * no diagonal blend. A 2-stop Dual Color renders as left/right halves of
  162. * distinct colour, matching the way real dual-colour spools look on the
  163. * reel — without this, Gradient and Dual Color produced the same diagonal
  164. * blend and were visually indistinguishable (#1154 follow-up).
  165. * - everything else (``gradient`` and the default): a smooth 135° linear
  166. * gradient across the stops, the original visual for blended-stop spools.
  167. */
  168. export function buildColorLayer(
  169. rgba: string | null | undefined,
  170. stops: string[],
  171. subtype: string | null | undefined,
  172. effectType?: string | null,
  173. ): string {
  174. const baseHex = rgba ? toCssHex(rgba) : null;
  175. // No stops → solid colour (or default grey when nothing is set at all).
  176. if (stops.length === 0) {
  177. return `linear-gradient(${baseHex ?? '#808080'}, ${baseHex ?? '#808080'})`;
  178. }
  179. // With stops we ignore the single rgba and gradient across the stops.
  180. const allStops = stops.length === 1 ? [stops[0], stops[0]] : stops;
  181. const subtypeLower = (subtype ?? '').toLowerCase();
  182. const effectLower = (effectType ?? '').toLowerCase();
  183. if (subtypeLower === 'multicolor' || effectLower === 'multicolor') {
  184. const n = allStops.length;
  185. const segments = allStops
  186. .map((c, i) => {
  187. const start = ((i / n) * 360).toFixed(3);
  188. const end = (((i + 1) / n) * 360).toFixed(3);
  189. return `${c} ${start}deg ${end}deg`;
  190. })
  191. .join(', ');
  192. return `conic-gradient(from 0deg, ${segments})`;
  193. }
  194. if (effectLower === 'dual-color' || effectLower === 'tri-color') {
  195. // Equal-width hard-split bars: each stop occupies its own contiguous
  196. // segment with no blend across the boundary. CSS double-position stops
  197. // (``c X% Y%``) collapse the transition zone to zero so the colour
  198. // change is a hard vertical line rather than a diagonal smear.
  199. const n = allStops.length;
  200. const segments = allStops
  201. .map((c, i) => {
  202. const start = ((i / n) * 100).toFixed(3);
  203. const end = (((i + 1) / n) * 100).toFixed(3);
  204. return `${c} ${start}% ${end}%`;
  205. })
  206. .join(', ');
  207. return `linear-gradient(to right, ${segments})`;
  208. }
  209. return `linear-gradient(135deg, ${allStops.join(', ')})`;
  210. }
  211. /** Resolve the CSS overlay string for an effect key. */
  212. export function resolveEffectOverlay(
  213. effectKey: string,
  214. effectSize: SwatchType,
  215. effectSeed?: number,
  216. ): EffectLayer | null {
  217. const fn = EFFECT_OVERLAYS[effectKey as FilamentEffect];
  218. return fn ? fn(effectSeed, effectSize) : null;
  219. }
  220. /** Public helper: produce a CSS background-image value (list of layered
  221. * <image>s) for a filament, for callers that want to paint a banner or
  222. * large area instead of using the swatch element. Returns a
  223. * ``CSSProperties``-compatible object with ``backgroundImage`` and
  224. * ``backgroundSize`` so the checkerboard underlayer keeps a fixed tile
  225. * density regardless of element size — without per-layer sizing, a
  226. * card-sized banner only shows 4 huge checker cells.
  227. */
  228. export function buildFilamentBackground(opts: {
  229. effectSize: SwatchType;
  230. rgba?: string | null;
  231. extraColors?: string | null;
  232. effectType?: FilamentEffect | string | null;
  233. subtype?: string | null;
  234. }): { backgroundImage: string; backgroundSize: string } {
  235. const stops = parseStops(opts.extraColors);
  236. const colorLayer = buildColorLayer(opts.rgba, stops, opts.subtype, opts.effectType);
  237. const effectSeed = hash_fnv1a32(opts.rgba, opts.extraColors, opts.subtype, opts.effectType);
  238. const effectLayer =
  239. typeof opts.effectType === 'string'
  240. ? resolveEffectOverlay(opts.effectType, opts.effectSize, effectSeed)
  241. : null;
  242. // Layer order (top → bottom): effect overlay → colour layer → checkerboard.
  243. // Per-layer background-size: 'cover' on the painted layers (so they fill
  244. // the element) and the fixed tile size on the checkerboard so the cell
  245. // count scales with the element rather than the element scaling the cells.
  246. const layers: { image: string; size: string }[] = [];
  247. if (effectLayer) {
  248. const effectImages = Array.isArray(effectLayer) ? effectLayer : [effectLayer];
  249. effectImages.forEach((image) => {
  250. layers.push({ image, size: 'cover' });
  251. });
  252. }
  253. layers.push({ image: colorLayer, size: 'cover' });
  254. layers.push({ image: CHECKERBOARD_BG, size: CHECKERBOARD_TILE_SIZE });
  255. return {
  256. backgroundImage: layers.map((l) => l.image).join(', '),
  257. backgroundSize: layers.map((l) => l.size).join(', '),
  258. };
  259. }