| 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<string>(); |
| 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<ColorPattern, number> = { 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, |
| }; |
| } |
|
|
|
|
|
|