1use otf_pixels_core::{
20 AccessPattern, ImageDescriptor, Op, PixelsError, Region, Result, Tile, TileMut,
21};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
25pub enum Quarter {
26 #[default]
28 None,
29 Clockwise90,
31 Half,
33 Clockwise270,
35}
36
37impl Quarter {
38 pub fn from_degrees(degrees: i32) -> Result<Self> {
49 if degrees % 90 != 0 {
50 return Err(PixelsError::invalid_argument(
51 "degrees",
52 format!("rotation must be a multiple of 90, got {degrees}"),
53 ));
54 }
55 Ok(match degrees.rem_euclid(360) / 90 {
56 1 => Self::Clockwise90,
57 2 => Self::Half,
58 3 => Self::Clockwise270,
59 _ => Self::None,
60 })
61 }
62
63 #[must_use]
65 pub const fn transposes(self) -> bool {
66 matches!(self, Self::Clockwise90 | Self::Clockwise270)
67 }
68
69 #[must_use]
71 pub const fn inverse(self) -> Self {
72 match self {
73 Self::None => Self::None,
74 Self::Clockwise90 => Self::Clockwise270,
75 Self::Half => Self::Half,
76 Self::Clockwise270 => Self::Clockwise90,
77 }
78 }
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
83pub struct Rotate {
84 quarter: Quarter,
85}
86
87impl Rotate {
88 #[must_use]
90 pub const fn new(quarter: Quarter) -> Self {
91 Self { quarter }
92 }
93
94 pub fn degrees(degrees: i32) -> Result<Self> {
100 Ok(Self::new(Quarter::from_degrees(degrees)?))
101 }
102
103 #[must_use]
105 pub const fn quarter(&self) -> Quarter {
106 self.quarter
107 }
108
109 const fn source_of(&self, x: u32, y: u32, input_width: u32, input_height: u32) -> (u32, u32) {
114 match self.quarter {
115 Quarter::None => (x, y),
116 Quarter::Clockwise90 => (y, input_height.saturating_sub(1).saturating_sub(x)),
119 Quarter::Half => (
120 input_width.saturating_sub(1).saturating_sub(x),
121 input_height.saturating_sub(1).saturating_sub(y),
122 ),
123 Quarter::Clockwise270 => (input_width.saturating_sub(1).saturating_sub(y), x),
124 }
125 }
126}
127
128impl Op for Rotate {
129 fn rescaled(&self) -> Option<std::sync::Arc<dyn Op>> {
133 Some(std::sync::Arc::new(Self::new(self.quarter)))
134 }
135 fn name(&self) -> &'static str {
136 "rotate"
137 }
138
139 fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
140 let input = inputs
141 .first()
142 .ok_or_else(|| PixelsError::graph("`rotate` takes one input, got none"))?;
143 if self.quarter.transposes() {
144 input.resized(input.height, input.width)
145 } else {
146 Ok(*input)
147 }
148 }
149
150 fn input_regions(&self, output: Region, inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
151 let input = inputs
152 .first()
153 .ok_or_else(|| PixelsError::graph("`rotate` takes one input, got none"))?;
154
155 let (x0, y0) = self.source_of(output.x, output.y, input.width, input.height);
159 let last_x = output.x + output.width.saturating_sub(1);
160 let last_y = output.y + output.height.saturating_sub(1);
161 let (x1, y1) = self.source_of(last_x, last_y, input.width, input.height);
162
163 let (left, right) = (x0.min(x1), x0.max(x1));
164 let (top, bottom) = (y0.min(y1), y0.max(y1));
165 Ok(vec![Region::new(
166 left,
167 top,
168 right - left + 1,
169 bottom - top + 1,
170 )])
171 }
172
173 fn access_pattern(&self) -> AccessPattern {
174 if self.quarter.transposes() {
180 AccessPattern::Spatial
181 } else {
182 AccessPattern::Sequential
183 }
184 }
185
186 fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
187 let input = inputs
188 .first()
189 .ok_or_else(|| PixelsError::graph("`rotate` takes one input tile, got none"))?;
190 if input.pixel() != output.pixel() {
191 return Err(PixelsError::graph(format!(
192 "`rotate` input is {} but output is {}",
193 input.pixel(),
194 output.pixel()
195 )));
196 }
197
198 let bytes = output.pixel().bytes_per_pixel();
199 let region = output.region();
200 let source = input.region();
201
202 let (input_width, input_height) = whole_input_size(self.quarter, region, source);
206
207 for y in region.y..region.y + region.height {
208 let mut row_bytes = Vec::with_capacity(region.width as usize * bytes);
211 for x in region.x..region.x + region.width {
212 let (sx, sy) = self.source_of(x, y, input_width, input_height);
213 let Some(from) = input.row(sy) else {
214 row_bytes.resize(row_bytes.len() + bytes, 0);
215 continue;
216 };
217 let at = (sx.saturating_sub(source.x) as usize) * bytes;
218 match from.get(at..at + bytes) {
219 Some(pixel) => row_bytes.extend_from_slice(pixel),
220 None => row_bytes.resize(row_bytes.len() + bytes, 0),
221 }
222 }
223 if let Some(target) = output.row_mut(y) {
224 let len = target.len().min(row_bytes.len());
225 if let (Some(to), Some(from)) = (target.get_mut(..len), row_bytes.get(..len)) {
226 to.copy_from_slice(from);
227 }
228 }
229 }
230 Ok(())
231 }
232}
233
234const fn whole_input_size(quarter: Quarter, output: Region, source: Region) -> (u32, u32) {
241 match quarter {
242 Quarter::None => (source.x + source.width, source.y + source.height),
243 Quarter::Clockwise90 => (
244 source.x + source.width,
245 source.y + source.height + output.x,
247 ),
248 Quarter::Half => (
249 source.x + source.width + output.x,
250 source.y + source.height + output.y,
251 ),
252 Quarter::Clockwise270 => (source.x + source.width + output.y, source.y + source.height),
253 }
254}
255
256#[cfg(test)]
257#[allow(
258 clippy::unwrap_used,
259 clippy::expect_used,
260 clippy::indexing_slicing,
261 clippy::panic,
262 reason = "tests operate on known-good values and assert shapes directly"
263)]
264mod tests {
265 use super::*;
266 use otf_pixels_core::{PixelFormat, TileBuf};
267
268 fn apply(
270 op: &dyn Op,
271 input: &ImageDescriptor,
272 bytes: &[u8],
273 ) -> Result<(ImageDescriptor, Vec<u8>)> {
274 let out_desc = op.output_descriptor(std::slice::from_ref(input))?;
275 let source = TileBuf::from_vec(input.region(), input.pixel, bytes.to_vec())?;
276 let mut target = TileBuf::for_image(&out_desc)?;
277 op.compute(&[source.as_tile()?], &mut target.as_tile_mut()?)?;
278 Ok((out_desc, target.into_bytes()))
279 }
280
281 fn coded(width: u32, height: u32) -> (ImageDescriptor, Vec<u8>) {
283 let descriptor = ImageDescriptor::new(width, height, PixelFormat::Rgb8).unwrap();
284 let mut bytes = Vec::new();
285 for y in 0..height {
286 for x in 0..width {
287 bytes.extend_from_slice(&[x as u8, y as u8, 0]);
288 }
289 }
290 (descriptor, bytes)
291 }
292
293 #[test]
294 fn degrees_normalize_to_quarter_turns() {
295 assert_eq!(Quarter::from_degrees(0).unwrap(), Quarter::None);
296 assert_eq!(Quarter::from_degrees(90).unwrap(), Quarter::Clockwise90);
297 assert_eq!(Quarter::from_degrees(180).unwrap(), Quarter::Half);
298 assert_eq!(Quarter::from_degrees(270).unwrap(), Quarter::Clockwise270);
299 assert_eq!(Quarter::from_degrees(360).unwrap(), Quarter::None);
300 assert_eq!(Quarter::from_degrees(-90).unwrap(), Quarter::Clockwise270);
301 assert_eq!(Quarter::from_degrees(450).unwrap(), Quarter::Clockwise90);
302 }
303
304 #[test]
305 fn an_angle_that_is_not_a_quarter_turn_is_an_error() {
306 for degrees in [1, 45, 89, -30, 100] {
309 assert!(
310 Quarter::from_degrees(degrees).is_err(),
311 "{degrees} should be rejected"
312 );
313 }
314 }
315
316 #[test]
317 fn rotating_transposes_the_shape_only_for_quarter_turns() {
318 let input = ImageDescriptor::new(30, 20, PixelFormat::Rgb8).unwrap();
319 for (quarter, expected) in [
320 (Quarter::None, (30, 20)),
321 (Quarter::Clockwise90, (20, 30)),
322 (Quarter::Half, (30, 20)),
323 (Quarter::Clockwise270, (20, 30)),
324 ] {
325 let out = Rotate::new(quarter)
326 .output_descriptor(std::slice::from_ref(&input))
327 .unwrap();
328 assert_eq!((out.width, out.height), expected, "{quarter:?}");
329 }
330 }
331
332 #[test]
333 fn four_quarter_turns_return_the_original() {
334 let (desc, bytes) = coded(7, 5);
337 let mut current = (desc, bytes.clone());
338 for _ in 0..4 {
339 current = apply(&Rotate::new(Quarter::Clockwise90), ¤t.0, ¤t.1).unwrap();
340 }
341 assert_eq!(current.0.width, 7);
342 assert_eq!(current.0.height, 5);
343 assert_eq!(current.1, bytes, "four turns did not return the original");
344 }
345
346 #[test]
347 fn a_rotation_and_its_inverse_cancel() {
348 let (desc, bytes) = coded(9, 4);
349 for quarter in [
350 Quarter::None,
351 Quarter::Clockwise90,
352 Quarter::Half,
353 Quarter::Clockwise270,
354 ] {
355 let (mid_desc, mid) = apply(&Rotate::new(quarter), &desc, &bytes).unwrap();
356 let (back_desc, back) =
357 apply(&Rotate::new(quarter.inverse()), &mid_desc, &mid).unwrap();
358 assert_eq!((back_desc.width, back_desc.height), (9, 4), "{quarter:?}");
359 assert_eq!(back, bytes, "{quarter:?} did not cancel with its inverse");
360 }
361 }
362
363 #[test]
364 fn clockwise_ninety_moves_the_corners_where_it_should() {
365 let (desc, bytes) = coded(4, 3);
368 let (out_desc, out) = apply(&Rotate::new(Quarter::Clockwise90), &desc, &bytes).unwrap();
369 assert_eq!((out_desc.width, out_desc.height), (3, 4));
370
371 let top_left = &out[0..3];
374 assert_eq!(top_left, [0, 2, 0], "expected input (0,2) at output (0,0)");
375 }
376
377 #[test]
378 fn half_turn_is_flip_composed_with_flop() {
379 let (desc, bytes) = coded(6, 5);
380 let (_, rotated) = apply(&Rotate::new(Quarter::Half), &desc, &bytes).unwrap();
381 let (mid_desc, flipped) = apply(&crate::Flip, &desc, &bytes).unwrap();
382 let (_, both) = apply(&crate::Flop, &mid_desc, &flipped).unwrap();
383 assert_eq!(rotated, both, "180 degrees is not flip then flop");
384 }
385
386 #[test]
387 fn the_output_is_independent_of_how_the_image_is_tiled() {
388 for quarter in [
392 Quarter::None,
393 Quarter::Clockwise90,
394 Quarter::Half,
395 Quarter::Clockwise270,
396 ] {
397 let (desc, bytes) = coded(13, 11);
398 let op = Rotate::new(quarter);
399 let (out_desc, whole) = apply(&op, &desc, &bytes).unwrap();
400 let source = TileBuf::from_vec(desc.region(), desc.pixel, bytes.clone()).unwrap();
401 let mut target = TileBuf::for_image(&out_desc).unwrap();
402
403 for (tw, th) in [(4_u32, 4_u32), (1, 11), (13, 1), (5, 3)] {
404 let mut y = 0;
405 while y < out_desc.height {
406 let h = th.min(out_desc.height - y);
407 let mut x = 0;
408 while x < out_desc.width {
409 let w = tw.min(out_desc.width - x);
410 let region = Region::new(x, y, w, h);
411 let demand = op
412 .input_regions(region, std::slice::from_ref(&desc))
413 .unwrap();
414 let mut cut = TileBuf::zeroed(demand[0], desc.pixel).unwrap();
415 otf_pixels_core::copy_region(
416 &source.as_tile().unwrap(),
417 &mut cut.as_tile_mut().unwrap(),
418 demand[0],
419 )
420 .unwrap();
421 let mut sub = TileBuf::zeroed(region, out_desc.pixel).unwrap();
422 op.compute(&[cut.as_tile().unwrap()], &mut sub.as_tile_mut().unwrap())
423 .unwrap();
424 otf_pixels_core::copy_region(
425 &sub.as_tile().unwrap(),
426 &mut target.as_tile_mut().unwrap(),
427 region,
428 )
429 .unwrap();
430 x += w;
431 }
432 y += h;
433 }
434 assert_eq!(
435 target.bytes(),
436 whole.as_slice(),
437 "{quarter:?} at {tw}x{th} tiles differed from the whole-image result"
438 );
439 }
440 }
441 }
442
443 #[test]
444 fn demand_never_reaches_outside_the_input() {
445 let input = ImageDescriptor::new(17, 9, PixelFormat::Rgb8).unwrap();
446 for quarter in [
447 Quarter::None,
448 Quarter::Clockwise90,
449 Quarter::Half,
450 Quarter::Clockwise270,
451 ] {
452 let op = Rotate::new(quarter);
453 let out = op.output_descriptor(std::slice::from_ref(&input)).unwrap();
454 for y in 0..out.height {
455 for x in 0..out.width {
456 let demand = op
457 .input_regions(Region::new(x, y, 1, 1), std::slice::from_ref(&input))
458 .unwrap();
459 let r = demand[0];
460 assert!(
461 r.x + r.width <= input.width && r.y + r.height <= input.height,
462 "{quarter:?} demand {r} leaves a {}x{} input",
463 input.width,
464 input.height
465 );
466 }
467 }
468 }
469 }
470
471 #[test]
472 fn rotation_works_for_every_pixel_format() {
473 for &format in PixelFormat::ALL {
476 let descriptor = ImageDescriptor::new(5, 3, format).unwrap();
477 let len = descriptor.byte_len().unwrap();
478 let bytes: Vec<u8> = (0..len).map(|i| (i % 253) as u8).collect();
479 let op = Rotate::new(Quarter::Clockwise90);
480 let (mid_desc, mid) = apply(&op, &descriptor, &bytes).unwrap();
481 let (_, back) = apply(&Rotate::new(Quarter::Clockwise270), &mid_desc, &mid).unwrap();
482 assert_eq!(
483 back, bytes,
484 "{format} did not round-trip through a rotation"
485 );
486 }
487 }
488}