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 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 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 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}