Skip to main content

git_koi/
coat.rs

1//! Koi coats colored by language pigments on a chosen body, sampled in body
2//! coordinates.
3//!
4//! Each language's GitHub color becomes the koi pigment nearest in hue: red (beni),
5//! orange (orenji), yellow (ki), or blue (ai). Grays and languages without a color
6//! become black (sumi). Languages on the same pigment add their shares, and equal
7//! shares rank red, black, yellow, orange, then blue. Organic patches seeded by the
8//! account give each pigment body area in proportion to its share, pulled partway
9//! toward the top pigment's classic variety: red plates, black lightning, a netted blue
10//! back, or a netted solid yellow or orange coat. The second pigment adds spots, or
11//! red, orange, or yellow sides under a blue back; the third tints the fin roots. Young
12//! koi show their pigments pale until [`crate::koi::FULL_COLOR_CONTRIBUTIONS`]: red,
13//! orange, yellow, and blue come in first, and black last.
14//!
15//! The caller chooses one of six [`KoiBody`] values. Each body redraws the pigments in
16//! its own palette, and a pigment that would match the ground swaps so it stays
17//! visible: black becomes white on black, blue becomes white on Asagi, yellow becomes
18//! platinum on gold and gold on platinum, and tea draws every pigment in earthy tones.
19//! Black and Asagi also brighten yellow, and black turns blue sky blue. Gold and
20//! platinum catch a metallic sheen on the bare body, never on the color patches. A koi
21//! without counted languages shows its plain body.
22//!
23//! The streak marks from [`crate::markings`] show here. The current streak fills a
24//! dithered share of the fin rims and tips with the body's streak color: gold on white,
25//! black, tea, and platinum, and red on Asagi and gold. The longest streak changes only
26//! the language colors, from dull through normal to extra bright and vivid, with black
27//! never lightening, and then gin-rin glints build on the color patches.
28
29use std::collections::BTreeMap;
30use std::ops::Range;
31
32use crate::history::LanguageColor;
33use crate::koi::{FULL_COLOR_CONTRIBUTIONS, KoiDevelopment, calculate_growth_fraction};
34use crate::markings::{StreakMarks, calculate_streak_marks};
35
36pub(crate) const WHITE_RAMP: [[u8; 4]; 4] = [
37    [90, 105, 136, 255],
38    [139, 155, 180, 255],
39    [192, 203, 220, 255],
40    [255, 255, 255, 255],
41];
42const RED_RAMP: [[u8; 4]; 4] = [
43    [162, 38, 51, 255],
44    [228, 59, 68, 255],
45    [240, 96, 79, 255],
46    [246, 117, 122, 255],
47];
48const BLACK_RAMP: [[u8; 4]; 3] = [[24, 20, 37, 255], [38, 43, 68, 255], [58, 68, 102, 255]];
49const YELLOW_RAMP: [[u8; 4]; 3] = [
50    [215, 118, 67, 255],
51    [254, 174, 52, 255],
52    [254, 231, 97, 255],
53];
54const ORANGE_RAMP: [[u8; 4]; 3] = [[190, 74, 47, 255], [247, 118, 34, 255], [254, 174, 52, 255]];
55const BLUE_RAMP: [[u8; 4]; 3] = [[58, 68, 102, 255], [18, 78, 137, 255], [0, 153, 219, 255]];
56const BLUE_NET_RAMP: [[u8; 4]; 1] = [[38, 43, 68, 255]];
57const PALE_RED_RAMP: [[u8; 4]; 3] = [
58    [194, 133, 105, 255],
59    [232, 183, 150, 255],
60    [234, 212, 170, 255],
61];
62const PALE_BLACK_RAMP: [[u8; 4]; 3] = [
63    [90, 105, 136, 255],
64    [139, 155, 180, 255],
65    [192, 203, 220, 255],
66];
67const PALE_YELLOW_RAMP: [[u8; 4]; 3] = [
68    [232, 183, 150, 255],
69    [234, 212, 170, 255],
70    [255, 255, 255, 255],
71];
72const PALE_BLUE_RAMP: [[u8; 4]; 3] = [
73    [139, 155, 180, 255],
74    [192, 203, 220, 255],
75    [255, 255, 255, 255],
76];
77const PALE_NET_RAMP: [[u8; 4]; 1] = [[139, 155, 180, 255]];
78const STREAK_GOLD_RAMP: [[u8; 4]; 3] = [
79    [247, 118, 34, 255],
80    [254, 174, 52, 255],
81    [254, 231, 97, 255],
82];
83const GLINT_COLOR: [u8; 4] = [255, 255, 255, 255];
84const ASAGI_RAMP: [[u8; 4]; 4] = [
85    [38, 43, 68, 255],
86    [58, 68, 102, 255],
87    [90, 105, 136, 255],
88    [139, 155, 180, 255],
89];
90const ASAGI_NET_RAMP: [[u8; 4]; 1] = [[58, 68, 102, 255]];
91const TEA_RAMP: [[u8; 4]; 4] = [
92    [62, 39, 49, 255],
93    [115, 62, 57, 255],
94    [184, 111, 80, 255],
95    [228, 166, 114, 255],
96];
97const GOLD_RAMP: [[u8; 4]; 5] = [
98    [115, 62, 57, 255],
99    [215, 118, 67, 255],
100    [254, 174, 52, 255],
101    [254, 231, 97, 255],
102    [255, 255, 255, 255],
103];
104const PLATINUM_RAMP: [[u8; 4]; 5] = [
105    [90, 105, 136, 255],
106    [139, 155, 180, 255],
107    [192, 203, 220, 255],
108    [244, 247, 251, 255],
109    [255, 255, 255, 255],
110];
111const BRIGHT_YELLOW_RAMP: [[u8; 4]; 4] = [
112    [215, 118, 67, 255],
113    [254, 174, 52, 255],
114    [254, 231, 97, 255],
115    [255, 243, 176, 255],
116];
117const SKY_RAMP: [[u8; 4]; 3] = [[18, 78, 137, 255], [0, 153, 219, 255], [44, 232, 245, 255]];
118const RUST_RAMP: [[u8; 4]; 3] = [[115, 62, 57, 255], [190, 74, 47, 255], [215, 118, 67, 255]];
119const UMBER_RAMP: [[u8; 4]; 3] = [[24, 20, 37, 255], [62, 39, 49, 255], [115, 62, 57, 255]];
120const SAND_RAMP: [[u8; 4]; 3] = [
121    [184, 111, 80, 255],
122    [228, 166, 114, 255],
123    [234, 212, 170, 255],
124];
125const CLAY_RAMP: [[u8; 4]; 3] = [
126    [190, 74, 47, 255],
127    [215, 118, 67, 255],
128    [228, 166, 114, 255],
129];
130const SLATE_RAMP: [[u8; 4]; 3] = [[38, 43, 68, 255], [58, 68, 102, 255], [90, 105, 136, 255]];
131const BRONZE_RAMP: [[u8; 4]; 4] = [
132    [62, 39, 49, 255],
133    [115, 62, 57, 255],
134    [160, 106, 58, 255],
135    [201, 154, 90, 255],
136];
137const METAL_RED_RAMP: [[u8; 4]; 4] = [
138    [162, 38, 51, 255],
139    [247, 118, 34, 255],
140    [254, 174, 52, 255],
141    [255, 255, 255, 255],
142];
143const METAL_ORANGE_RAMP: [[u8; 4]; 4] = [
144    [190, 74, 47, 255],
145    [247, 118, 34, 255],
146    [254, 174, 52, 255],
147    [255, 255, 255, 255],
148];
149const COPPER_RAMP: [[u8; 4]; 4] = [
150    [115, 62, 57, 255],
151    [184, 111, 80, 255],
152    [228, 166, 114, 255],
153    [255, 255, 255, 255],
154];
155const METAL_BLUE_RAMP: [[u8; 4]; 4] = [
156    [18, 78, 137, 255],
157    [0, 153, 219, 255],
158    [139, 155, 180, 255],
159    [255, 255, 255, 255],
160];
161const STEEL_RAMP: [[u8; 4]; 4] = [
162    [38, 43, 68, 255],
163    [58, 68, 102, 255],
164    [90, 105, 136, 255],
165    [192, 203, 220, 255],
166];
167
168/// Patch scores must beat the white ground's score to place a pigment.
169const WHITE_PATCH_SCORE: f64 = 0.5;
170/// Share of the patch span that pigments cover together, divided by their shares.
171const PATCH_COVERAGE: f64 = 0.58;
172const PATCH_SPAN: Range<f64> = 0.02..0.86;
173/// Patches move this far from organic shapes toward the top pigment's variety layout.
174const VARIETY_PULL: f64 = 0.25;
175// The variety layout votes with a hard step, so it carries little weight against the
176// smooth patch scores.
177const VARIETY_VOTE_WEIGHT: f64 = 0.16 * VARIETY_PULL;
178const SPOT_SPAN: Range<f64> = 0.12..0.8;
179const SPOT_LATERAL_LIMIT: f64 = 0.8;
180/// The full variety layout tints 40% of each fin; the pull shortens the tint with it.
181const FIN_ROOT_TINT_FRACTION: f64 = 0.4 * VARIETY_PULL;
182/// Streak color fills the fin rims and each fin beyond this fraction of its length.
183const STREAK_FIN_START_FRACTION: f64 = 0.55;
184const DULL_SATURATION: f64 = 0.3;
185const VIVID_SATURATION_GAIN: f64 = 0.3;
186const DULL_DARKENING: f64 = 0.15;
187const VIVID_BRIGHTENING: f64 = 0.12;
188
189/// The koi's ground color, chosen by the profile owner. Each body redraws the language
190/// pigments in its own palette, and a pigment that would match the ground swaps to one
191/// that stays visible.
192#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
193pub enum KoiBody {
194    /// Shiro: white.
195    #[default]
196    White,
197    /// Karasu: black.
198    Black,
199    /// Blue-gray with a scale net, a white head, and orange fins.
200    Asagi,
201    /// Cha: tea, with earthy colors.
202    Tea,
203    /// Ogon: metallic gold.
204    Gold,
205    /// Metallic platinum.
206    Platinum,
207}
208
209impl KoiBody {
210    fn select_ground_ramp(self) -> &'static [[u8; 4]] {
211        match self {
212            Self::White => &WHITE_RAMP,
213            Self::Black => &BLACK_RAMP,
214            Self::Asagi => &ASAGI_RAMP,
215            Self::Tea => &TEA_RAMP,
216            Self::Gold => &GOLD_RAMP,
217            Self::Platinum => &PLATINUM_RAMP,
218        }
219    }
220
221    fn select_fin_ramp(self) -> &'static [[u8; 4]] {
222        match self {
223            Self::White | Self::Black => &WHITE_RAMP,
224            Self::Asagi => &ORANGE_RAMP,
225            Self::Tea | Self::Gold | Self::Platinum => self.select_ground_ramp(),
226        }
227    }
228
229    /// The mouth's rim: a light shade of the body's own color, or on the black body its
230    /// lightest shade.
231    pub(crate) fn select_mouth_rim_color(self) -> [u8; 4] {
232        let ground_ramp = self.select_ground_ramp();
233        match self {
234            Self::Black => ground_ramp[ground_ramp.len() - 1],
235            _ => ground_ramp[ground_ramp.len() - 2],
236        }
237    }
238
239    /// The current-streak fin color, chosen to stand out against this body's fins.
240    fn select_streak_fin_ramp(self) -> &'static [[u8; 4]] {
241        match self {
242            Self::Asagi | Self::Gold => &RED_RAMP,
243            Self::White | Self::Black | Self::Tea | Self::Platinum => &STREAK_GOLD_RAMP,
244        }
245    }
246
247    /// Gold and platinum: a soft glow along the back and a bright band behind the head.
248    fn calculate_metallic_sheen(self, along_fraction: f64, lateral_fraction: f64) -> f64 {
249        const BACK_GLOW: f64 = 0.1;
250        const BAND_ALONG_FRACTION: f64 = 0.35;
251        const BAND_HALF_WIDTH_FRACTION: f64 = 0.06;
252        const BAND_BRIGHTNESS: f64 = 0.3;
253        if !matches!(self, Self::Gold | Self::Platinum) {
254            return 0.0;
255        }
256        let band_brightness =
257            if (along_fraction - BAND_ALONG_FRACTION).abs() < BAND_HALF_WIDTH_FRACTION {
258                BAND_BRIGHTNESS
259            } else {
260                0.0
261            };
262        BACK_GLOW * (1.0 - lateral_fraction.abs()) + band_brightness
263    }
264
265    /// The body's version of a pigment: black turns white on black, blue turns white on
266    /// Asagi, yellow turns platinum on gold and gold on platinum, and tea draws every
267    /// pigment in earthy tones. Black and Asagi also brighten yellow, and black turns
268    /// blue sky blue.
269    fn select_pigment_ramp(self, pigment: Pigment) -> &'static [[u8; 4]] {
270        match (self, pigment) {
271            (Self::Black, Pigment::Sumi) | (Self::Asagi, Pigment::Ai) => &WHITE_RAMP,
272            (Self::Black | Self::Asagi, Pigment::Ki) => &BRIGHT_YELLOW_RAMP,
273            (Self::Black, Pigment::Ai) => &SKY_RAMP,
274            (Self::Tea, Pigment::Beni) => &RUST_RAMP,
275            (Self::Tea, Pigment::Sumi) => &UMBER_RAMP,
276            (Self::Tea, Pigment::Ki) => &SAND_RAMP,
277            (Self::Tea, Pigment::Orenji) => &CLAY_RAMP,
278            (Self::Tea, Pigment::Ai) => &SLATE_RAMP,
279            (Self::Gold, Pigment::Beni) => &METAL_RED_RAMP,
280            (Self::Gold, Pigment::Sumi) => &BRONZE_RAMP,
281            (Self::Gold, Pigment::Ki) => &PLATINUM_RAMP,
282            (Self::Platinum, Pigment::Beni) => &METAL_ORANGE_RAMP,
283            (Self::Platinum, Pigment::Sumi) => &STEEL_RAMP,
284            (Self::Platinum, Pigment::Ki) => &GOLD_RAMP,
285            (Self::Gold | Self::Platinum, Pigment::Orenji) => &COPPER_RAMP,
286            (Self::Gold | Self::Platinum, Pigment::Ai) => &METAL_BLUE_RAMP,
287            (_, Pigment::Beni) => &RED_RAMP,
288            (_, Pigment::Sumi) => &BLACK_RAMP,
289            (_, Pigment::Ki) => &YELLOW_RAMP,
290            (_, Pigment::Orenji) => &ORANGE_RAMP,
291            (_, Pigment::Ai) => &BLUE_RAMP,
292        }
293    }
294}
295
296/// Koi pigments; each language color becomes the one nearest in hue.
297#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
298enum Pigment {
299    /// Red.
300    Beni,
301    /// Black.
302    Sumi,
303    /// Yellow.
304    Ki,
305    /// Orange.
306    Orenji,
307    /// Blue.
308    Ai,
309}
310
311/// A koi's language pigments laid out on one body shape.
312///
313/// Languages on the same pigment add their shares. Organic patches give each pigment
314/// body area in proportion to its share, pulled toward the top pigment's classic
315/// variety layout. The second pigment adds spots, or Asagi sides under blue; the third
316/// tints the fin roots. Young koi show pigments pale until FULL_COLOR_CONTRIBUTIONS:
317/// red, orange, yellow, and blue come in first, and black last. Streak marks color
318/// the fins and change only the language colors.
319pub(crate) struct KoiCoat {
320    identity_seed: u64,
321    koi_body: KoiBody,
322    /// Largest share first; equal shares keep pigment order.
323    pigment_shares: Vec<(Pigment, f64)>,
324    patch_biases: Vec<f64>,
325    spot_threshold: f64,
326    color_fraction: f64,
327    streak_marks: StreakMarks,
328}
329
330/// How to color one pixel of the coat.
331pub(crate) struct CoatPaint {
332    pub(crate) shade_ramp: &'static [[u8; 4]],
333    /// Added to the lighting before a shade is chosen.
334    pub(crate) brightness_shift: f64,
335    /// Scales a shaded color's distance from its own gray; one leaves it unchanged.
336    pub(crate) saturation: f64,
337    /// Replaces the shaded color, as on a glinting scale.
338    pub(crate) glint_color: Option<[u8; 4]>,
339}
340
341impl CoatPaint {
342    fn plain(shade_ramp: &'static [[u8; 4]]) -> Self {
343        Self {
344            shade_ramp,
345            brightness_shift: 0.0,
346            saturation: 1.0,
347            glint_color: None,
348        }
349    }
350}
351
352struct BodySample {
353    along_fraction: f64,
354    lateral_fraction: f64,
355    area_weight: f64,
356}
357
358impl KoiCoat {
359    /// `calculate_half_width` gives the body's half width at an along fraction, so
360    /// pigment areas are measured on the fish being drawn. A language without a
361    /// recorded color counts as black.
362    pub(crate) fn new(
363        koi_development: &KoiDevelopment,
364        koi_body: KoiBody,
365        calculate_half_width: impl Fn(f64) -> f64,
366    ) -> Self {
367        let identity_seed = koi_development.identity_seed;
368        let pigment_shares = calculate_pigment_shares(koi_development);
369        let body_samples = sample_body(calculate_half_width);
370        Self {
371            identity_seed,
372            koi_body,
373            patch_biases: fit_patch_biases(&pigment_shares, identity_seed, &body_samples),
374            spot_threshold: find_spot_threshold(&pigment_shares, identity_seed, &body_samples),
375            pigment_shares,
376            color_fraction: (calculate_growth_fraction(koi_development.total_contributions)
377                / calculate_growth_fraction(FULL_COLOR_CONTRIBUTIONS))
378            .min(1.0),
379            streak_marks: calculate_streak_marks(koi_development),
380        }
381    }
382
383    pub(crate) fn select_body_paint(
384        &self,
385        along_fraction: f64,
386        lateral_fraction: f64,
387        body_length_pixels: f64,
388        half_width_pixels: f64,
389        dither_threshold: f64,
390    ) -> CoatPaint {
391        const ASAGI_HEAD_FRACTION: f64 = 0.11;
392        let is_on_net = || {
393            is_on_scale_net(
394                along_fraction,
395                lateral_fraction,
396                body_length_pixels,
397                half_width_pixels,
398            )
399        };
400        let Some(pigment) = self.select_body_pigment(along_fraction, lateral_fraction) else {
401            let shade_ramp: &'static [[u8; 4]] = match self.koi_body {
402                KoiBody::Asagi if along_fraction <= ASAGI_HEAD_FRACTION => &WHITE_RAMP,
403                KoiBody::Asagi if is_on_net() => &ASAGI_NET_RAMP,
404                koi_body => koi_body.select_ground_ramp(),
405            };
406            return CoatPaint {
407                brightness_shift: self
408                    .koi_body
409                    .calculate_metallic_sheen(along_fraction, lateral_fraction),
410                ..CoatPaint::plain(shade_ramp)
411            };
412        };
413        let pigment_ramp = self.koi_body.select_pigment_ramp(pigment);
414        let base_pigment = self.pigment_shares.first().map(|&(pigment, _)| pigment);
415        let shade_ramp: &'static [[u8; 4]] = match pigment {
416            // Only the standard blue carries its dark net.
417            Pigment::Ai if pigment_ramp == BLUE_RAMP && is_on_net() => &BLUE_NET_RAMP,
418            Pigment::Ki | Pigment::Orenji if base_pigment == Some(pigment) && is_on_net() => {
419                &pigment_ramp[..1]
420            }
421            _ => pigment_ramp,
422        };
423        let is_glinting = self.is_glinting(
424            along_fraction,
425            lateral_fraction,
426            body_length_pixels,
427            half_width_pixels,
428        );
429        CoatPaint {
430            shade_ramp: self.develop_ramp(pigment, shade_ramp, dither_threshold),
431            brightness_shift: self.calculate_vibrancy_shift(pigment),
432            saturation: self.calculate_vibrancy_saturation(),
433            glint_color: is_glinting.then_some(GLINT_COLOR),
434        }
435    }
436
437    /// The current streak fills a dithered share of the fin rims and tips with the
438    /// body's streak color. Elsewhere, a fin-root tint is a language color, unless it
439    /// matches the plain fins and so never shows.
440    pub(crate) fn select_fin_paint(
441        &self,
442        along_fraction: f64,
443        lateral_fraction: f64,
444        is_on_rim: bool,
445        dither_threshold: f64,
446    ) -> CoatPaint {
447        if dither_threshold < self.streak_marks.streak_fin_strength
448            && (is_on_rim || along_fraction > STREAK_FIN_START_FRACTION)
449        {
450            return CoatPaint::plain(self.koi_body.select_streak_fin_ramp());
451        }
452        let plain_fin_ramp = self.koi_body.select_fin_ramp();
453        match self.pigment_shares.get(2) {
454            Some(&(tertiary_pigment, _))
455                if along_fraction < FIN_ROOT_TINT_FRACTION && lateral_fraction.abs() < 0.85 =>
456            {
457                let tint_ramp = self.develop_ramp(
458                    tertiary_pigment,
459                    self.koi_body.select_pigment_ramp(tertiary_pigment),
460                    dither_threshold,
461                );
462                if tint_ramp == plain_fin_ramp {
463                    CoatPaint::plain(tint_ramp)
464                } else {
465                    CoatPaint {
466                        saturation: self.calculate_vibrancy_saturation(),
467                        ..CoatPaint::plain(tint_ramp)
468                    }
469                }
470            }
471            _ => CoatPaint::plain(plain_fin_ramp),
472        }
473    }
474
475    /// Dull language colors darken and vivid ones brighten, except black, which never
476    /// lightens.
477    fn calculate_vibrancy_shift(&self, pigment: Pigment) -> f64 {
478        let color_vibrancy = self.streak_marks.color_vibrancy;
479        if color_vibrancy < 1.0 {
480            -DULL_DARKENING * (1.0 - color_vibrancy)
481        } else if pigment == Pigment::Sumi {
482            0.0
483        } else {
484            VIVID_BRIGHTENING * (color_vibrancy - 1.0)
485        }
486    }
487
488    /// Dull language colors pull toward their own gray; vivid ones push away from it.
489    fn calculate_vibrancy_saturation(&self) -> f64 {
490        let color_vibrancy = self.streak_marks.color_vibrancy;
491        if color_vibrancy < 1.0 {
492            DULL_SATURATION + (1.0 - DULL_SATURATION) * color_vibrancy
493        } else {
494            1.0 + VIVID_SATURATION_GAIN * (color_vibrancy - 1.0)
495        }
496    }
497
498    /// Gin-rin: scattered scales on the color patches glint, more as glimmer builds.
499    fn is_glinting(
500        &self,
501        along_fraction: f64,
502        lateral_fraction: f64,
503        body_length_pixels: f64,
504        half_width_pixels: f64,
505    ) -> bool {
506        const GLINT_SPAN: Range<f64> = 0.14..0.74;
507        const GLINT_LATERAL_LIMIT: f64 = 0.86;
508        // Share of scales that glint at full glimmer.
509        const FULL_GLINT_SHARE: f64 = 0.36;
510        let glimmer_strength = self.streak_marks.glimmer_strength;
511        if glimmer_strength <= 0.0
512            || !GLINT_SPAN.contains(&along_fraction)
513            || lateral_fraction.abs() >= GLINT_LATERAL_LIMIT
514        {
515            return false;
516        }
517        let scale_row = (lateral_fraction + 1.0) * half_width_pixels / 2.0;
518        let scale_column =
519            along_fraction * body_length_pixels / 2.4 + scale_row.floor().rem_euclid(2.0) * 0.5;
520        hash_lattice_point(
521            self.identity_seed ^ 0x6119,
522            scale_column.floor() as i64,
523            scale_row.floor() as i64,
524        ) < glimmer_strength * FULL_GLINT_SHARE
525            && scale_row.fract() < 0.5
526            && scale_column.fract() < 0.42
527    }
528
529    /// Blend the organic patch scores with the variety layout's vote; the white ground
530    /// competes like a pigment.
531    fn select_body_pigment(&self, along_fraction: f64, lateral_fraction: f64) -> Option<Pigment> {
532        let variety_pigment = self.select_variety_pigment(along_fraction, lateral_fraction);
533        let is_in_patch_span = PATCH_SPAN.contains(&along_fraction);
534        let mut selected_pigment = None;
535        let mut best_score = (1.0 - VARIETY_VOTE_WEIGHT) * WHITE_PATCH_SCORE
536            + VARIETY_VOTE_WEIGHT * f64::from(u8::from(variety_pigment.is_none()));
537        for (pigment_index, (&(pigment, _), patch_bias)) in self
538            .pigment_shares
539            .iter()
540            .zip(&self.patch_biases)
541            .enumerate()
542        {
543            let patch_score = if is_in_patch_span {
544                sample_patch_score(
545                    self.identity_seed,
546                    pigment_index,
547                    along_fraction,
548                    lateral_fraction,
549                ) + patch_bias
550            } else {
551                0.0
552            };
553            let blended_score = (1.0 - VARIETY_VOTE_WEIGHT) * patch_score
554                + VARIETY_VOTE_WEIGHT * f64::from(u8::from(variety_pigment == Some(pigment)));
555            if blended_score > best_score {
556                best_score = blended_score;
557                selected_pigment = Some(pigment);
558            }
559        }
560        selected_pigment
561    }
562
563    /// The top pigment's classic layout with the second pigment's spots: red plates
564    /// (Kohaku), black lightning and patches (Shiro Utsuri), a blue back with red,
565    /// orange, or yellow sides (Asagi), or a solid yellow or orange coat.
566    fn select_variety_pigment(
567        &self,
568        along_fraction: f64,
569        lateral_fraction: f64,
570    ) -> Option<Pigment> {
571        let identity_seed = self.identity_seed;
572        let &(base_pigment, _) = self.pigment_shares.first()?;
573        let secondary_pigment = self.pigment_shares.get(1).map(|&(pigment, _)| pigment);
574        let has_asagi_sides = base_pigment == Pigment::Ai
575            && matches!(
576                secondary_pigment,
577                Some(Pigment::Beni | Pigment::Orenji | Pigment::Ki)
578            );
579        if let Some(secondary_pigment) = secondary_pigment
580            && !has_asagi_sides
581            && SPOT_SPAN.contains(&along_fraction)
582            && lateral_fraction.abs() < SPOT_LATERAL_LIMIT
583            && sample_spot_noise(identity_seed, along_fraction, lateral_fraction)
584                > self.spot_threshold
585        {
586            return Some(secondary_pigment);
587        }
588        match base_pigment {
589            Pigment::Beni => is_in_red_plate(identity_seed, along_fraction, lateral_fraction)
590                .then_some(Pigment::Beni),
591            Pigment::Sumi => {
592                let is_in_lightning = along_fraction < 0.13
593                    && (lateral_fraction - 0.25 * (along_fraction * 55.0).sin()).abs() < 0.14;
594                (is_in_lightning
595                    || sample_fractal_noise(
596                        identity_seed,
597                        along_fraction * 4.0 + 60.0,
598                        lateral_fraction * 1.2,
599                    ) > 0.5)
600                    .then_some(Pigment::Sumi)
601            }
602            Pigment::Ai if along_fraction < 0.11 => None,
603            Pigment::Ai if lateral_fraction.abs() > 0.64 => {
604                secondary_pigment.filter(|_| has_asagi_sides)
605            }
606            Pigment::Ai | Pigment::Ki | Pigment::Orenji => Some(base_pigment),
607        }
608    }
609
610    /// Swap in the pale ramp on the dithered share of pixels whose pigment has not
611    /// yet come in.
612    fn develop_ramp(
613        &self,
614        pigment: Pigment,
615        shade_ramp: &'static [[u8; 4]],
616        dither_threshold: f64,
617    ) -> &'static [[u8; 4]] {
618        if dither_threshold <= calculate_pigment_development(pigment, self.color_fraction) {
619            return shade_ramp;
620        }
621        if shade_ramp.len() == 1 {
622            return &PALE_NET_RAMP;
623        }
624        match pigment {
625            Pigment::Beni | Pigment::Orenji => &PALE_RED_RAMP,
626            Pigment::Sumi => &PALE_BLACK_RAMP,
627            Pigment::Ki => &PALE_YELLOW_RAMP,
628            Pigment::Ai => &PALE_BLUE_RAMP,
629        }
630    }
631}
632
633/// Sum each pigment's language shares, largest first; equal shares keep pigment order.
634fn calculate_pigment_shares(koi_development: &KoiDevelopment) -> Vec<(Pigment, f64)> {
635    let total_share: f64 = koi_development.language_shares.values().sum();
636    let mut summed_shares = BTreeMap::new();
637    for (language_name, language_share) in &koi_development.language_shares {
638        let language_color = koi_development
639            .language_colors
640            .get(language_name)
641            .copied()
642            .flatten();
643        *summed_shares
644            .entry(select_pigment(language_color))
645            .or_insert(0.0) += language_share / total_share;
646    }
647    // Sums of equal shares can differ in their last bits, so order by rounded shares.
648    let rank_share = |pigment_share: f64| (pigment_share * 1e9).round();
649    let mut pigment_shares: Vec<_> = summed_shares.into_iter().collect();
650    pigment_shares.sort_by(|left, right| {
651        rank_share(right.1)
652            .total_cmp(&rank_share(left.1))
653            .then(left.0.cmp(&right.0))
654    });
655    pigment_shares
656}
657
658/// Map a GitHub language color to the koi pigment nearest in hue. Grays and missing
659/// colors become black.
660fn select_pigment(language_color: Option<LanguageColor>) -> Pigment {
661    const MIN_CHROMA: f64 = 0.12;
662    const MIN_SATURATION: f64 = 0.2;
663    let Some(LanguageColor(color_channels)) = language_color else {
664        return Pigment::Sumi;
665    };
666    let [red, green, blue] = color_channels.map(|color_channel| f64::from(color_channel) / 255.0);
667    let max_channel = red.max(green).max(blue);
668    let min_channel = red.min(green).min(blue);
669    let chroma = max_channel - min_channel;
670    let lightness = (max_channel + min_channel) / 2.0;
671    if chroma < MIN_CHROMA || chroma / (1.0 - (2.0 * lightness - 1.0).abs()) < MIN_SATURATION {
672        return Pigment::Sumi;
673    }
674    let hue_degrees = if max_channel == red {
675        60.0 * ((green - blue) / chroma).rem_euclid(6.0)
676    } else if max_channel == green {
677        60.0 * ((blue - red) / chroma + 2.0)
678    } else {
679        60.0 * ((red - green) / chroma + 4.0)
680    };
681    match hue_degrees {
682        hue_degrees if !(18.0..330.0).contains(&hue_degrees) => Pigment::Beni,
683        hue_degrees if hue_degrees < 45.0 => Pigment::Orenji,
684        hue_degrees if hue_degrees < 160.0 => Pigment::Ki,
685        _ => Pigment::Ai,
686    }
687}
688
689/// How far a pigment has come in, from pale (0) to full color (1).
690fn calculate_pigment_development(pigment: Pigment, color_fraction: f64) -> f64 {
691    let (start_fraction, span_fraction) = match pigment {
692        Pigment::Sumi => (0.35, 0.6),
693        _ => (0.05, 0.55),
694    };
695    let fraction = ((color_fraction - start_fraction) / span_fraction).clamp(0.0, 1.0);
696    fraction * fraction * (3.0 - 2.0 * fraction)
697}
698
699/// A grid over the patch span, weighted by half width so each sample stands for its
700/// share of body area.
701fn sample_body(calculate_half_width: impl Fn(f64) -> f64) -> Vec<BodySample> {
702    const ALONG_STEPS: usize = 170;
703    const ALONG_STEP_FRACTION: f64 = 0.005;
704    const LATERAL_STEPS: usize = 41;
705    const LATERAL_STEP_FRACTION: f64 = 0.05;
706    let mut body_samples = Vec::with_capacity(ALONG_STEPS * LATERAL_STEPS);
707    for along_step in 0..ALONG_STEPS {
708        let along_fraction = PATCH_SPAN.start + along_step as f64 * ALONG_STEP_FRACTION;
709        let area_weight = calculate_half_width(along_fraction);
710        for lateral_step in 0..LATERAL_STEPS {
711            body_samples.push(BodySample {
712                along_fraction,
713                lateral_fraction: -1.0 + lateral_step as f64 * LATERAL_STEP_FRACTION,
714                area_weight,
715            });
716        }
717    }
718    body_samples
719}
720
721/// Raise or lower each pigment's patch scores until its area matches its share.
722fn fit_patch_biases(
723    pigment_shares: &[(Pigment, f64)],
724    identity_seed: u64,
725    body_samples: &[BodySample],
726) -> Vec<f64> {
727    const FIT_PASSES: usize = 120;
728    const FIT_RATE: f64 = 0.6;
729    let patch_scores: Vec<Vec<f64>> = body_samples
730        .iter()
731        .map(|body_sample| {
732            (0..pigment_shares.len())
733                .map(|pigment_index| {
734                    sample_patch_score(
735                        identity_seed,
736                        pigment_index,
737                        body_sample.along_fraction,
738                        body_sample.lateral_fraction,
739                    )
740                })
741                .collect()
742        })
743        .collect();
744    let total_weight: f64 = body_samples
745        .iter()
746        .map(|body_sample| body_sample.area_weight)
747        .sum();
748    let mut patch_biases = vec![0.0; pigment_shares.len()];
749    for _ in 0..FIT_PASSES {
750        let mut covered_fractions = vec![0.0; pigment_shares.len()];
751        for (body_sample, sample_scores) in body_samples.iter().zip(&patch_scores) {
752            let mut covering_index = None;
753            let mut best_score = WHITE_PATCH_SCORE;
754            for (pigment_index, (patch_score, patch_bias)) in
755                sample_scores.iter().zip(&patch_biases).enumerate()
756            {
757                if patch_score + patch_bias > best_score {
758                    best_score = patch_score + patch_bias;
759                    covering_index = Some(pigment_index);
760                }
761            }
762            if let Some(pigment_index) = covering_index {
763                covered_fractions[pigment_index] += body_sample.area_weight / total_weight;
764            }
765        }
766        for ((patch_bias, &(_, pigment_share)), covered_fraction) in patch_biases
767            .iter_mut()
768            .zip(pigment_shares)
769            .zip(covered_fractions)
770        {
771            *patch_bias += FIT_RATE * (PATCH_COVERAGE * pigment_share - covered_fraction);
772        }
773    }
774    patch_biases
775}
776
777/// The spot noise level above which the second pigment's spots cover their target
778/// share of the spot span; no second pigment means no spots.
779fn find_spot_threshold(
780    pigment_shares: &[(Pigment, f64)],
781    identity_seed: u64,
782    body_samples: &[BodySample],
783) -> f64 {
784    const SPOTS_PER_SHARE: f64 = 0.75;
785    const MAX_SPOT_COVERAGE: f64 = 0.32;
786    let Some(&(_, secondary_share)) = pigment_shares.get(1) else {
787        return f64::INFINITY;
788    };
789    let spot_coverage = (secondary_share * SPOTS_PER_SHARE).min(MAX_SPOT_COVERAGE);
790    let mut spot_noises: Vec<f64> = body_samples
791        .iter()
792        .filter(|body_sample| SPOT_SPAN.contains(&body_sample.along_fraction))
793        .map(|body_sample| {
794            sample_spot_noise(
795                identity_seed,
796                body_sample.along_fraction,
797                body_sample.lateral_fraction,
798            )
799        })
800        .collect();
801    spot_noises.sort_by(f64::total_cmp);
802    spot_noises[((1.0 - spot_coverage) * (spot_noises.len() - 1) as f64) as usize]
803}
804
805fn sample_patch_score(
806    identity_seed: u64,
807    pigment_index: usize,
808    along_fraction: f64,
809    lateral_fraction: f64,
810) -> f64 {
811    let pigment_seed = identity_seed ^ (pigment_index as u64 + 1).wrapping_mul(0x9e3779b97f4a7c15);
812    sample_fractal_noise(
813        pigment_seed,
814        along_fraction * 3.0 + pigment_index as f64 * 13.0,
815        lateral_fraction * 0.95 + 7.0,
816    )
817}
818
819fn sample_spot_noise(identity_seed: u64, along_fraction: f64, lateral_fraction: f64) -> f64 {
820    sample_fractal_noise(
821        identity_seed ^ 0x5b07,
822        along_fraction * 9.0 + 30.0,
823        lateral_fraction * 2.6,
824    )
825}
826
827/// Kohaku's three red plates from head to tail, each with a seeded lean, width, and
828/// edge.
829fn is_in_red_plate(identity_seed: u64, along_fraction: f64, lateral_fraction: f64) -> bool {
830    const PLATE_SHARES: [f64; 3] = [0.42, 0.33, 0.25];
831    const PLATES_START_FRACTION: f64 = 0.035;
832    const PLATES_END_FRACTION: f64 = 0.8;
833    const PLATE_GAP_FRACTION: f64 = 0.05;
834    let plated_fraction = PLATES_END_FRACTION
835        - PLATES_START_FRACTION
836        - PLATE_GAP_FRACTION * (PLATE_SHARES.len() - 1) as f64;
837    let mut plate_start_fraction = PLATES_START_FRACTION;
838    for (plate_index, plate_share) in PLATE_SHARES.into_iter().enumerate() {
839        let plate_length_fraction = plated_fraction * plate_share;
840        let plate_center_fraction = plate_start_fraction + plate_length_fraction / 2.0;
841        plate_start_fraction += plate_length_fraction + PLATE_GAP_FRACTION;
842        let lattice_index = plate_index as i64;
843        let plate_lean = (hash_lattice_point(identity_seed, lattice_index, 1) - 0.5) * 0.55;
844        let plate_half_width = 0.62 + hash_lattice_point(identity_seed, lattice_index, 2) * 0.24;
845        let edge_wobble = (sample_fractal_noise(
846            identity_seed ^ 0xa11ce,
847            along_fraction * 16.0 + plate_index as f64 * 31.0,
848            lateral_fraction * 2.4,
849        ) - 0.5)
850            * 0.9;
851        let plate_distance = ((along_fraction - plate_center_fraction)
852            / (plate_length_fraction / 2.0))
853            .abs()
854            .powi(4)
855            + ((lateral_fraction - plate_lean) / plate_half_width)
856                .abs()
857                .powi(4);
858        if plate_distance < 1.0 + edge_wobble {
859            return true;
860        }
861    }
862    false
863}
864
865/// Offset rows of scale outlines for blue backs and solid yellow or orange coats.
866fn is_on_scale_net(
867    along_fraction: f64,
868    lateral_fraction: f64,
869    body_length_pixels: f64,
870    half_width_pixels: f64,
871) -> bool {
872    let scale_row = (lateral_fraction + 1.0) * half_width_pixels / 2.4;
873    let scale_column =
874        along_fraction * body_length_pixels / 2.8 + scale_row.floor().rem_euclid(2.0) * 0.5;
875    scale_row.fract().min(scale_column.fract()) < 0.17
876}
877
878fn sample_fractal_noise(identity_seed: u64, coordinate_x: f64, coordinate_y: f64) -> f64 {
879    let mut noise_sum = 0.0;
880    let mut total_weight = 0.0;
881    for octave_index in 0..3 {
882        let frequency = 2.0_f64.powi(octave_index);
883        let noise_weight = 1.0 / frequency;
884        noise_sum += sample_smooth_noise(
885            identity_seed,
886            coordinate_x * frequency,
887            coordinate_y * frequency,
888        ) * noise_weight;
889        total_weight += noise_weight;
890    }
891    noise_sum / total_weight
892}
893
894fn sample_smooth_noise(identity_seed: u64, coordinate_x: f64, coordinate_y: f64) -> f64 {
895    let lattice_x = coordinate_x.floor() as i64;
896    let lattice_y = coordinate_y.floor() as i64;
897    let fraction_x = coordinate_x - coordinate_x.floor();
898    let fraction_y = coordinate_y - coordinate_y.floor();
899    let blend_x = fraction_x * fraction_x * (3.0 - 2.0 * fraction_x);
900    let blend_y = fraction_y * fraction_y * (3.0 - 2.0 * fraction_y);
901    let top_left = hash_lattice_point(identity_seed, lattice_x, lattice_y);
902    let top_right = hash_lattice_point(identity_seed, lattice_x + 1, lattice_y);
903    let bottom_left = hash_lattice_point(identity_seed, lattice_x, lattice_y + 1);
904    let bottom_right = hash_lattice_point(identity_seed, lattice_x + 1, lattice_y + 1);
905    let top_noise = top_left + (top_right - top_left) * blend_x;
906    let bottom_noise = bottom_left + (bottom_right - bottom_left) * blend_x;
907    top_noise + (bottom_noise - top_noise) * blend_y
908}
909
910fn hash_lattice_point(identity_seed: u64, lattice_x: i64, lattice_y: i64) -> f64 {
911    let mut hash_bits = identity_seed
912        ^ (lattice_x as u64).wrapping_mul(0x9e3779b97f4a7c15)
913        ^ (lattice_y as u64).wrapping_mul(0xbf58476d1ce4e5b9);
914    hash_bits = (hash_bits ^ (hash_bits >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
915    hash_bits = (hash_bits ^ (hash_bits >> 27)).wrapping_mul(0x94d049bb133111eb);
916    hash_bits ^= hash_bits >> 31;
917    (hash_bits >> 11) as f64 / (1_u64 << 53) as f64
918}