1use super::parser::{ParsedBoard, assert_width_normalized};
15use crate::{Tessellation, TileKind};
16
17#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
19pub enum MirrorAxis {
20 Horizontal,
22
23 Vertical,
25}
26
27#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
29pub enum Rotation {
30 Clockwise,
31 CounterClockwise,
32}
33
34#[derive(Debug, Copy, Clone, PartialEq, Eq)]
35pub(crate) enum BoardTransform {
36 MirrorHorizontal,
37 MirrorVertical,
38 RotateClockwise,
39 RotateCounterClockwise,
40}
41
42fn to_i64(value: usize) -> i64 {
43 i64::try_from(value).unwrap_or(i64::MAX)
45}
46
47fn to_usize(value: i64) -> usize {
48 usize::try_from(value).unwrap_or(0)
50}
51
52fn raw_bounds(
53 body: &ParsedBoard, tessellation: Tessellation, transform: BoardTransform,
54) -> (i64, i64, i64, i64) {
55 let mut min_x = i64::MAX;
56 let mut min_y = i64::MAX;
57 let mut max_x = i64::MIN;
58 let mut max_y = i64::MIN;
59
60 for (y, row) in body.iter().enumerate() {
61 for x in 0..row.len() {
62 let (mapped_x, mapped_y) =
63 tessellation.transformed_raw(transform, to_i64(x), to_i64(y));
64 min_x = min_x.min(mapped_x);
65 min_y = min_y.min(mapped_y);
66 max_x = max_x.max(mapped_x);
67 max_y = max_y.max(mapped_y);
68 }
69 }
70
71 (min_x, min_y, max_x, max_y)
72}
73
74fn anchored_size(max_x: i64, max_y: i64, dx: i64, dy: i64) -> (usize, usize) {
75 (to_usize(max_x + dx + 1), to_usize(max_y + dy + 1))
76}
77
78fn best_anchor(
79 tessellation: Tessellation, min_x: i64, min_y: i64, max_x: i64, max_y: i64,
80) -> (i64, i64, usize, usize) {
81 let tight_dx = -min_x;
82 let tight_dy = -min_y;
83 let mut best: Option<(i64, i64, usize, usize, usize)> = None;
84
85 for (extra_x, extra_y) in [(0_i64, 0_i64), (1, 0), (0, 1), (1, 1)] {
86 let dx = tight_dx + extra_x;
87 let dy = tight_dy + extra_y;
88 if !tessellation.is_rigid_translation(dx, dy) {
89 continue;
90 }
91
92 let (width, height) = anchored_size(max_x, max_y, dx, dy);
93 let area = width.saturating_mul(height);
94 if best.is_none_or(|(_, _, _, _, best_area)| area < best_area) {
95 best = Some((dx, dy, width, height, area));
96 }
97 }
98
99 best.map_or((tight_dx, tight_dy, 0, 0), |(dx, dy, width, height, _)| {
102 (dx, dy, width, height)
103 })
104}
105
106pub(super) fn transformed(
107 body: &ParsedBoard, tessellation: Tessellation, transform: BoardTransform,
108) -> ParsedBoard {
109 assert_width_normalized(body);
110 if body.is_empty() {
111 return ParsedBoard::new();
112 }
113
114 let (min_x, min_y, max_x, max_y) = raw_bounds(body, tessellation, transform);
115 let (dx, dy, width, height) = best_anchor(tessellation, min_x, min_y, max_x, max_y);
116 let mut transformed = vec![vec![TileKind::Floor; width]; height];
117
118 for (y, row) in body.iter().enumerate() {
119 for (x, tile) in row.iter().enumerate() {
120 let (mapped_x, mapped_y) =
121 tessellation.transformed_raw(transform, to_i64(x), to_i64(y));
122 transformed[to_usize(mapped_y + dy)][to_usize(mapped_x + dx)] = *tile;
123 }
124 }
125
126 transformed
127}
128
129#[cfg(test)]
130mod tests {
131 use std::collections::{HashMap, HashSet};
132
133 use super::*;
134
135 const TESSELLATIONS: [Tessellation; 4] = [
136 Tessellation::Sokoban,
137 Tessellation::Hexoban,
138 Tessellation::Trioban,
139 Tessellation::Octoban,
140 ];
141 const TRANSFORMS: [BoardTransform; 4] = [
142 BoardTransform::MirrorHorizontal,
143 BoardTransform::MirrorVertical,
144 BoardTransform::RotateClockwise,
145 BoardTransform::RotateCounterClockwise,
146 ];
147 const SIZES: [(usize, usize); 6] = [(4, 4), (4, 5), (5, 4), (5, 5), (7, 3), (1, 5)];
148
149 type Position = (usize, usize);
150 type PositionMap = HashMap<Position, Position>;
151
152 fn mapping(
153 tessellation: Tessellation, transform: BoardTransform, width: usize, height: usize,
154 ) -> (Position, PositionMap) {
155 let body = vec![vec![TileKind::Floor; width]; height];
156 let (min_x, min_y, max_x, max_y) = raw_bounds(&body, tessellation, transform);
157 let (dx, dy, transformed_width, transformed_height) =
158 best_anchor(tessellation, min_x, min_y, max_x, max_y);
159 let positions = (0..height)
160 .flat_map(|y| {
161 (0..width).map(move |x| {
162 let (mapped_x, mapped_y) =
163 tessellation.transformed_raw(transform, to_i64(x), to_i64(y));
164 ((x, y), (to_usize(mapped_x + dx), to_usize(mapped_y + dy)))
165 })
166 })
167 .collect();
168 ((transformed_width, transformed_height), positions)
169 }
170
171 fn neighbors(
172 tessellation: Tessellation, position: (usize, usize), width: usize, height: usize,
173 ) -> HashSet<(usize, usize)> {
174 tessellation
175 .known_directions()
176 .iter()
177 .filter_map(|direction| {
178 tessellation.neighbor(position.0, position.1, *direction, width, height)
179 })
180 .collect()
181 }
182
183 mod test_anchoring {
193 use super::*;
194
195 type SizeExpectation = (Tessellation, Position, [Position; 4]);
197
198 #[rustfmt::skip]
199 #[test]
200 fn it_anchors_to_the_smallest_area() {
201 let expectations: [SizeExpectation; 8] = [
203 (Tessellation::Sokoban, (5, 4), [(5, 4), (5, 4), (4, 5), (4, 5)]),
204 (Tessellation::Sokoban, (4, 3), [(4, 3), (4, 3), (3, 4), (3, 4)]),
205 (Tessellation::Hexoban, (4, 4), [(4, 5), (5, 4), (4, 6), (5, 6)]),
206 (Tessellation::Hexoban, (4, 3), [(4, 3), (5, 3), (4, 5), (4, 6)]),
207 (Tessellation::Trioban, (4, 4), [(4, 4), (5, 4), (7, 4), (7, 4)]),
208 (Tessellation::Trioban, (5, 5), [(6, 5), (5, 5), (10, 5), (10, 5)]),
209 (Tessellation::Octoban, (5, 4), [(6, 4), (5, 4), (4, 6), (4, 5)]),
210 (Tessellation::Octoban, (4, 3), [(4, 3), (5, 3), (3, 4), (3, 5)]),
211 ];
212
213 for (tessellation, (width, height), wanted) in expectations {
214 for (transform, want) in TRANSFORMS.iter().zip(wanted) {
215 let (size, _) = mapping(tessellation, *transform, width, height);
216 assert_eq!(
217 size, want,
218 "{tessellation:?} {width}x{height} under {transform:?}"
219 );
220 }
221 }
222 }
223
224 #[test]
229 fn it_breaks_an_exact_area_tie_by_adding_a_column() {
230 let ties = [
231 (
232 Tessellation::Octoban,
233 (2, 2),
234 BoardTransform::MirrorHorizontal,
235 (3, 2),
236 ),
237 (Tessellation::Octoban, (2, 2), BoardTransform::RotateClockwise, (3, 2)),
238 (
239 Tessellation::Octoban,
240 (4, 4),
241 BoardTransform::MirrorHorizontal,
242 (5, 4),
243 ),
244 (
245 Tessellation::Trioban,
246 (3, 3),
247 BoardTransform::MirrorHorizontal,
248 (4, 3),
249 ),
250 (
251 Tessellation::Trioban,
252 (5, 5),
253 BoardTransform::MirrorHorizontal,
254 (6, 5),
255 ),
256 (Tessellation::Trioban, (4, 4), BoardTransform::MirrorVertical, (5, 4)),
257 ];
258
259 for (tessellation, (width, height), transform, wanted) in ties {
260 let (size, _) = mapping(tessellation, transform, width, height);
261 assert_eq!(size, wanted, "{tessellation:?} {width}x{height} under {transform:?}");
262
263 let body = vec![vec![TileKind::Floor; width]; height];
266 let (min_x, min_y, max_x, max_y) = raw_bounds(&body, tessellation, transform);
267 let column = anchored_size(max_x, max_y, -min_x + 1, -min_y);
268 let row = anchored_size(max_x, max_y, -min_x, -min_y + 1);
269
270 assert!(tessellation.is_rigid_translation(-min_x + 1, -min_y));
271 assert!(tessellation.is_rigid_translation(-min_x, -min_y + 1));
272 assert_eq!(column, wanted);
273 assert_eq!(
274 column.0 * column.1,
275 row.0 * row.1,
276 "{tessellation:?} {width}x{height} {transform:?}: {column:?} vs {row:?} \
277 should tie on area"
278 );
279 }
280 }
281
282 #[test]
286 fn it_leaves_padding_cells_with_no_preimage() {
287 let expectations = [
288 (
290 Tessellation::Trioban,
291 (4, 3),
292 BoardTransform::MirrorHorizontal,
293 vec![(0, 0), (0, 1), (0, 2)],
294 ),
295 (
298 Tessellation::Hexoban,
299 (4, 3),
300 BoardTransform::MirrorVertical,
301 vec![(0, 0), (4, 1), (0, 2)],
302 ),
303 ];
304
305 for (tessellation, (width, height), transform, wanted) in expectations {
306 let ((transformed_width, transformed_height), positions) =
307 mapping(tessellation, transform, width, height);
308 let covered: HashSet<Position> = positions.values().copied().collect();
309
310 let holes: Vec<Position> = (0..transformed_height)
311 .flat_map(|y| (0..transformed_width).map(move |x| (x, y)))
312 .filter(|position| !covered.contains(position))
313 .collect();
314
315 assert_eq!(holes, wanted, "{tessellation:?} {width}x{height} under {transform:?}");
316 }
317 }
318 }
319
320 #[test]
321 fn every_transform_is_injective_and_preserves_adjacency() {
322 for tessellation in TESSELLATIONS {
323 for (width, height) in SIZES {
324 for transform in TRANSFORMS {
325 let ((new_width, new_height), positions) =
326 mapping(tessellation, transform, width, height);
327 let image: HashSet<_> = positions.values().copied().collect();
328 assert_eq!(
329 image.len(),
330 width * height,
331 "{tessellation:?} {width}x{height} {transform:?} is not injective"
332 );
333
334 for (source, destination) in &positions {
335 let mapped_neighbors: HashSet<_> =
336 neighbors(tessellation, *source, width, height)
337 .iter()
338 .map(|neighbor| positions[neighbor])
339 .collect();
340 let destination_neighbors: HashSet<_> =
341 neighbors(tessellation, *destination, new_width, new_height)
342 .intersection(&image)
343 .copied()
344 .collect();
345
346 assert_eq!(
347 mapped_neighbors, destination_neighbors,
348 "{tessellation:?} {width}x{height} {transform:?}: {source:?} -> \
349 {destination:?}"
350 );
351 }
352 }
353 }
354 }
355 }
356
357 #[test]
358 fn every_transform_preserves_the_multiset_of_non_floor_tiles() {
359 const MARKERS: [TileKind; 6] = [
360 TileKind::Wall,
361 TileKind::Pusher,
362 TileKind::Box,
363 TileKind::Goal,
364 TileKind::BoxOnGoal,
365 TileKind::PusherOnGoal,
366 ];
367
368 for tessellation in TESSELLATIONS {
369 for (width, height) in SIZES {
370 let body: ParsedBoard = (0..height)
371 .map(|y| {
372 (0..width)
373 .map(|x| MARKERS[(y * width + x) % MARKERS.len()])
374 .collect()
375 })
376 .collect();
377 for transform in TRANSFORMS {
378 let result = transformed(&body, tessellation, transform);
379 let actual = MARKERS.map(|marker| {
380 result
381 .iter()
382 .flatten()
383 .filter(|&&tile| tile == marker)
384 .count()
385 });
386 let expected = MARKERS.map(|marker| {
387 body.iter()
388 .flatten()
389 .filter(|&&tile| tile == marker)
390 .count()
391 });
392 assert_eq!(actual, expected, "{tessellation:?} {width}x{height} {transform:?}");
393 }
394 }
395 }
396 }
397
398 #[test]
399 fn empty_boards_are_unchanged_by_every_transform() {
400 for tessellation in TESSELLATIONS {
401 for transform in TRANSFORMS {
402 assert!(transformed(&ParsedBoard::new(), tessellation, transform).is_empty());
403 }
404 }
405 }
406}