1use 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
168const WHITE_PATCH_SCORE: f64 = 0.5;
170const PATCH_COVERAGE: f64 = 0.58;
172const PATCH_SPAN: Range<f64> = 0.02..0.86;
173const VARIETY_PULL: f64 = 0.25;
175const VARIETY_VOTE_WEIGHT: f64 = 0.16 * VARIETY_PULL;
178const SPOT_SPAN: Range<f64> = 0.12..0.8;
179const SPOT_LATERAL_LIMIT: f64 = 0.8;
180const FIN_ROOT_TINT_FRACTION: f64 = 0.4 * VARIETY_PULL;
182const 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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
193pub enum KoiBody {
194 #[default]
196 White,
197 Black,
199 Asagi,
201 Tea,
203 Gold,
205 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 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 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 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 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#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
298enum Pigment {
299 Beni,
301 Sumi,
303 Ki,
305 Orenji,
307 Ai,
309}
310
311pub(crate) struct KoiCoat {
320 identity_seed: u64,
321 koi_body: KoiBody,
322 pigment_shares: Vec<(Pigment, f64)>,
324 patch_biases: Vec<f64>,
325 spot_threshold: f64,
326 color_fraction: f64,
327 streak_marks: StreakMarks,
328}
329
330pub(crate) struct CoatPaint {
332 pub(crate) shade_ramp: &'static [[u8; 4]],
333 pub(crate) brightness_shift: f64,
335 pub(crate) saturation: f64,
337 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 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 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 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 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 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 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 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 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 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 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
633fn 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 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
658fn 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
689fn 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
699fn 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
721fn 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
777fn 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
827fn 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
865fn 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}