annotate_celeste_map/
lib.rs

1#![allow(clippy::wildcard_in_or_patterns)]
2use std::{fs::File, io::BufWriter, path::Path};
3
4use anyhow::Result;
5use celesteloader::{
6    cct_physics_inspector::{MapBounds, PhysicsInspector},
7    map::Bounds,
8};
9use image::{DynamicImage, ImageOutputFormat, Rgba};
10use imageproc::drawing::{text_size, Canvas};
11use rusttype::{Font, Scale};
12use tiny_skia::{
13    Color, GradientStop, LinearGradient, Paint, PathBuilder, Pixmap, Point, Shader, Stroke,
14    Transform,
15};
16
17const CONNECTION_COLOR_ANITIALIASING: bool = false;
18const CONNECTION_COLOR_TRANSPARENCY: u8 = 100;
19
20pub struct Annotate {
21    map: DynamicImage,
22    pub bounds: MapBounds,
23}
24impl Annotate {
25    pub fn new(map: DynamicImage, bounds: MapBounds) -> Self {
26        assert_eq!(map.dimensions(), bounds.dimensions());
27
28        Annotate { map, bounds }
29    }
30
31    pub fn load(path: impl AsRef<Path>, anchor: Anchor) -> Result<Self> {
32        let map = image::io::Reader::open(path)?.decode()?;
33
34        let map_dims = map.dimensions();
35        let bounds = match anchor {
36            Anchor::TopLeft { room_pos } => MapBounds::from_pos_width(room_pos, map_dims),
37            Anchor::BottomLeft {
38                room_pos,
39                room_height,
40            } => {
41                let bottom_y = room_pos.1 + room_height;
42                MapBounds {
43                    x: room_pos.0..room_pos.0 + map_dims.0 as i32,
44                    y: bottom_y - map_dims.1 as i32..bottom_y,
45                }
46            }
47        };
48
49        Ok(Annotate { map, bounds })
50    }
51
52    pub fn annotate_entries(&mut self, path: impl AsRef<Path>, font: &Font) -> Result<&mut Self> {
53        let circle_radius = 22;
54        let mut maps = csv::ReaderBuilder::new()
55            .has_headers(false)
56            .from_path(path)?;
57        for record in maps.records() {
58            let record = record?;
59
60            let [num, name, x, y] = record.iter().collect::<Vec<_>>().try_into().unwrap();
61            let x: i32 = x.parse()?;
62            let y: i32 = y.parse()?;
63
64            let position = self.bounds.map_offset((x, y));
65
66            let bench_name = name.strip_prefix("bench_");
67
68            let (name, color) = match bench_name {
69                Some(name) => (name, Rgba([0, 0, 255, 255])),
70                _ => (num, Rgba([255, 0, 0, 255])),
71            };
72
73            imageproc::drawing::draw_filled_circle_mut(
74                &mut self.map,
75                position,
76                circle_radius,
77                color,
78            );
79            let scale = Scale::uniform(35.0);
80            draw_text_centered(
81                &mut self.map,
82                Rgba([255, 255, 255, 255]),
83                position,
84                scale,
85                font,
86                name,
87            );
88        }
89
90        Ok(self)
91    }
92
93    pub fn annotate_cct_recording(
94        &mut self,
95        physics_inspector: &PhysicsInspector,
96        i: u32,
97    ) -> Result<&mut Self> {
98        let position_log = physics_inspector.position_log(i)?;
99
100        let mut path = Vec::new();
101        for log in position_log {
102            let item = log?;
103
104            let state = item.flags.split(' ').next().unwrap().to_owned();
105            let (map_x, map_y) = self.bounds.map_offset_f32((item.x, item.y));
106
107            let new_entry = (map_x, map_y, state);
108            let same_as_last = path.last() == Some(&new_entry);
109            if !same_as_last {
110                path.push(new_entry);
111            }
112        }
113
114        for window in path.windows(2) {
115            let &[(from_x, from_y, ref state), (to_x, to_y, _)] = window else {
116                unreachable!()
117            };
118
119            let color = match state.as_str() {
120                "StNormal" => Rgba([0, 255, 0, CONNECTION_COLOR_TRANSPARENCY]),
121                "StDash" => Rgba([255, 0, 0, CONNECTION_COLOR_TRANSPARENCY]),
122                "StClimb" => Rgba([255, 255, 0, 200]),
123                "StDummy" => Rgba([255, 255, 255, CONNECTION_COLOR_TRANSPARENCY]),
124                "StOther" | _ => Rgba([255, 0, 255, CONNECTION_COLOR_TRANSPARENCY]),
125            };
126
127            if CONNECTION_COLOR_ANITIALIASING {
128                imageproc::drawing::draw_antialiased_line_segment_mut(
129                    &mut self.map,
130                    (from_x as i32, from_y as i32),
131                    (to_x as i32, to_y as i32),
132                    color,
133                    imageproc::pixelops::interpolate,
134                );
135            } else {
136                imageproc::drawing::draw_line_segment_mut(
137                    &mut self.map,
138                    (from_x, from_y),
139                    (to_x, to_y),
140                    color,
141                );
142            }
143        }
144
145        Ok(self)
146    }
147
148    pub fn save(&mut self, path: impl AsRef<Path>) -> Result<()> {
149        let out = File::create(path)?;
150        self.map
151            .write_to(&mut BufWriter::new(out), ImageOutputFormat::Png)?;
152
153        Ok(())
154    }
155}
156
157pub enum Anchor {
158    TopLeft {
159        room_pos: (i32, i32),
160    },
161    BottomLeft {
162        room_pos: (i32, i32),
163        room_height: i32,
164    },
165}
166
167fn draw_text_centered<'a>(
168    canvas: &'a mut DynamicImage,
169    color: <DynamicImage as Canvas>::Pixel,
170    position: (i32, i32),
171    scale: Scale,
172    font: &'a Font<'a>,
173    text: &str,
174) {
175    let size = text_size(scale, font, text);
176    imageproc::drawing::draw_text_mut(
177        canvas,
178        color,
179        position.0 - size.0 / 2,
180        position.1 - size.1 / 2,
181        scale,
182        font,
183        text,
184    );
185}
186
187#[derive(Clone, Copy)]
188pub enum ColorMode {
189    Gradient,
190    State,
191    Random,
192    Color([u8; 4]),
193}
194
195#[derive(Clone, Copy)]
196pub struct LineSettings {
197    pub width: f32,
198    pub anti_alias: bool,
199    pub color_mode: ColorMode,
200}
201impl Default for LineSettings {
202    fn default() -> Self {
203        Self {
204            width: 2.0,
205            anti_alias: true,
206            color_mode: ColorMode::Gradient,
207        }
208    }
209}
210
211pub fn annotate_cct_recording_skia(
212    image: &mut Pixmap,
213    physics_inspector: &PhysicsInspector,
214    i: impl Iterator<Item = u32>,
215    bounds: Bounds,
216    settings: LineSettings,
217) -> Result<()> {
218    let mut random_color_index = 0;
219    let random_transparency = 200;
220    let random_colors = [
221        Color::from_rgba8(255, 0, 0, random_transparency),
222        Color::from_rgba8(0, 255, 0, random_transparency),
223        Color::from_rgba8(0, 0, 255, random_transparency),
224        Color::from_rgba8(255, 255, 0, random_transparency),
225        Color::from_rgba8(255, 0, 255, random_transparency),
226        Color::from_rgba8(0, 255, 255, random_transparency),
227        Color::from_rgba8(128, 0, 128, random_transparency),
228        Color::from_rgba8(255, 165, 0, random_transparency),
229        Color::from_rgba8(0, 128, 0, random_transparency),
230        Color::from_rgba8(255, 192, 203, random_transparency),
231    ];
232
233    for i in i {
234        annotate_single_cct_recording_skia(
235            image,
236            physics_inspector,
237            i,
238            bounds,
239            settings,
240            random_colors[random_color_index],
241        )?;
242
243        random_color_index = (random_color_index + 1) % random_colors.len();
244    }
245    Ok(())
246}
247
248fn annotate_single_cct_recording_skia(
249    image: &mut Pixmap,
250    physics_inspector: &PhysicsInspector,
251    i: u32,
252    bounds: Bounds,
253    settings: LineSettings,
254    random_color: Color,
255) -> Result<()> {
256    // read path
257    let position_log = physics_inspector.position_log(i)?;
258
259    let mut path = Vec::new();
260    for log in position_log {
261        let item = log?;
262        let state = item.flags.split(' ').next().unwrap().to_owned();
263
264        let new_entry = (item.x, item.y, state);
265        let same_as_last = path.last() == Some(&new_entry);
266        if !same_as_last {
267            path.push(new_entry);
268        }
269    }
270
271    if path.len() <= 1 {
272        return Ok(());
273    }
274
275    let map2img = Transform::from_translate(-bounds.position.x as f32, -bounds.position.y as f32);
276
277    // render fn
278    let mut flush = |pb: PathBuilder, state: &str| {
279        let Some(path) = pb.finish() else { return };
280
281        let shader = match settings.color_mode {
282            ColorMode::Gradient => LinearGradient::new(
283                Point::from_xy(0.0, 0.0),
284                Point::from_xy(bounds.size.0 as f32, bounds.size.1 as f32),
285                vec![
286                    GradientStop::new(0.0, Color::from_rgba8(255, 0, 0, 255)),
287                    GradientStop::new(0.5, Color::from_rgba8(128, 0, 128, 255)),
288                    GradientStop::new(1.0, Color::from_rgba8(15, 30, 150, 255)),
289                ],
290                tiny_skia::SpreadMode::Reflect,
291                Transform::identity(),
292            )
293            .unwrap(),
294            ColorMode::State => {
295                let transparency = 255;
296
297                let color = match state {
298                    "StNormal" => Color::from_rgba8(0, 255, 0, transparency),
299                    "StDash" => Color::from_rgba8(255, 0, 0, transparency),
300                    "StClimb" => Color::from_rgba8(255, 255, 0, transparency),
301                    "StDummy" => Color::from_rgba8(255, 255, 255, transparency),
302                    "StOther" | _ => Color::from_rgba8(255, 0, 255, transparency),
303                };
304                Shader::SolidColor(color)
305            }
306            ColorMode::Color([r, g, b, a]) => Shader::SolidColor(Color::from_rgba8(r, g, b, a)),
307            ColorMode::Random => Shader::SolidColor(random_color),
308        };
309
310        image.stroke_path(
311            &path,
312            &Paint {
313                shader,
314                blend_mode: tiny_skia::BlendMode::SourceOver,
315                anti_alias: settings.anti_alias,
316                ..Default::default()
317            },
318            &Stroke {
319                width: settings.width,
320                line_cap: tiny_skia::LineCap::Butt,
321                line_join: tiny_skia::LineJoin::Round,
322                ..Default::default()
323            },
324            map2img,
325            None,
326        );
327    };
328
329    // iterate through path
330    let mut pb = PathBuilder::new();
331
332    let mut items = path.into_iter();
333    let (x, y, mut last_state) = items.next().unwrap();
334    pb.move_to(x, y);
335
336    for (x, y, state) in items {
337        pb.line_to(x, y);
338
339        if let ColorMode::State = settings.color_mode {
340            if state != last_state {
341                flush(std::mem::take(&mut pb), &last_state);
342                pb.move_to(x, y)
343            }
344        }
345
346        last_state = state;
347    }
348
349    flush(pb, &last_state);
350
351    Ok(())
352}