import { hsvToRgb, rgbToHsv } from './color'; import type { Alleles, ColorAlleles, ColorPattern, Hsv, Rgb, Stabilities } from './types'; const DOMINANT_HUE_ZONES: Array<[number, number]> = [ [0, 20], [100, 140], [220, 260], ]; const THC_SOFT_CAP = 50; const THC_SOFT_THRESHOLD = 31; export type BloomScoreBreakdown = { score: number; colorCount: number; hueSpread: number; averageSaturation: number; averageValue: number; muddyStops: number; pattern: ColorPattern; }; export type ColorExpression = { rgb: Rgb; hsv: Hsv; palette: Rgb[]; hsvPalette: Hsv[]; pattern: ColorPattern; contrast: number; }; export type Phenotype = { thc: number; cbd: number; yield: number; growTime: number; budColorRgb: Rgb; leafColorRgb: Rgb; budColorHsv: Hsv; leafColorHsv: Hsv; budPaletteRgb: Rgb[]; leafPaletteRgb: Rgb[]; budPattern: ColorPattern; leafPattern: ColorPattern; }; export function isPrimaryHue(hue: number): boolean { return DOMINANT_HUE_ZONES.some(([low, high]) => hue >= low && hue <= high); } function clamp01(value: number): number { return Math.max(0, Math.min(1, value)); } function roundOne(value: number): number { return Math.round(value * 10) / 10; } function wrapHue(hue: number): number { return ((hue % 360) + 360) % 360; } function dominanceExpression(values: [number, number], stability = 0.5, lowDominates = false): number { const boundedStability = clamp01(stability); const dominant = lowDominates ? Math.min(...values) : Math.max(...values); const recessive = lowDominates ? Math.max(...values) : Math.min(...values); const dominanceWeight = 0.58 + boundedStability * 0.34; return dominant * dominanceWeight + recessive * (1 - dominanceWeight); } function hueDistance(left: number, right: number): number { const distance = Math.abs((((left - right) % 360) + 540) % 360 - 180); return roundOne(distance); } function mixHue(left: number, right: number, weightRight: number): number { const delta = (((right - left) % 360) + 540) % 360 - 180; return wrapHue(left + delta * weightRight); } function uniqueHsvPalette(colors: Hsv[]): Hsv[] { const seen = new Set(); const unique: Hsv[] = []; for (const color of colors) { const key = `${Math.round(color[0] / 8)}_${Math.round(color[1] * 10)}_${Math.round(color[2] * 10)}`; if (!seen.has(key)) { seen.add(key); unique.push(color); } } return unique.slice(0, 6); } function mean(values: number[]): number { return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0; } function hueSpread(hues: number[]): number { if (hues.length <= 1) return 0; let maxDistance = 0; for (let left = 0; left < hues.length; left += 1) { for (let right = left + 1; right < hues.length; right += 1) { const leftHue = hues[left]; const rightHue = hues[right]; if (leftHue === undefined || rightHue === undefined) continue; maxDistance = Math.max(maxDistance, hueDistance(leftHue, rightHue)); } } return maxDistance; } export function bloomScoreFromPalette(palette: Rgb[], pattern: ColorPattern = 'solid'): BloomScoreBreakdown { const hsvStops = uniqueHsvPalette(palette.map((color) => rgbToHsv(color))); if (!hsvStops.length) { return { score: 0, colorCount: 0, hueSpread: 0, averageSaturation: 0, averageValue: 0, muddyStops: 0, pattern }; } const colorCount = hsvStops.length; const spread = hueSpread(hsvStops.map((hsv) => hsv[0])); const averageSaturation = mean(hsvStops.map((hsv) => hsv[1])); const averageValue = mean(hsvStops.map((hsv) => hsv[2])); const muddyStops = hsvStops.filter((hsv) => hsv[1] < 0.34 || hsv[2] < 0.28).length; const patternBonus: Record = { solid: 0, speckled: 4, split: 8, rimmed: 10, mosaic: 16, polycolor: 22 }; const zoneScore = Math.min(1, Math.max(0, (colorCount - 1) / 5)) * 24; const spreadScore = (spread / 180) * 22; const colorQuality = averageSaturation * 34 + averageValue * 10; const muddyPenalty = (muddyStops / colorCount) * 18; const score = Math.max(0, Math.min(100, colorQuality + zoneScore + spreadScore + patternBonus[pattern] - muddyPenalty)); return { score: Math.round(score * 10) / 10, colorCount, hueSpread: Math.round(spread * 10) / 10, averageSaturation: Math.round(averageSaturation * 1000) / 1000, averageValue: Math.round(averageValue * 1000) / 1000, muddyStops, pattern, }; } export function expressThcPotential(values: [number, number], stability = 0.5): number { const raw = dominanceExpression(values, stability); if (raw <= THC_SOFT_THRESHOLD) return raw; const pressure = 0.9 + clamp01(stability) * 0.35; const excess = raw - THC_SOFT_THRESHOLD; return THC_SOFT_THRESHOLD + (THC_SOFT_CAP - THC_SOFT_THRESHOLD) * (1 - Math.exp(-(excess * pressure) / 12)); } export function expressColorAlleles(hsvAlleles: ColorAlleles, stability = 1): ColorExpression { const boundedStability = clamp01(stability); const volatility = 1 - boundedStability; const hsv0: Hsv = [wrapHue(hsvAlleles.Hue[0]), clamp01(hsvAlleles.Saturation[0]), clamp01(hsvAlleles.Value[0])]; const hsv1: Hsv = [wrapHue(hsvAlleles.Hue[1]), clamp01(hsvAlleles.Saturation[1]), clamp01(hsvAlleles.Value[1])]; const strength = (hsv: Hsv) => (hsv[1] * 0.68 + hsv[2] * 0.32) * (1 + boundedStability); const strength0 = strength(hsv0) + (isPrimaryHue(hsv0[0]) ? 0.08 : 0); const strength1 = strength(hsv1) + (isPrimaryHue(hsv1[0]) ? 0.08 : 0); const primary = strength0 >= strength1 ? hsv0 : hsv1; const secondary = primary === hsv0 ? hsv1 : hsv0; const contrast = hueDistance(hsv0[0], hsv1[0]); const balance = Math.min(strength0, strength1) / Math.max(0.001, Math.max(strength0, strength1)); const reveal = contrast / 180 * 0.42 + balance * 0.32 + volatility * 0.42; const visibleColors = Math.max(1, Math.min(6, Math.ceil(reveal * 6 - boundedStability * 1.25))); const colors: Hsv[] = [primary]; if (visibleColors >= 2) colors.push(secondary); if (visibleColors >= 3) colors.push([mixHue(primary[0], secondary[0], 0.33), Math.max(primary[1], secondary[1]) * 0.95, Math.min(1, Math.max(primary[2], secondary[2]) * 1.04)]); if (visibleColors >= 4) colors.push([mixHue(primary[0], secondary[0], 0.66), Math.min(1, secondary[1] + volatility * 0.14), Math.min(1, secondary[2] + volatility * 0.1)]); if (visibleColors >= 5) colors.push([wrapHue(primary[0] + 145 + volatility * 35), Math.min(1, primary[1] * 0.86 + volatility * 0.18), Math.min(1, primary[2] * 0.92 + 0.08)]); if (visibleColors >= 6) colors.push([wrapHue(secondary[0] - 120 - volatility * 24), Math.min(1, secondary[1] * 0.9 + 0.08), Math.max(0.2, secondary[2] * 0.72)]); const hsvPalette = uniqueHsvPalette(colors); const pattern: ColorPattern = (() => { if (hsvPalette.length <= 1) return 'solid'; if (hsvPalette.length >= 6) return 'polycolor'; if (hsvPalette.length >= 4) return 'mosaic'; if (contrast >= 128 && boundedStability >= 0.38) return 'rimmed'; if (contrast >= 72) return 'split'; return 'speckled'; })(); return { rgb: hsvToRgb(primary[0], primary[1], primary[2]), hsv: primary, palette: hsvPalette.map((hsv) => hsvToRgb(hsv[0], hsv[1], hsv[2])), hsvPalette, pattern, contrast, }; } export function generatePhenotype(alleles: Alleles, stabilities: Stabilities): Phenotype { const budColor = expressColorAlleles(alleles.BudColor, stabilities.BudColor); const leafColor = expressColorAlleles(alleles.LeafColor, stabilities.LeafColor); return { thc: expressThcPotential(alleles.THC, stabilities.THC), cbd: dominanceExpression(alleles.CBD, stabilities.CBD), yield: dominanceExpression(alleles.Yield, stabilities.Yield), growTime: dominanceExpression(alleles.GrowTime, stabilities.GrowTime, true), budColorRgb: budColor.rgb, leafColorRgb: leafColor.rgb, budColorHsv: budColor.hsv, leafColorHsv: leafColor.hsv, budPaletteRgb: budColor.palette, leafPaletteRgb: leafColor.palette, budPattern: budColor.pattern, leafPattern: leafColor.pattern, }; }