Skip to main content

git_koi/
render.rs

1//! A curved koi colored from its languages on a chosen body, with Classic shading and
2//! transparent surroundings.
3//!
4//! The body and fins follow sampled curves, with shading that follows the body surface
5//! and fin rays that follow each fin's direction. The head widens from a round nose
6//! along a gentle slant, and short pixel whiskers angle forward from the snout.
7//! Contribution totals set the size through [`crate::koi::calculate_growth_fraction`],
8//! and growth slows as the fish matures. Young koi have a rounder head, larger eyes, a
9//! slimmer body, and short rounded fins, and grow into the adult shape by
10//! [`crate::koi::YOUNG_SHAPE_CONTRIBUTIONS`]. After [`crate::koi::ADULT_CONTRIBUTIONS`]
11//! the body keeps lengthening, broadens slightly, and its fins and tail lengthen; the
12//! maximum form keeps its anatomy at higher totals. Quiet days alone do not change the
13//! base anatomy. The pose and framing account for the body and fins.
14//!
15//! Every growth level shares one square native canvas of [`FRAME_WIDTH_PIXELS`] by
16//! [`FRAME_HEIGHT_PIXELS`], which the encoders in [`crate::output`] scale up exactly.
17//! Anatomical proportions live here; pigments, bodies, and palettes in [`crate::coat`];
18//! streak mark strengths in [`crate::markings`]; poses in [`crate::motion`].
19
20use std::f64::consts::{PI, TAU};
21
22use crate::behavior::Behavior;
23use crate::coat::{KoiBody, KoiCoat, WHITE_RAMP};
24use crate::koi::{
25    ADULT_CONTRIBUTIONS, KoiDevelopment, YOUNG_SHAPE_CONTRIBUTIONS, calculate_growth_fraction,
26};
27use crate::motion::{Course, STILL_POSE, SwimPose, calculate_swim_pose, frame_count};
28
29/// Width of the native canvas every koi is drawn on, at every growth level.
30pub const FRAME_WIDTH_PIXELS: u32 = 256;
31/// Height of the native canvas; the canvas is square.
32pub const FRAME_HEIGHT_PIXELS: u32 = FRAME_WIDTH_PIXELS;
33
34/// A renderer-owned, fixed-size frame with row-major RGBA pixels.
35#[derive(Debug, Eq, PartialEq)]
36pub struct KoiFrame {
37    rgba_pixels: Vec<[u8; 4]>,
38}
39
40impl KoiFrame {
41    /// The pixels as bytes, row by row from the top left, four channels per pixel: red,
42    /// green, blue, alpha. The length is the canvas area times four.
43    pub fn as_rgba_bytes(&self) -> &[u8] {
44        self.rgba_pixels.as_flattened()
45    }
46
47    /// Build a frame from chosen pixels, for encoder tests that need colors no koi has.
48    #[cfg(test)]
49    pub(crate) fn from_rgba_pixels(rgba_pixels: Vec<[u8; 4]>) -> Self {
50        Self { rgba_pixels }
51    }
52}
53
54/// Render the koi in a centered, fixed swimming pose, colored from its languages on
55/// the caller's chosen body. Contribution totals set its size and how far its colors
56/// have come in. The account seed places patches in body coordinates, so growth does
57/// not reroll the pattern. Streak marks follow the development's streaks as of its
58/// observation date. Identical inputs produce identical pixels without I/O, clock
59/// reads, or input mutation.
60pub fn render_koi(koi_development: &KoiDevelopment, koi_body: KoiBody) -> KoiFrame {
61    let koi_anatomy = calculate_anatomy(koi_development.total_contributions);
62    render_pose(
63        koi_development,
64        koi_body,
65        &koi_anatomy,
66        &STILL_POSE,
67        Framing::CenterOnKoi,
68    )
69}
70
71/// Render one frame of a behavior's seamless loop, colored like `render_koi`, in the
72/// pose `motion::calculate_swim_pose` gives; frame numbers wrap around the loop of
73/// `motion::frame_count(behavior)` frames. Laps circle the frame center, and longer
74/// koi swim a tighter lap so every size fits. A hovering loop is framed once, centered
75/// on the koi's sweep, and its drift shrinks only as far as the largest koi need to
76/// stay inside. The view never follows the fins, so it never shakes. Under water the koi
77/// turns toward water blue and partly drops out, more with depth. Identical inputs
78/// produce identical pixels without I/O, clock reads, or input mutation.
79pub fn render_swim_frame(
80    koi_development: &KoiDevelopment,
81    koi_body: KoiBody,
82    behavior: Behavior,
83    frame_index: u32,
84) -> KoiFrame {
85    let koi_anatomy = calculate_anatomy(koi_development.total_contributions);
86    let swim_pose = calculate_swim_pose(behavior, frame_index);
87    let pose_center = match swim_pose.course {
88        Course::Lap { .. } => [0.0, 0.0],
89        Course::Hover { drift_pixels, .. } => {
90            let hover_fit = fit_hover_loop(behavior, &koi_anatomy);
91            [0, 1].map(|axis_index| {
92                hover_fit.sweep_center[axis_index]
93                    - hover_fit.drift_scales[axis_index] * drift_pixels[axis_index]
94            })
95        }
96    };
97    render_pose(
98        koi_development,
99        koi_body,
100        &koi_anatomy,
101        &swim_pose,
102        Framing::At(pose_center),
103    )
104}
105
106/// Where the view is centered: on the koi itself for the still image, or at a point
107/// fixed for the whole loop.
108enum Framing {
109    CenterOnKoi,
110    At([f64; 2]),
111}
112
113/// One framing for a whole hovering loop.
114struct HoverFit {
115    sweep_center: [f64; 2],
116    /// Shares of the pose's drift that keep every frame inside, from zero through one.
117    drift_scales: [f64; 2],
118}
119
120/// Center a hovering loop on everything the koi covers across its frames, then shrink
121/// the drift on each axis only as far as needed to keep every frame inside the frame.
122fn fit_hover_loop(behavior: Behavior, koi_anatomy: &KoiAnatomy) -> HoverFit {
123    // Room for the outline, a clear pixel at the frame edge, and rounding.
124    const FIT_MARGIN_PIXELS: f64 = 2.5;
125    let frame_bounds: Vec<([[f64; 2]; 2], [f64; 2])> = (0..frame_count(behavior))
126        .map(|frame_index| {
127            let swim_pose = calculate_swim_pose(behavior, frame_index);
128            let body_spine = build_spine(koi_anatomy, &swim_pose);
129            let fin_curves = build_fins(&body_spine, koi_anatomy, &swim_pose);
130            let [mut min_corner, mut max_corner] = measure_curve_bounds(&body_spine, &fin_curves);
131            // A whisker point covers the pixel to its right and below.
132            for whisker_point in
133                calculate_whisker_points(&body_spine, koi_anatomy, swim_pose.whisker_sway_radians)
134            {
135                for axis_index in 0..2 {
136                    min_corner[axis_index] = min_corner[axis_index].min(whisker_point[axis_index]);
137                    max_corner[axis_index] =
138                        max_corner[axis_index].max(whisker_point[axis_index] + 1.0);
139                }
140            }
141            let drift_pixels = match swim_pose.course {
142                Course::Hover { drift_pixels, .. } => drift_pixels,
143                Course::Lap { .. } => [0.0; 2],
144            };
145            ([min_corner, max_corner], drift_pixels)
146        })
147        .collect();
148    let frame_sizes = [
149        f64::from(FRAME_WIDTH_PIXELS),
150        f64::from(FRAME_HEIGHT_PIXELS),
151    ];
152    let mut hover_fit = HoverFit {
153        sweep_center: [0.0; 2],
154        drift_scales: [1.0; 2],
155    };
156    for axis_index in 0..2 {
157        let sweep_min = frame_bounds
158            .iter()
159            .map(|(bounds, _)| bounds[0][axis_index])
160            .fold(f64::INFINITY, f64::min);
161        let sweep_max = frame_bounds
162            .iter()
163            .map(|(bounds, _)| bounds[1][axis_index])
164            .fold(f64::NEG_INFINITY, f64::max);
165        let sweep_center = (sweep_min + sweep_max) / 2.0;
166        let frame_half = frame_sizes[axis_index] / 2.0;
167        let mut drift_scale: f64 = 1.0;
168        for (bounds, drift_pixels) in &frame_bounds {
169            let drift = drift_pixels[axis_index];
170            let room = if drift > 0.0 {
171                frame_half - FIT_MARGIN_PIXELS - (bounds[1][axis_index] - sweep_center)
172            } else {
173                frame_half - FIT_MARGIN_PIXELS + (bounds[0][axis_index] - sweep_center)
174            };
175            if drift != 0.0 {
176                drift_scale = drift_scale.min(room / drift.abs());
177            }
178        }
179        hover_fit.sweep_center[axis_index] = sweep_center;
180        hover_fit.drift_scales[axis_index] = drift_scale.clamp(0.0, 1.0);
181    }
182    hover_fit
183}
184
185fn render_pose(
186    koi_development: &KoiDevelopment,
187    koi_body: KoiBody,
188    koi_anatomy: &KoiAnatomy,
189    swim_pose: &SwimPose,
190    framing: Framing,
191) -> KoiFrame {
192    let koi_coat = KoiCoat::new(koi_development, koi_body, |along_fraction| {
193        calculate_body_half_width(along_fraction, koi_anatomy)
194    });
195    let body_spine = build_spine(koi_anatomy, swim_pose);
196    let fin_curves = build_fins(&body_spine, koi_anatomy, swim_pose);
197    let [min_corner, max_corner] = measure_curve_bounds(&body_spine, &fin_curves);
198    let pose_center = match framing {
199        Framing::CenterOnKoi => [
200            (min_corner[0] + max_corner[0]) / 2.0,
201            (min_corner[1] + max_corner[1]) / 2.0,
202        ],
203        Framing::At(pose_center) => pose_center,
204    };
205    let koi_appearance = KoiAppearance {
206        koi_anatomy,
207        koi_coat: &koi_coat,
208    };
209    let mut rgba_pixels = Vec::with_capacity((FRAME_WIDTH_PIXELS * FRAME_HEIGHT_PIXELS) as usize);
210    for pixel_y in 0..FRAME_HEIGHT_PIXELS {
211        for pixel_x in 0..FRAME_WIDTH_PIXELS {
212            let sample_position = [
213                f64::from(pixel_x) + 0.5 - f64::from(FRAME_WIDTH_PIXELS) / 2.0 + pose_center[0],
214                f64::from(pixel_y) + 0.5 - f64::from(FRAME_HEIGHT_PIXELS) / 2.0 + pose_center[1],
215            ];
216            // Nothing outside the curves' bounds can be covered, so skip sampling it.
217            let is_inside_bounds = (0..2).all(|axis_index| {
218                (min_corner[axis_index]..=max_corner[axis_index])
219                    .contains(&sample_position[axis_index])
220            });
221            rgba_pixels.push(if is_inside_bounds {
222                sample_koi(
223                    &body_spine,
224                    &fin_curves,
225                    sample_position,
226                    [pixel_x, pixel_y],
227                    &koi_appearance,
228                )
229            } else {
230                [0; 4]
231            });
232        }
233    }
234    paint_mouth(
235        &mut rgba_pixels,
236        &body_spine,
237        koi_body,
238        pose_center,
239        swim_pose.mouth_opening,
240    );
241    let fish_pixel_mask: Vec<bool> = rgba_pixels
242        .iter()
243        .map(|pixel_channels| pixel_channels[3] != 0)
244        .collect();
245    let whisker_points =
246        calculate_whisker_points(&body_spine, koi_anatomy, swim_pose.whisker_sway_radians);
247    paint_whiskers(&mut rgba_pixels, &whisker_points, pose_center);
248    paint_outline(&mut rgba_pixels);
249    if let Some(feeding_phase) = swim_pose.feeding_phase {
250        paint_feeding(
251            &mut rgba_pixels,
252            &body_spine,
253            koi_anatomy,
254            pose_center,
255            feeding_phase,
256        );
257    }
258    if swim_pose.depth > 0.0 {
259        paint_depth(&mut rgba_pixels, &fish_pixel_mask, swim_pose.depth);
260    }
261    KoiFrame { rgba_pixels }
262}
263
264/// Where the mouth opens: on the snout's center line, just inside its front.
265fn calculate_mouth_position(body_spine: &[CurvePoint]) -> [f64; 2] {
266    const MOUTH_FORWARD_FRACTION: f64 = 0.75;
267    let snout = body_spine[0];
268    let forward_direction = calculate_forward_direction(body_spine, 0);
269    [0, 1].map(|axis_index| {
270        snout.position[axis_index]
271            + forward_direction[axis_index] * snout.half_width_pixels * MOUTH_FORWARD_FRACTION
272    })
273}
274
275/// The mouth: a circle at the front of the snout with a rim in the body's color around
276/// a dark opening, growing from under a pixel to half the snout's width as it opens.
277/// It is painted before the outline, so it is outlined like the koi, and a mostly
278/// closed mouth paints nothing.
279fn paint_mouth(
280    rgba_pixels: &mut [[u8; 4]],
281    body_spine: &[CurvePoint],
282    koi_body: KoiBody,
283    pose_center: [f64; 2],
284    mouth_opening: f64,
285) {
286    const VISIBLE_OPENING: f64 = 0.1;
287    const CLOSED_RADIUS_PIXELS: f64 = 0.8;
288    const OPEN_RADIUS_PER_SNOUT_RADIUS: f64 = 0.5;
289    const MIN_OPEN_RADIUS_PIXELS: f64 = 1.8;
290    const RIM_WIDTH_PIXELS: f64 = 0.9;
291    if mouth_opening <= VISIBLE_OPENING {
292        return;
293    }
294    let open_radius_pixels = (body_spine[0].half_width_pixels * OPEN_RADIUS_PER_SNOUT_RADIUS)
295        .max(MIN_OPEN_RADIUS_PIXELS);
296    let radius_pixels = interpolate(
297        CLOSED_RADIUS_PIXELS,
298        open_radius_pixels,
299        mouth_opening.min(1.0),
300    );
301    let mouth_world = calculate_mouth_position(body_spine);
302    let frame_sizes = [
303        f64::from(FRAME_WIDTH_PIXELS),
304        f64::from(FRAME_HEIGHT_PIXELS),
305    ];
306    let mouth = [0, 1].map(|axis_index| {
307        mouth_world[axis_index] - pose_center[axis_index] + frame_sizes[axis_index] / 2.0
308    });
309    let rim_color = koi_body.select_mouth_rim_color();
310    let frame_width = FRAME_WIDTH_PIXELS as usize;
311    let first_y = (mouth[1] - radius_pixels).floor().max(0.0) as usize;
312    let last_y = ((mouth[1] + radius_pixels).ceil() as usize).min(FRAME_HEIGHT_PIXELS as usize - 1);
313    let first_x = (mouth[0] - radius_pixels).floor().max(0.0) as usize;
314    let last_x = ((mouth[0] + radius_pixels).ceil() as usize).min(frame_width - 1);
315    for pixel_y in first_y..=last_y {
316        for pixel_x in first_x..=last_x {
317            let distance_pixels =
318                (pixel_x as f64 + 0.5 - mouth[0]).hypot(pixel_y as f64 + 0.5 - mouth[1]);
319            if distance_pixels <= radius_pixels {
320                rgba_pixels[pixel_y * frame_width + pixel_x] =
321                    if distance_pixels > radius_pixels - RIM_WIDTH_PIXELS {
322                        rim_color
323                    } else {
324                        OUTLINE_COLOR
325                    };
326            }
327        }
328    }
329}
330
331/// Surface feeding: ripple rings spreading from the mouth and food pellets drifting in
332/// to it, drawn on the water around the koi. Where the frame edge leaves too little
333/// room, both shrink toward the mouth instead of running off the edge.
334fn paint_feeding(
335    rgba_pixels: &mut [[u8; 4]],
336    body_spine: &[CurvePoint],
337    koi_anatomy: &KoiAnatomy,
338    pose_center: [f64; 2],
339    feeding_phase: f64,
340) {
341    const RING_COUNT: usize = 3;
342    const RING_WIDTH_PER_BODY_LENGTH: f64 = 0.3;
343    const RING_HEIGHT_PER_WIDTH: f64 = 0.4;
344    const RING_ANGLE_STEP_RADIANS: f64 = 0.035;
345    const PELLET_COUNT: usize = 5;
346    const PELLET_NEAREST_PIXELS: f64 = 4.0;
347    const PELLET_REACH_PIXELS: f64 = 14.0;
348    const PELLET_WANDER_PIXELS: f64 = 9.0;
349    // Pellets vanish just before they reach the mouth.
350    const PELLET_EATEN_LIFE: f64 = 0.92;
351    const PELLET_COLORS: [[u8; 4]; 2] = [[228, 166, 114, 255], [184, 111, 80, 255]];
352    // A clear pixel between the extras and the frame edge, plus rounding.
353    const EDGE_MARGIN_PIXELS: f64 = 2.0;
354    let frame_sizes = [
355        f64::from(FRAME_WIDTH_PIXELS),
356        f64::from(FRAME_HEIGHT_PIXELS),
357    ];
358    let mouth_world = calculate_mouth_position(body_spine);
359    let mouth = [0, 1].map(|axis_index| {
360        mouth_world[axis_index] - pose_center[axis_index] + frame_sizes[axis_index] / 2.0
361    });
362    let room = [0, 1].map(|axis_index| {
363        (mouth[axis_index] - EDGE_MARGIN_PIXELS)
364            .min(frame_sizes[axis_index] - 1.0 - EDGE_MARGIN_PIXELS - mouth[axis_index])
365            .max(0.0)
366    });
367    let forward_direction = calculate_forward_direction(body_spine, 0);
368    let frame_width = FRAME_WIDTH_PIXELS as usize;
369    let mut paint_water = |point: [f64; 2], color: [u8; 4], dither_limit: f64| {
370        let [pixel_x, pixel_y] = point.map(f64::floor);
371        if !(0.0..frame_sizes[0]).contains(&pixel_x) || !(0.0..frame_sizes[1]).contains(&pixel_y) {
372            return;
373        }
374        let pixel_index = pixel_y as usize * frame_width + pixel_x as usize;
375        if rgba_pixels[pixel_index][3] == 0
376            && calculate_dither_threshold([pixel_x as u32, pixel_y as u32]) < dither_limit
377        {
378            rgba_pixels[pixel_index] = color;
379        }
380    };
381    let largest_ring_width = koi_anatomy.body_length_pixels * RING_WIDTH_PER_BODY_LENGTH * 0.85;
382    let ring_scale = 1.0_f64
383        .min(room[0] / largest_ring_width)
384        .min(room[1] / (largest_ring_width * RING_HEIGHT_PER_WIDTH));
385    for ring_index in 0..RING_COUNT {
386        let ring_phase = (feeding_phase + ring_index as f64 / RING_COUNT as f64).fract();
387        let ring_width = ring_scale
388            * koi_anatomy.body_length_pixels
389            * RING_WIDTH_PER_BODY_LENGTH
390            * (0.25 + 0.6 * ring_phase);
391        let ring_color = WHITE_RAMP[if ring_index % 2 == 1 { 2 } else { 1 }];
392        let mut ring_angle: f64 = 0.0;
393        while ring_angle < TAU {
394            paint_water(
395                [
396                    mouth[0] + ring_angle.cos() * ring_width,
397                    mouth[1] + ring_angle.sin() * ring_width * RING_HEIGHT_PER_WIDTH,
398                ],
399                ring_color,
400                (1.0 - ring_phase) * 0.75,
401            );
402            ring_angle += RING_ANGLE_STEP_RADIANS;
403        }
404    }
405    // The second pellet pixel sits one to the right, so leave it room too.
406    let farthest_pellet_pixels = PELLET_NEAREST_PIXELS + PELLET_REACH_PIXELS + PELLET_WANDER_PIXELS;
407    let pellet_scale = 1.0_f64.min((room[0].min(room[1]) - 1.0).max(0.0) / farthest_pellet_pixels);
408    for pellet_index in 0..PELLET_COUNT {
409        let life = (feeding_phase + pellet_index as f64 / PELLET_COUNT as f64).fract();
410        if life >= PELLET_EATEN_LIFE {
411            continue;
412        }
413        let reach = PELLET_NEAREST_PIXELS + (1.0 - life) * PELLET_REACH_PIXELS;
414        let wander = (1.0 - life) * PELLET_WANDER_PIXELS;
415        let pellet_point = [
416            mouth[0]
417                + pellet_scale
418                    * (forward_direction[0] * reach + (pellet_index as f64 * 2.1).sin() * wander),
419            mouth[1]
420                + pellet_scale
421                    * (forward_direction[1] * reach + (pellet_index as f64 * 1.7).cos() * wander),
422        ];
423        paint_water(pellet_point, PELLET_COLORS[0], 1.0);
424        paint_water(
425            [pellet_point[0] + 1.0, pellet_point[1]],
426            PELLET_COLORS[1],
427            1.0,
428        );
429    }
430}
431
432/// Under water the koi becomes a blue koi: each body and fin pixel maps its own
433/// brightness onto a water-blue scale, the whiskers and outline soften to deep blue,
434/// and a dithered share of each drops out, more for the outline.
435fn paint_depth(rgba_pixels: &mut [[u8; 4]], fish_pixel_mask: &[bool], depth: f64) {
436    const DEEP_WATER: [f64; 3] = [18.0, 78.0, 137.0];
437    const MID_WATER: [f64; 3] = [0.0, 153.0, 219.0];
438    const SHALLOW_WATER: [f64; 3] = [190.0, 225.0, 245.0];
439    const OUTLINE_WATER: [f64; 3] = [24.0, 48.0, 92.0];
440    const FISH_DROPOUT_PER_DEPTH: f64 = 0.15;
441    const OUTLINE_DROPOUT_PER_DEPTH: f64 = 0.45;
442    const TINT_PER_DEPTH: f64 = 1.15;
443    let tint = (depth * TINT_PER_DEPTH).min(1.0);
444    let frame_width = FRAME_WIDTH_PIXELS as usize;
445    for (pixel_index, pixel_channels) in rgba_pixels.iter_mut().enumerate() {
446        if pixel_channels[3] == 0 {
447            continue;
448        }
449        let is_fish_pixel = fish_pixel_mask[pixel_index];
450        let dropout_per_depth = if is_fish_pixel {
451            FISH_DROPOUT_PER_DEPTH
452        } else {
453            OUTLINE_DROPOUT_PER_DEPTH
454        };
455        let pixel_position = [
456            (pixel_index % frame_width) as u32,
457            (pixel_index / frame_width) as u32,
458        ];
459        if calculate_dither_threshold(pixel_position) < depth * dropout_per_depth {
460            *pixel_channels = [0; 4];
461            continue;
462        }
463        let original_color =
464            [0, 1, 2].map(|channel_index| f64::from(pixel_channels[channel_index]));
465        let water_color = if is_fish_pixel {
466            let brightness =
467                (0.3 * original_color[0] + 0.59 * original_color[1] + 0.11 * original_color[2])
468                    / 255.0;
469            let (from_color, to_color, blend) = if brightness < 0.5 {
470                (DEEP_WATER, MID_WATER, brightness * 2.0)
471            } else {
472                (MID_WATER, SHALLOW_WATER, brightness * 2.0 - 1.0)
473            };
474            [0, 1, 2].map(|channel_index| {
475                interpolate(from_color[channel_index], to_color[channel_index], blend)
476            })
477        } else {
478            OUTLINE_WATER
479        };
480        for channel_index in 0..3 {
481            pixel_channels[channel_index] = interpolate(
482                original_color[channel_index],
483                water_color[channel_index],
484                tint,
485            )
486            .round() as u8;
487        }
488    }
489}
490
491const OUTLINE_COLOR: [u8; 4] = [24, 20, 37, 255];
492const DITHER_RANKS: [[u8; 4]; 4] = [[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]];
493
494struct KoiAnatomy {
495    body_length_pixels: f64,
496    body_width_pixels: f64,
497    /// Share of the body length over which the head widens from the nose.
498    head_slant_fraction: f64,
499    /// Nose width as a share of the full body width.
500    nose_width_fraction: f64,
501    /// Exponent of the taper from the shoulders to the tail stalk.
502    tail_taper_exponent: f64,
503    /// Fin shapes blend from young (0) to adult (1).
504    adult_fin_fraction: f64,
505    fin_length_multiplier: f64,
506    eye_along_fraction: f64,
507    /// Young koi have proportionally larger eyes; adults use 1.
508    eye_scale: f64,
509}
510
511struct KoiAppearance<'a> {
512    koi_anatomy: &'a KoiAnatomy,
513    koi_coat: &'a KoiCoat,
514}
515
516// Positions and half widths are pixels; consecutive points form tapered strokes.
517#[derive(Clone, Copy)]
518struct CurvePoint {
519    position: [f64; 2],
520    half_width_pixels: f64,
521}
522
523struct CurveSample {
524    along_fraction: f64,
525    lateral_pixels: f64,
526    distance_pixels: f64,
527    half_width_pixels: f64,
528    normal_direction: [f64; 2],
529}
530
531#[derive(Clone, Copy)]
532struct FinShape {
533    length_body_widths: f64,
534    half_width_body_widths: f64,
535    width_start_fraction: f64,
536    taper_exponent: f64,
537}
538
539impl FinShape {
540    fn blend(self, other: Self, other_fraction: f64) -> Self {
541        Self {
542            length_body_widths: interpolate(
543                self.length_body_widths,
544                other.length_body_widths,
545                other_fraction,
546            ),
547            half_width_body_widths: interpolate(
548                self.half_width_body_widths,
549                other.half_width_body_widths,
550                other_fraction,
551            ),
552            width_start_fraction: interpolate(
553                self.width_start_fraction,
554                other.width_start_fraction,
555                other_fraction,
556            ),
557            taper_exponent: interpolate(self.taper_exponent, other.taper_exponent, other_fraction),
558        }
559    }
560}
561
562// Each pair shares its proportions: short rounded fins when young, longer adult fins.
563// Direction offsets in radians describe the two fins independently, relative to the
564// spine's backward tangent.
565struct FinPair {
566    fin_kind: FinKind,
567    root_along_fraction: f64,
568    root_side_fraction: f64,
569    young_shape: FinShape,
570    adult_shape: FinShape,
571    direction_offsets: [&'static [f64]; 2],
572}
573
574#[derive(Clone, Copy)]
575enum FinKind {
576    Tail,
577    Side,
578    Pelvic,
579}
580
581const FIN_PAIRS: [FinPair; 3] = [
582    FinPair {
583        fin_kind: FinKind::Tail,
584        root_along_fraction: 1.0,
585        root_side_fraction: 0.0,
586        young_shape: FinShape {
587            length_body_widths: 1.05,
588            half_width_body_widths: 0.3,
589            width_start_fraction: 0.16,
590            taper_exponent: 0.6,
591        },
592        adult_shape: FinShape {
593            length_body_widths: 1.62,
594            half_width_body_widths: 0.3,
595            width_start_fraction: 0.125,
596            taper_exponent: 0.69,
597        },
598        direction_offsets: [
599            &[-0.18035, -0.25648, -0.37367, -0.50421, -0.61344, -0.66997],
600            &[0.248, 0.30115, 0.29856, 0.24064, 0.14695, 0.05079],
601        ],
602    },
603    FinPair {
604        fin_kind: FinKind::Side,
605        root_along_fraction: 0.22,
606        root_side_fraction: 0.8,
607        young_shape: FinShape {
608            length_body_widths: 0.66,
609            half_width_body_widths: 0.25,
610            width_start_fraction: 0.18,
611            taper_exponent: 0.45,
612        },
613        adult_shape: FinShape {
614            length_body_widths: 1.13,
615            half_width_body_widths: 0.24,
616            width_start_fraction: 0.09,
617            taper_exponent: 0.54,
618        },
619        direction_offsets: [
620            &[0.87, 0.77239, 0.71583, 0.70044],
621            &[-0.65221, -0.58002, -0.5356, -0.48311],
622        ],
623    },
624    FinPair {
625        fin_kind: FinKind::Pelvic,
626        root_along_fraction: 0.47,
627        root_side_fraction: 0.7,
628        young_shape: FinShape {
629            length_body_widths: 0.36,
630            half_width_body_widths: 0.1,
631            width_start_fraction: 0.15,
632            taper_exponent: 0.5,
633        },
634        adult_shape: FinShape {
635            length_body_widths: 0.57,
636            half_width_body_widths: 0.11,
637            width_start_fraction: 0.11,
638            taper_exponent: 0.59,
639        },
640        direction_offsets: [&[0.39139, 0.3769, 0.39376], &[-0.62997, -0.48164, -0.32809]],
641    },
642];
643
644fn calculate_anatomy(total_contributions: u64) -> KoiAnatomy {
645    const MIN_BODY_LENGTH_PIXELS: f64 = 37.5;
646    const ADULT_BODY_LENGTH_PIXELS: f64 = 137.5;
647    const MAX_BODY_LENGTH_PIXELS: f64 = 167.5;
648    const YOUNG_BODY_WIDTH_FRACTION: f64 = 0.17;
649    const ADULT_BODY_WIDTH_FRACTION: f64 = 0.2;
650    const MATURE_BODY_WIDTH_GAIN: f64 = 0.045;
651    const YOUNG_HEAD_SLANT_FRACTION: f64 = 0.14;
652    const ADULT_HEAD_SLANT_FRACTION: f64 = 0.187;
653    const YOUNG_NOSE_WIDTH_FRACTION: f64 = 0.75;
654    const ADULT_NOSE_WIDTH_FRACTION: f64 = 0.517;
655    const YOUNG_TAIL_TAPER_EXPONENT: f64 = 1.25;
656    const ADULT_TAIL_TAPER_EXPONENT: f64 = 1.8;
657    const YOUNG_EYE_ALONG_FRACTION: f64 = 0.08;
658    const ADULT_EYE_ALONG_FRACTION: f64 = 0.06;
659    const YOUNG_EYE_SCALE: f64 = 2.1;
660    const YOUNG_FIN_LENGTH_MULTIPLIER: f64 = 0.85;
661    const MAX_FIN_LENGTH_MULTIPLIER: f64 = 1.1;
662    // Fins keep their young shape for the first quarter of the young stage.
663    const YOUNG_FIN_HOLD_FRACTION: f64 = 0.25;
664    let growth_fraction = calculate_growth_fraction(total_contributions);
665    let adult_growth_fraction = calculate_growth_fraction(ADULT_CONTRIBUTIONS);
666    let young_growth_fraction = (growth_fraction / adult_growth_fraction).min(1.0);
667    let mature_growth_fraction =
668        ((growth_fraction - adult_growth_fraction) / (1.0 - adult_growth_fraction)).max(0.0);
669    let adult_shape_fraction =
670        (growth_fraction / calculate_growth_fraction(YOUNG_SHAPE_CONTRIBUTIONS)).min(1.0);
671    let body_length_pixels = MIN_BODY_LENGTH_PIXELS
672        + (ADULT_BODY_LENGTH_PIXELS - MIN_BODY_LENGTH_PIXELS) * young_growth_fraction
673        + (MAX_BODY_LENGTH_PIXELS - ADULT_BODY_LENGTH_PIXELS) * mature_growth_fraction;
674    let body_width_fraction = interpolate(
675        YOUNG_BODY_WIDTH_FRACTION,
676        ADULT_BODY_WIDTH_FRACTION,
677        adult_shape_fraction,
678    ) + MATURE_BODY_WIDTH_GAIN * mature_growth_fraction;
679    KoiAnatomy {
680        body_length_pixels,
681        body_width_pixels: body_length_pixels * body_width_fraction,
682        head_slant_fraction: interpolate(
683            YOUNG_HEAD_SLANT_FRACTION,
684            ADULT_HEAD_SLANT_FRACTION,
685            adult_shape_fraction,
686        ),
687        nose_width_fraction: interpolate(
688            YOUNG_NOSE_WIDTH_FRACTION,
689            ADULT_NOSE_WIDTH_FRACTION,
690            adult_shape_fraction,
691        ),
692        tail_taper_exponent: interpolate(
693            YOUNG_TAIL_TAPER_EXPONENT,
694            ADULT_TAIL_TAPER_EXPONENT,
695            adult_shape_fraction,
696        ),
697        adult_fin_fraction: smooth_step(
698            (adult_shape_fraction - YOUNG_FIN_HOLD_FRACTION) / (1.0 - YOUNG_FIN_HOLD_FRACTION),
699        ),
700        fin_length_multiplier: interpolate(
701            YOUNG_FIN_LENGTH_MULTIPLIER,
702            1.0,
703            smooth_step(adult_shape_fraction),
704        ) * interpolate(
705            1.0,
706            MAX_FIN_LENGTH_MULTIPLIER,
707            mature_growth_fraction,
708        ),
709        eye_along_fraction: interpolate(
710            YOUNG_EYE_ALONG_FRACTION,
711            ADULT_EYE_ALONG_FRACTION,
712            adult_shape_fraction,
713        ),
714        eye_scale: interpolate(YOUNG_EYE_SCALE, 1.0, adult_shape_fraction),
715    }
716}
717
718fn interpolate(from: f64, to: f64, fraction: f64) -> f64 {
719    from + (to - from) * fraction
720}
721
722/// Ease in and out across zero through one; values outside are clamped.
723fn smooth_step(fraction: f64) -> f64 {
724    let fraction = fraction.clamp(0.0, 1.0);
725    fraction * fraction * (3.0 - 2.0 * fraction)
726}
727
728/// The spine follows the pose's course: the still pose's fixed heading and gentle bend,
729/// turned by any heading offset, or a clockwise circle around the frame center.
730fn build_spine(koi_anatomy: &KoiAnatomy, swim_pose: &SwimPose) -> Vec<CurvePoint> {
731    const HEADING_RADIANS: f64 = -0.08;
732    const BEND_RADIANS: f64 = 0.35;
733    const WAVE_AMPLITUDE_FRACTION: f64 = 0.06;
734    const WAVE_CYCLES: f64 = 0.9;
735    let body_length_pixels = koi_anatomy.body_length_pixels;
736    let point_count = body_length_pixels.round() as usize;
737    const MIN_HALF_WIDTH_PIXELS: f64 = 1.3;
738    let calculate_point_half_width = |point_index: usize| {
739        calculate_body_half_width(point_index as f64 / point_count as f64, koi_anatomy)
740            .max(MIN_HALF_WIDTH_PIXELS)
741    };
742    let center_points = match swim_pose.course {
743        Course::Hover {
744            heading_offset_radians,
745            ..
746        } => {
747            let mut center_points = vec![CurvePoint {
748                position: [0.0, 0.0],
749                half_width_pixels: calculate_point_half_width(0),
750            }];
751            for point_index in 1..point_count {
752                let backward_angle_radians = HEADING_RADIANS
753                    + heading_offset_radians
754                    + PI
755                    + BEND_RADIANS * point_index as f64 / point_count as f64;
756                let [previous_x, previous_y] = center_points[point_index - 1].position;
757                center_points.push(CurvePoint {
758                    position: [
759                        previous_x + backward_angle_radians.cos(),
760                        previous_y + backward_angle_radians.sin(),
761                    ],
762                    half_width_pixels: calculate_point_half_width(point_index),
763                });
764            }
765            center_points
766        }
767        // Each body point sits one pixel of arc behind the one in front.
768        Course::Lap { angle_radians } => {
769            let radius_pixels = calculate_lap_radius(body_length_pixels);
770            (0..point_count)
771                .map(|point_index| {
772                    let point_angle_radians = angle_radians - point_index as f64 / radius_pixels;
773                    CurvePoint {
774                        position: [
775                            radius_pixels * point_angle_radians.cos(),
776                            radius_pixels * point_angle_radians.sin(),
777                        ],
778                        half_width_pixels: calculate_point_half_width(point_index),
779                    }
780                })
781                .collect()
782        }
783    };
784    let mut body_spine = Vec::with_capacity(point_count);
785    for (point_index, center_point) in center_points.iter().enumerate() {
786        let along_fraction = point_index as f64 / point_count as f64;
787        let forward_direction = calculate_forward_direction(&center_points, point_index);
788        let head_wave_fraction = 0.1;
789        let wave_envelope =
790            head_wave_fraction + (1.0 - head_wave_fraction) * along_fraction.powi(2);
791        let wave_offset = body_length_pixels
792            * WAVE_AMPLITUDE_FRACTION
793            * swim_pose.wave_strength
794            * wave_envelope
795            * (along_fraction * TAU * WAVE_CYCLES - swim_pose.wave_phase_radians).sin();
796        body_spine.push(CurvePoint {
797            position: [
798                center_point.position[0] - forward_direction[1] * wave_offset,
799                center_point.position[1] + forward_direction[0] * wave_offset,
800            ],
801            half_width_pixels: center_point.half_width_pixels,
802        });
803    }
804    body_spine
805}
806
807/// Each fin turns at its root and bends along its length as the pose says; a turn
808/// outward is away from the body, whichever side the fin is on.
809fn build_fins(
810    body_spine: &[CurvePoint],
811    koi_anatomy: &KoiAnatomy,
812    swim_pose: &SwimPose,
813) -> Vec<Vec<CurvePoint>> {
814    let mut fin_curves = Vec::new();
815    for fin_pair in &FIN_PAIRS {
816        let root_index =
817            (fin_pair.root_along_fraction * (body_spine.len() - 1) as f64).round() as usize;
818        let root_point = body_spine[root_index];
819        let forward_direction = calculate_forward_direction(body_spine, root_index);
820        let backward_angle_radians = (-forward_direction[1]).atan2(-forward_direction[0]);
821        for (side_index, (body_side, direction_offsets)) in [-1.0, 1.0]
822            .into_iter()
823            .zip(fin_pair.direction_offsets)
824            .enumerate()
825        {
826            let outward_sign = direction_offsets[0].signum();
827            let (root_turn_radians, bend_radians) = match fin_pair.fin_kind {
828                FinKind::Tail => (
829                    outward_sign * swim_pose.tail_spread_radians,
830                    swim_pose.tail_lobe_bends_radians[side_index],
831                ),
832                FinKind::Side => (
833                    outward_sign * swim_pose.side_fin_openings_radians[side_index],
834                    outward_sign * swim_pose.side_fin_bends_radians[side_index],
835                ),
836                FinKind::Pelvic => (outward_sign * swim_pose.pelvic_fin_opening_radians, 0.0),
837            };
838            let root_offset_pixels =
839                root_point.half_width_pixels * fin_pair.root_side_fraction * body_side;
840            let root_position = [
841                root_point.position[0] - forward_direction[1] * root_offset_pixels,
842                root_point.position[1] + forward_direction[0] * root_offset_pixels,
843            ];
844            fin_curves.push(build_fin(
845                fin_pair,
846                root_position,
847                backward_angle_radians + root_turn_radians,
848                koi_anatomy,
849                direction_offsets,
850                bend_radians,
851            ));
852        }
853    }
854    fin_curves
855}
856
857fn build_fin(
858    fin_pair: &FinPair,
859    root_position: [f64; 2],
860    backward_angle_radians: f64,
861    koi_anatomy: &KoiAnatomy,
862    direction_offsets: &[f64],
863    bend_radians: f64,
864) -> Vec<CurvePoint> {
865    let fin_shape = fin_pair
866        .young_shape
867        .blend(fin_pair.adult_shape, koi_anatomy.adult_fin_fraction);
868    let fin_length_pixels = koi_anatomy.body_width_pixels
869        * fin_shape.length_body_widths
870        * koi_anatomy.fin_length_multiplier;
871    let segment_count = fin_length_pixels.ceil() as usize;
872    let segment_length_pixels = fin_length_pixels / segment_count as f64;
873    let mut center_position = root_position;
874    let mut fin_points = Vec::new();
875    for point_index in 0..=segment_count {
876        let along_fraction = point_index as f64 / segment_count as f64;
877        let width_progress = fin_shape.width_start_fraction
878            + (1.0 - fin_shape.width_start_fraction) * along_fraction;
879        fin_points.push(CurvePoint {
880            position: center_position,
881            half_width_pixels: koi_anatomy.body_width_pixels
882                * fin_shape.half_width_body_widths
883                * (PI * width_progress)
884                    .sin()
885                    .max(0.0)
886                    .powf(fin_shape.taper_exponent),
887        });
888        let direction_position = (along_fraction * direction_offsets.len() as f64 - 0.5)
889            .clamp(0.0, (direction_offsets.len() - 1) as f64);
890        let direction_index = direction_position.floor() as usize;
891        let start_angle = direction_offsets[direction_index];
892        let end_angle = direction_offsets[(direction_index + 1).min(direction_offsets.len() - 1)];
893        let segment_angle = backward_angle_radians
894            + start_angle
895            + (end_angle - start_angle) * direction_position.fract()
896            + bend_radians * along_fraction;
897        center_position[0] += segment_length_pixels * segment_angle.cos();
898        center_position[1] += segment_length_pixels * segment_angle.sin();
899    }
900    fin_points
901}
902
903/// Paint the whisker points. They fill only empty pixels and are painted before the
904/// outline, so they are outlined like the koi.
905fn paint_whiskers(rgba_pixels: &mut [[u8; 4]], whisker_points: &[[f64; 2]], pose_center: [f64; 2]) {
906    let frame_width = FRAME_WIDTH_PIXELS as usize;
907    for whisker_point in whisker_points {
908        let pixel_x =
909            (whisker_point[0] - pose_center[0] + f64::from(FRAME_WIDTH_PIXELS) / 2.0).floor();
910        let pixel_y =
911            (whisker_point[1] - pose_center[1] + f64::from(FRAME_HEIGHT_PIXELS) / 2.0).floor();
912        if !(0.0..f64::from(FRAME_WIDTH_PIXELS)).contains(&pixel_x)
913            || !(0.0..f64::from(FRAME_HEIGHT_PIXELS)).contains(&pixel_y)
914        {
915            continue;
916        }
917        let pixel_index = pixel_y as usize * frame_width + pixel_x as usize;
918        if rgba_pixels[pixel_index][3] == 0 {
919            rgba_pixels[pixel_index] = WHITE_RAMP[1];
920        }
921    }
922}
923
924/// One point per whisker pixel: short lines angled forward and out from the edge of
925/// the snout, swung together by the sway.
926fn calculate_whisker_points(
927    body_spine: &[CurvePoint],
928    koi_anatomy: &KoiAnatomy,
929    sway_radians: f64,
930) -> Vec<[f64; 2]> {
931    const SPREAD_RADIANS: f64 = 0.85;
932    const ROOT_SIDE_FRACTION: f64 = 0.8;
933    const ROOT_FORWARD_FRACTION: f64 = 0.55;
934    let whisker_length_pixels = calculate_whisker_length(koi_anatomy);
935    let snout_position = body_spine[0].position;
936    let snout_radius_pixels = body_spine[0].half_width_pixels;
937    let forward_direction = calculate_forward_direction(body_spine, 0);
938    let heading_radians = forward_direction[1].atan2(forward_direction[0]);
939    let mut whisker_points = Vec::new();
940    for body_side in [-1.0, 1.0] {
941        let side_offset_pixels = snout_radius_pixels * ROOT_SIDE_FRACTION * body_side;
942        let forward_offset_pixels = snout_radius_pixels * ROOT_FORWARD_FRACTION;
943        let root_position = [
944            snout_position[0] - forward_direction[1] * side_offset_pixels
945                + forward_direction[0] * forward_offset_pixels,
946            snout_position[1]
947                + forward_direction[0] * side_offset_pixels
948                + forward_direction[1] * forward_offset_pixels,
949        ];
950        let whisker_angle = heading_radians + sway_radians + body_side * SPREAD_RADIANS;
951        for step_pixels in 1..=whisker_length_pixels {
952            whisker_points.push([
953                root_position[0] + whisker_angle.cos() * f64::from(step_pixels),
954                root_position[1] + whisker_angle.sin() * f64::from(step_pixels),
955            ]);
956        }
957    }
958    whisker_points
959}
960
961/// Whiskers grow with the koi, from three to five pixels.
962fn calculate_whisker_length(koi_anatomy: &KoiAnatomy) -> u32 {
963    const BODY_PIXELS_PER_WHISKER_PIXEL: f64 = 34.0;
964    (koi_anatomy.body_length_pixels / BODY_PIXELS_PER_WHISKER_PIXEL)
965        .round()
966        .clamp(3.0, 5.0) as u32
967}
968
969/// The lap circle's radius: as wide as the frame allows, tighter for longer koi so the
970/// whole fish stays inside, and never so tight that the body covers more than its
971/// share of the circle.
972fn calculate_lap_radius(body_length_pixels: f64) -> f64 {
973    const FRAME_REACH_PIXELS: f64 = 116.0;
974    const OUTER_REACH_FRACTION: f64 = 0.33;
975    const MAX_LAP_RADIUS_PIXELS: f64 = 88.0;
976    const MAX_BODY_SHARE_OF_LAP: f64 = 0.6;
977    (FRAME_REACH_PIXELS - OUTER_REACH_FRACTION * body_length_pixels)
978        .min(MAX_LAP_RADIUS_PIXELS)
979        .max(body_length_pixels / (TAU * MAX_BODY_SHARE_OF_LAP))
980}
981
982/// The corners of the box that holds every curve, including its half widths.
983fn measure_curve_bounds(
984    body_spine: &[CurvePoint],
985    fin_curves: &[Vec<CurvePoint>],
986) -> [[f64; 2]; 2] {
987    let mut min_corner = [f64::INFINITY; 2];
988    let mut max_corner = [f64::NEG_INFINITY; 2];
989    for curve_point in body_spine.iter().chain(fin_curves.iter().flatten()) {
990        for axis_index in 0..2 {
991            min_corner[axis_index] = min_corner[axis_index]
992                .min(curve_point.position[axis_index] - curve_point.half_width_pixels);
993            max_corner[axis_index] = max_corner[axis_index]
994                .max(curve_point.position[axis_index] + curve_point.half_width_pixels);
995        }
996    }
997    [min_corner, max_corner]
998}
999
1000fn sample_koi(
1001    body_spine: &[CurvePoint],
1002    fin_curves: &[Vec<CurvePoint>],
1003    sample_position: [f64; 2],
1004    pixel_position: [u32; 2],
1005    koi_appearance: &KoiAppearance,
1006) -> [u8; 4] {
1007    if let Some(body_sample) = sample_curve(body_spine, sample_position) {
1008        return shade_body(&body_sample, pixel_position, koi_appearance);
1009    }
1010    for fin_curve in fin_curves {
1011        if let Some(fin_sample) = sample_curve(fin_curve, sample_position) {
1012            return shade_fin(&fin_sample, pixel_position, koi_appearance.koi_coat);
1013        }
1014    }
1015    [0; 4]
1016}
1017
1018// Constructed curves contain at least two points and no zero-length segments.
1019fn sample_curve(curve_points: &[CurvePoint], sample_position: [f64; 2]) -> Option<CurveSample> {
1020    let mut closest_sample = None;
1021    let mut closest_distance_pixels = f64::INFINITY;
1022    for (segment_index, end_points) in curve_points.windows(2).enumerate() {
1023        let [start_point, end_point] = [end_points[0], end_points[1]];
1024        let segment_x = end_point.position[0] - start_point.position[0];
1025        let segment_y = end_point.position[1] - start_point.position[1];
1026        let offset_x = sample_position[0] - start_point.position[0];
1027        let offset_y = sample_position[1] - start_point.position[1];
1028        let segment_length_pixels = segment_x.hypot(segment_y);
1029        let segment_progress = ((offset_x * segment_x + offset_y * segment_y)
1030            / segment_length_pixels.powi(2))
1031        .clamp(0.0, 1.0);
1032        let distance_pixels = (offset_x - segment_progress * segment_x)
1033            .hypot(offset_y - segment_progress * segment_y);
1034        if distance_pixels >= closest_distance_pixels {
1035            continue;
1036        }
1037        closest_distance_pixels = distance_pixels;
1038        closest_sample = Some(CurveSample {
1039            along_fraction: (segment_index as f64 + segment_progress)
1040                / (curve_points.len() - 1) as f64,
1041            lateral_pixels: (offset_x * segment_y - offset_y * segment_x) / segment_length_pixels,
1042            distance_pixels,
1043            half_width_pixels: start_point.half_width_pixels
1044                + segment_progress * (end_point.half_width_pixels - start_point.half_width_pixels),
1045            normal_direction: [
1046                segment_y / segment_length_pixels,
1047                -segment_x / segment_length_pixels,
1048            ],
1049        });
1050    }
1051    closest_sample
1052        .filter(|curve_sample| curve_sample.distance_pixels <= curve_sample.half_width_pixels)
1053}
1054
1055fn shade_body(
1056    body_sample: &CurveSample,
1057    pixel_position: [u32; 2],
1058    koi_appearance: &KoiAppearance,
1059) -> [u8; 4] {
1060    let koi_anatomy = koi_appearance.koi_anatomy;
1061    let body_length_pixels = koi_anatomy.body_length_pixels;
1062    let lateral_fraction = body_sample.lateral_pixels / body_sample.half_width_pixels;
1063    let body_paint = koi_appearance.koi_coat.select_body_paint(
1064        body_sample.along_fraction,
1065        lateral_fraction,
1066        body_length_pixels,
1067        body_sample.half_width_pixels,
1068        calculate_dither_threshold(pixel_position),
1069    );
1070    const INNER_RIM_PIXELS: f64 = 0.8;
1071    if body_sample.distance_pixels > body_sample.half_width_pixels - INNER_RIM_PIXELS {
1072        return body_paint.shade_ramp[0];
1073    }
1074    let eye_lateral_fraction = 0.55;
1075    let eye_half_thickness_pixels = 0.8 * koi_anatomy.eye_scale;
1076    let eye_half_span_fraction = 0.25 * koi_anatomy.eye_scale.sqrt();
1077    if ((body_sample.along_fraction - koi_anatomy.eye_along_fraction) * body_length_pixels).abs()
1078        < eye_half_thickness_pixels
1079        && (lateral_fraction.abs() - eye_lateral_fraction).abs() < eye_half_span_fraction
1080    {
1081        return OUTLINE_COLOR;
1082    }
1083    if let Some(glint_color) = body_paint.glint_color {
1084        return glint_color;
1085    }
1086    let surface_brightness = calculate_body_brightness(body_sample, lateral_fraction, koi_anatomy)
1087        + body_paint.brightness_shift;
1088    adjust_saturation(
1089        choose_shade(body_paint.shade_ramp, surface_brightness, pixel_position),
1090        body_paint.saturation,
1091    )
1092}
1093
1094fn calculate_body_brightness(
1095    body_sample: &CurveSample,
1096    lateral_fraction: f64,
1097    koi_anatomy: &KoiAnatomy,
1098) -> f64 {
1099    const AMBIENT_BRIGHTNESS: f64 = 0.24;
1100    const DIFFUSE_STRENGTH: f64 = 0.7;
1101    const LIGHT_DIRECTION: [f64; 3] = [-0.32, -0.5, 0.8];
1102    let surface_normal_z = (1.0 - lateral_fraction.powi(2)).max(0.0).sqrt();
1103    let incident_light = lateral_fraction
1104        * (body_sample.normal_direction[0] * LIGHT_DIRECTION[0]
1105            + body_sample.normal_direction[1] * LIGHT_DIRECTION[1])
1106        + surface_normal_z * LIGHT_DIRECTION[2];
1107    let mut surface_brightness = AMBIENT_BRIGHTNESS + DIFFUSE_STRENGTH * incident_light.max(0.0);
1108    let dorsal_span = 0.27..0.62;
1109    let dorsal_half_width_fraction = 0.1;
1110    let dorsal_shadow = 0.18;
1111    if dorsal_span.contains(&body_sample.along_fraction)
1112        && lateral_fraction.abs() < dorsal_half_width_fraction
1113    {
1114        surface_brightness -= dorsal_shadow;
1115    }
1116    // The gill line sits near the back of the head's slant.
1117    let gill_along_fraction = koi_anatomy.head_slant_fraction * 0.72;
1118    let gill_inner_fraction = 0.3;
1119    let gill_shadow = 0.22;
1120    if ((body_sample.along_fraction - gill_along_fraction) * koi_anatomy.body_length_pixels).abs()
1121        < 0.5
1122        && lateral_fraction.abs() > gill_inner_fraction
1123    {
1124        surface_brightness -= gill_shadow;
1125    }
1126    surface_brightness
1127}
1128
1129fn shade_fin(fin_sample: &CurveSample, pixel_position: [u32; 2], koi_coat: &KoiCoat) -> [u8; 4] {
1130    const FIN_RIM_PIXELS: f64 = 0.6;
1131    const FIN_RAY_SPACING_PIXELS: f64 = 1.8;
1132    const FIN_ROOT_SHADOW: f64 = 0.12;
1133    let is_on_rim = fin_sample.distance_pixels > fin_sample.half_width_pixels - FIN_RIM_PIXELS;
1134    let fin_paint = koi_coat.select_fin_paint(
1135        fin_sample.along_fraction,
1136        fin_sample.lateral_pixels / fin_sample.half_width_pixels,
1137        is_on_rim,
1138        calculate_dither_threshold(pixel_position),
1139    );
1140    if is_on_rim {
1141        return adjust_saturation(fin_paint.shade_ramp[1], fin_paint.saturation);
1142    }
1143    let ray_phase = (fin_sample.lateral_pixels / FIN_RAY_SPACING_PIXELS).rem_euclid(1.0);
1144    let ray_brightness = 0.68;
1145    let membrane_brightness = 0.94;
1146    let surface_brightness = if ray_phase < 0.5 {
1147        ray_brightness
1148    } else {
1149        membrane_brightness
1150    };
1151    let root_shadow = FIN_ROOT_SHADOW * (1.0 - fin_sample.along_fraction).powi(2);
1152    adjust_saturation(
1153        choose_shade(
1154            fin_paint.shade_ramp,
1155            surface_brightness - root_shadow,
1156            pixel_position,
1157        ),
1158        fin_paint.saturation,
1159    )
1160}
1161
1162/// The body is drawn as a chain of round segments. The head widens from a round nose
1163/// along a gentle slant, so no segment outgrows the one in front of it: a steeper rise
1164/// leaves a knob at the tip, and a narrow head behind a wide body leaves a pinch.
1165fn calculate_body_half_width(along_fraction: f64, koi_anatomy: &KoiAnatomy) -> f64 {
1166    const MIN_SHOULDER_FRACTION: f64 = 0.3;
1167    const SHOULDER_GAP_FRACTION: f64 = 0.04;
1168    const TAPER_END_FRACTION: f64 = 0.92;
1169    const TAIL_WIDTH_FRACTION: f64 = 0.26;
1170    let head_slant_fraction = koi_anatomy.head_slant_fraction;
1171    let width_fraction = if along_fraction < head_slant_fraction {
1172        let nose_width_fraction = koi_anatomy.nose_width_fraction;
1173        nose_width_fraction
1174            + (1.0 - nose_width_fraction) * (along_fraction / head_slant_fraction * PI / 2.0).sin()
1175    } else {
1176        let shoulder_fraction =
1177            (head_slant_fraction + SHOULDER_GAP_FRACTION).max(MIN_SHOULDER_FRACTION);
1178        let taper_length_fraction = TAPER_END_FRACTION - shoulder_fraction;
1179        (1.0 - ((along_fraction - shoulder_fraction).max(0.0) / taper_length_fraction)
1180            .powf(koi_anatomy.tail_taper_exponent))
1181        .max(TAIL_WIDTH_FRACTION)
1182    };
1183    koi_anatomy.body_width_pixels / 2.0 * width_fraction
1184}
1185
1186fn calculate_forward_direction(curve_points: &[CurvePoint], point_index: usize) -> [f64; 2] {
1187    const DIRECTION_SPAN_SAMPLES: usize = 2;
1188    let earlier_point = curve_points[point_index.saturating_sub(DIRECTION_SPAN_SAMPLES)].position;
1189    let later_point =
1190        curve_points[(point_index + DIRECTION_SPAN_SAMPLES).min(curve_points.len() - 1)].position;
1191    let tangent_x = earlier_point[0] - later_point[0];
1192    let tangent_y = earlier_point[1] - later_point[1];
1193    let tangent_length_pixels = tangent_x.hypot(tangent_y);
1194    [
1195        tangent_x / tangent_length_pixels,
1196        tangent_y / tangent_length_pixels,
1197    ]
1198}
1199
1200fn paint_outline(rgba_pixels: &mut [[u8; 4]]) {
1201    let original_pixels = rgba_pixels.to_vec();
1202    let frame_width = FRAME_WIDTH_PIXELS as usize;
1203    for (pixel_index, pixel_channels) in rgba_pixels.iter_mut().enumerate() {
1204        if original_pixels[pixel_index][3] != 0 {
1205            continue;
1206        }
1207        let pixel_x = pixel_index % frame_width;
1208        let pixel_y = pixel_index / frame_width;
1209        let touches_shape = (pixel_x > 0 && original_pixels[pixel_index - 1][3] != 0)
1210            || (pixel_x + 1 < frame_width && original_pixels[pixel_index + 1][3] != 0)
1211            || (pixel_y > 0 && original_pixels[pixel_index - frame_width][3] != 0)
1212            || (pixel_y + 1 < FRAME_HEIGHT_PIXELS as usize
1213                && original_pixels[pixel_index + frame_width][3] != 0);
1214        if touches_shape {
1215            *pixel_channels = OUTLINE_COLOR;
1216        }
1217    }
1218}
1219
1220fn choose_shade(
1221    shade_ramp: &[[u8; 4]],
1222    surface_brightness: f64,
1223    pixel_position: [u32; 2],
1224) -> [u8; 4] {
1225    let shade_position = surface_brightness.clamp(0.0, 1.0) * (shade_ramp.len() - 1) as f64;
1226    let base_index = shade_position.floor() as usize;
1227    let shade_index = base_index
1228        + usize::from(shade_position.fract() > calculate_dither_threshold(pixel_position));
1229    shade_ramp[shade_index.min(shade_ramp.len() - 1)]
1230}
1231
1232/// Scale a color's distance from its own gray; one leaves it unchanged.
1233fn adjust_saturation(pixel_color: [u8; 4], saturation: f64) -> [u8; 4] {
1234    let [red, green, blue, alpha] = pixel_color.map(f64::from);
1235    let gray = 0.3 * red + 0.59 * green + 0.11 * blue;
1236    let saturate = |channel: f64| {
1237        (gray + (channel - gray) * saturation)
1238            .round()
1239            .clamp(0.0, 255.0) as u8
1240    };
1241    [saturate(red), saturate(green), saturate(blue), alpha as u8]
1242}
1243
1244/// The ordered-dither threshold at a pixel, from zero through one.
1245fn calculate_dither_threshold(pixel_position: [u32; 2]) -> f64 {
1246    let dither_x = pixel_position[0] as usize % DITHER_RANKS[0].len();
1247    let dither_y = pixel_position[1] as usize % DITHER_RANKS.len();
1248    (f64::from(DITHER_RANKS[dither_y][dither_x]) + 0.5)
1249        / (DITHER_RANKS.len() * DITHER_RANKS[0].len()) as f64
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254    use super::*;
1255
1256    /// Paint only the mouth on an empty canvas centered on it.
1257    fn paint_mouth_alone(koi_body: KoiBody, mouth_opening: f64) -> Vec<[u8; 4]> {
1258        let koi_anatomy = calculate_anatomy(1_500);
1259        let body_spine = build_spine(&koi_anatomy, &STILL_POSE);
1260        let mut rgba_pixels = vec![[0; 4]; (FRAME_WIDTH_PIXELS * FRAME_HEIGHT_PIXELS) as usize];
1261        let mouth_position = calculate_mouth_position(&body_spine);
1262        paint_mouth(
1263            &mut rgba_pixels,
1264            &body_spine,
1265            koi_body,
1266            mouth_position,
1267            mouth_opening,
1268        );
1269        rgba_pixels
1270    }
1271
1272    fn center_pixel(rgba_pixels: &[[u8; 4]]) -> [u8; 4] {
1273        rgba_pixels
1274            [(FRAME_HEIGHT_PIXELS / 2 * FRAME_WIDTH_PIXELS + FRAME_WIDTH_PIXELS / 2) as usize]
1275    }
1276
1277    fn count_painted(rgba_pixels: &[[u8; 4]]) -> usize {
1278        rgba_pixels
1279            .iter()
1280            .filter(|pixel_channels| pixel_channels[3] != 0)
1281            .count()
1282    }
1283
1284    #[test]
1285    fn the_mouth_opens_as_a_body_colored_rim_around_a_dark_opening() {
1286        assert_eq!(count_painted(&paint_mouth_alone(KoiBody::White, 0.1)), 0);
1287        for (koi_body, rim_color) in [
1288            (KoiBody::White, [192, 203, 220, 255]),
1289            (KoiBody::Black, [58, 68, 102, 255]),
1290        ] {
1291            let open_mouth = paint_mouth_alone(koi_body, 1.0);
1292            assert_eq!(center_pixel(&open_mouth), OUTLINE_COLOR);
1293            assert!(open_mouth.contains(&rim_color), "{koi_body:?}");
1294            assert!(
1295                open_mouth
1296                    .iter()
1297                    .all(|&pixel| pixel == [0; 4] || pixel == OUTLINE_COLOR || pixel == rim_color)
1298            );
1299        }
1300        let half_open = count_painted(&paint_mouth_alone(KoiBody::White, 0.5));
1301        let wide_open = count_painted(&paint_mouth_alone(KoiBody::White, 1.0));
1302        assert!(0 < half_open && half_open < wide_open);
1303    }
1304}