1#![allow(clippy::cast_sign_loss)]
4#![allow(clippy::cast_possible_wrap)]
5#![allow(clippy::missing_panics_doc)]
6
7use crate::image::ImageView;
8use bumpalo::Bump;
9use bumpalo::collections::Vec as BumpVec;
10
11#[derive(Clone, Copy, Default)]
13pub struct Gradient {
14 pub gx: i16,
16 pub gy: i16,
18 pub mag: u16,
20}
21
22use multiversion::multiversion;
23
24#[must_use]
27#[multiversion(targets(
28 "x86_64+avx2+bmi1+bmi2+popcnt+lzcnt",
29 "x86_64+avx512f+avx512bw+avx512dq+avx512vl",
30 "aarch64+neon"
31))]
32pub fn compute_sobel(img: &ImageView) -> Vec<Gradient> {
33 let w = img.width;
34 let h = img.height;
35 let mut grads = vec![Gradient::default(); w * h];
36
37 for y in 1..h - 1 {
42 for x in 1..w - 1 {
43 let p00 = i16::from(img.get_pixel(x - 1, y - 1));
44 let p10 = i16::from(img.get_pixel(x, y - 1));
45 let p20 = i16::from(img.get_pixel(x + 1, y - 1));
46 let p01 = i16::from(img.get_pixel(x - 1, y));
47 let p21 = i16::from(img.get_pixel(x + 1, y));
48 let p02 = i16::from(img.get_pixel(x - 1, y + 1));
49 let p12 = i16::from(img.get_pixel(x, y + 1));
50 let p22 = i16::from(img.get_pixel(x + 1, y + 1));
51
52 let gx = -p00 + p20 - 2 * p01 + 2 * p21 - p02 + p22;
53 let gy = -p00 - 2 * p10 - p20 + p02 + 2 * p12 + p22;
54
55 let mag = ((gx.abs() + gy.abs()) as u16).min(1000);
56
57 grads[y * w + x] = Gradient { gx, gy, mag };
58 }
59 }
60
61 grads
62}
63
64#[derive(Clone, Copy, Debug)]
66pub struct LineSegment {
67 pub x0: f32,
69 pub y0: f32,
71 pub x1: f32,
73 pub y1: f32,
75 pub angle: f32,
77}
78
79#[must_use]
82pub fn extract_line_segments(
83 grads: &[Gradient],
84 width: usize,
85 height: usize,
86 mag_thresh: u16,
87) -> Vec<LineSegment> {
88 let mut used = vec![false; width * height];
89 let mut segments = Vec::new();
90
91 for y in 2..height - 2 {
92 for x in 2..width - 2 {
93 let idx = y * width + x;
94 if used[idx] || grads[idx].mag < mag_thresh {
95 continue;
96 }
97
98 let seed_angle = f32::from(grads[idx].gy).atan2(f32::from(grads[idx].gx));
100
101 let mut points: Vec<(usize, usize)> = vec![(x, y)];
102 used[idx] = true;
103
104 let mut changed = true;
106 while changed && points.len() < 500 {
107 changed = false;
108 let (lx, ly) = *points
109 .last()
110 .expect("Points list should not be empty during refinement");
111
112 for dy in -1i32..=1 {
113 for dx in -1i32..=1 {
114 if dx == 0 && dy == 0 {
115 continue;
116 }
117 let nx = (lx as i32 + dx) as usize;
118 let ny = (ly as i32 + dy) as usize;
119 if nx >= width || ny >= height {
120 continue;
121 }
122
123 let nidx = ny * width + nx;
124 if used[nidx] || grads[nidx].mag < mag_thresh {
125 continue;
126 }
127
128 let angle = f32::from(grads[nidx].gy).atan2(f32::from(grads[nidx].gx));
129 let angle_diff = (angle - seed_angle).abs();
130 let angle_diff = angle_diff.min(std::f32::consts::PI - angle_diff);
131
132 if angle_diff < 0.3 {
133 points.push((nx, ny));
135 used[nidx] = true;
136 changed = true;
137 break;
138 }
139 }
140 if changed {
141 break;
142 }
143 }
144 }
145
146 if points.len() >= 10 {
147 let (x0, y0) = points[0];
149 let (x1, y1) = points[points.len() - 1];
150
151 segments.push(LineSegment {
152 x0: x0 as f32,
153 y0: y0 as f32,
154 x1: x1 as f32,
155 y1: y1 as f32,
156 angle: seed_angle,
157 });
158 }
159 }
160 }
161
162 segments
163}
164
165#[must_use]
167pub fn find_first_quad_from_segments(segments: &[LineSegment]) -> Option<[[f32; 2]; 4]> {
168 if segments.len() < 4 {
169 return None;
170 }
171
172 for i in 0..segments.len() {
175 for j in i + 1..segments.len() {
176 for k in j + 1..segments.len() {
177 for l in k + 1..segments.len() {
178 if let Some(corners) =
179 try_form_quad(&segments[i], &segments[j], &segments[k], &segments[l])
180 {
181 return Some(corners);
182 }
183 }
184 }
185 }
186 }
187
188 None
189}
190
191fn try_form_quad(
192 s0: &LineSegment,
193 s1: &LineSegment,
194 s2: &LineSegment,
195 s3: &LineSegment,
196) -> Option<[[f32; 2]; 4]> {
197 let mut segs = [*s0, *s1, *s2, *s3];
199 segs.sort_by(|a, b| a.angle.total_cmp(&b.angle));
200
201 let mut group1 = [None; 2];
202 let mut group2 = [None; 2];
203 let mut g1_idx = 0;
204 let mut g2_idx = 0;
205
206 group1[0] = Some(segs[0]);
207 g1_idx += 1;
208
209 for i in 1..4 {
210 let diff = angle_diff(segs[0].angle, segs[i].angle);
211 if diff < 0.6 {
212 if g1_idx < 2 {
213 group1[g1_idx] = Some(segs[i]);
214 g1_idx += 1;
215 } else {
216 return None;
217 }
218 } else if g2_idx < 2 {
219 group2[g2_idx] = Some(segs[i]);
220 g2_idx += 1;
221 } else {
222 return None;
223 }
224 }
225
226 if g1_idx != 2 || g2_idx != 2 {
227 return None;
228 }
229
230 let g1 = [
231 group1[0].expect("g1[0] exists"),
232 group1[1].expect("g1[1] exists"),
233 ];
234 let g2 = [
235 group2[0].expect("g2[0] exists"),
236 group2[1].expect("g2[1] exists"),
237 ];
238
239 let angle_between_groups = angle_diff(g1[0].angle, g2[0].angle);
241 if (angle_between_groups - std::f32::consts::FRAC_PI_2).abs() > 0.6 {
242 return None;
243 }
244
245 let c0 = line_intersection(&g1[0], &g2[0])?;
247 let c1 = line_intersection(&g1[0], &g2[1])?;
248 let c2 = line_intersection(&g1[1], &g2[1])?;
249 let c3 = line_intersection(&g1[1], &g2[0])?;
250
251 let area = quad_area(&[c0, c1, c2, c3]);
254
255 if area.abs() < 16.0 || area.abs() > 1_000_000.0 {
257 return None;
258 }
259
260 if area > 0.0 {
261 Some([c0, c1, c2, c3]) } else {
263 Some([c0, c3, c2, c1]) }
265}
266
267fn line_intersection(s1: &LineSegment, s2: &LineSegment) -> Option<[f32; 2]> {
268 let dx1 = s1.x1 - s1.x0;
269 let dy1 = s1.y1 - s1.y0;
270 let dx2 = s2.x1 - s2.x0;
271 let dy2 = s2.y1 - s2.y0;
272
273 let denom = dx1 * dy2 - dy1 * dx2;
274 if denom.abs() < 1e-6 {
275 return None; }
277
278 let t = ((s2.x0 - s1.x0) * dy2 - (s2.y0 - s1.y0) * dx2) / denom;
279 let p = [s1.x0 + t * dx1, s1.y0 + t * dy1];
280
281 let dist_sq = (p[0] - s1.x0).powi(2) + (p[1] - s1.y0).powi(2);
284 if dist_sq > 1000.0 * 1000.0 {
285 return None;
286 }
287
288 Some(p)
289}
290
291fn quad_area(corners: &[[f32; 2]; 4]) -> f32 {
292 let mut area = 0.0;
293 for i in 0..4 {
294 let j = (i + 1) % 4;
295 area += corners[i][0] * corners[j][1];
296 area -= corners[j][0] * corners[i][1];
297 }
298 area * 0.5
299}
300
301#[allow(clippy::cast_possible_wrap)]
315#[allow(clippy::cast_sign_loss)]
316#[allow(clippy::similar_names)]
317#[allow(clippy::too_many_arguments)]
318#[must_use]
319struct ComponentBounds {
320 min_x: usize,
321 min_y: usize,
322 max_x: usize,
323 max_y: usize,
324}
325
326struct QuadFitter<'a> {
327 arena: &'a Bump,
328 img: &'a ImageView<'a>,
329 labels: &'a [u32],
330 label: u32,
331 bounds: ComponentBounds,
332}
333
334impl<'a> QuadFitter<'a> {
335 #[allow(clippy::too_many_arguments)]
336 fn new(
337 arena: &'a Bump,
338 img: &'a ImageView<'a>,
339 labels: &'a [u32],
340 label: u32,
341 min_x: usize,
342 min_y: usize,
343 max_x: usize,
344 max_y: usize,
345 ) -> Self {
346 Self {
347 arena,
348 img,
349 labels,
350 label,
351 bounds: ComponentBounds {
352 min_x,
353 min_y,
354 max_x,
355 max_y,
356 },
357 }
358 }
359
360 #[allow(
361 clippy::cast_possible_wrap,
362 clippy::cast_sign_loss,
363 clippy::similar_names
364 )]
365 #[allow(unsafe_code)]
366 fn collect_boundary_points(&self) -> BumpVec<'a, (usize, usize, f32, f32)> {
367 let width = self.img.width;
368 let height = self.img.height;
369 let x0 = self.bounds.min_x.saturating_sub(1);
370 let y0 = self.bounds.min_y.saturating_sub(1);
371 let x1 = (self.bounds.max_x + 2).min(width);
372 let y1 = (self.bounds.max_y + 2).min(height);
373
374 let mut points = BumpVec::new_in(self.arena);
375
376 for y in y0.max(1)..y1.min(height - 1) {
377 for x in x0.max(1)..x1.min(width - 1) {
378 let idx = y * width + x;
379 if self.labels[idx] != self.label {
380 continue;
381 }
382
383 if self.labels[idx - 1] == self.label
384 && self.labels[idx + 1] == self.label
385 && self.labels[idx - width] == self.label
386 && self.labels[idx + width] == self.label
387 {
388 continue;
389 }
390
391 unsafe {
394 let p00 = i16::from(self.img.get_pixel_unchecked(x - 1, y - 1));
395 let p10 = i16::from(self.img.get_pixel_unchecked(x, y - 1));
396 let p20 = i16::from(self.img.get_pixel_unchecked(x + 1, y - 1));
397 let p01 = i16::from(self.img.get_pixel_unchecked(x - 1, y));
398 let p21 = i16::from(self.img.get_pixel_unchecked(x + 1, y));
399 let p02 = i16::from(self.img.get_pixel_unchecked(x - 1, y + 1));
400 let p12 = i16::from(self.img.get_pixel_unchecked(x, y + 1));
401 let p22 = i16::from(self.img.get_pixel_unchecked(x + 1, y + 1));
402
403 let gx = -p00 + p20 - 2 * p01 + 2 * p21 - p02 + p22;
404 let gy = -p00 - 2 * p10 - p20 + p02 + 2 * p12 + p22;
405 let mag = (gx.abs() + gy.abs()) as u16;
406
407 if mag > 10 {
408 let angle = f32::from(gy).atan2(f32::from(gx));
409 points.push((x, y, angle, f32::from(mag)));
410 }
411 }
412 }
413 }
414 points
415 }
416
417 fn fit(&self) -> Option<[[f32; 2]; 4]> {
418 let boundary_points = self.collect_boundary_points();
419 if boundary_points.len() < 8 {
420 return None;
421 }
422
423 let mut centroids = Self::compute_initial_centroids(&boundary_points);
424 let assignments = self.kmeans_cluster(&boundary_points, &mut centroids);
425 let lines = self.fit_lines(&boundary_points, &assignments);
426
427 if lines.len() < 4 {
428 return None;
429 }
430
431 find_first_quad_from_segments(&lines)
432 }
433
434 #[allow(clippy::similar_names)]
435 fn compute_initial_centroids(boundary_points: &[(usize, usize, f32, f32)]) -> [f32; 4] {
436 let mut weight_sum = 0.0f32;
437 let mut gx_sum = 0.0f32;
438 let mut gy_sum = 0.0f32;
439 let mut gxx_sum = 0.0f32;
440 let mut gyy_sum = 0.0f32;
441 let mut gxy_sum = 0.0f32;
442
443 for &(_x, _y, angle, mag) in boundary_points {
444 let w = mag;
445 let gx = angle.cos();
446 let gy = angle.sin();
447 weight_sum += w;
448 gx_sum += gx * w;
449 gy_sum += gy * w;
450 gxx_sum += gx * gx * w;
451 gyy_sum += gy * gy * w;
452 gxy_sum += gx * gy * w;
453 }
454
455 let mean_gx = gx_sum / weight_sum;
456 let mean_gy = gy_sum / weight_sum;
457 let cov_xx = gxx_sum / weight_sum - mean_gx * mean_gx;
458 let cov_yy = gyy_sum / weight_sum - mean_gy * mean_gy;
459 let cov_xy = gxy_sum / weight_sum - mean_gx * mean_gy;
460
461 let trace = cov_xx + cov_yy;
462 let det = cov_xx * cov_yy - cov_xy * cov_xy;
463 let discriminant = (trace * trace / 4.0 - det).max(0.0);
464 let lambda1 = trace / 2.0 + discriminant.sqrt();
465
466 let theta = if cov_xy.abs() > 1e-6 {
467 (lambda1 - cov_xx).atan2(cov_xy)
468 } else if cov_xx >= cov_yy {
469 0.0
470 } else {
471 std::f32::consts::FRAC_PI_2
472 };
473
474 let mut centroids = [
475 theta,
476 theta + std::f32::consts::FRAC_PI_2,
477 theta + std::f32::consts::PI,
478 theta - std::f32::consts::FRAC_PI_2,
479 ];
480 for c in &mut centroids {
482 while *c > std::f32::consts::PI {
483 *c -= 2.0 * std::f32::consts::PI;
484 }
485 while *c < -std::f32::consts::PI {
486 *c += 2.0 * std::f32::consts::PI;
487 }
488 }
489 centroids
490 }
491
492 fn kmeans_cluster(
493 &self,
494 boundary_points: &[(usize, usize, f32, f32)],
495 centroids: &mut [f32; 4],
496 ) -> BumpVec<'a, usize> {
497 let mut assignments = BumpVec::with_capacity_in(boundary_points.len(), self.arena);
498 assignments.resize(boundary_points.len(), 0);
499
500 for _ in 0..5 {
501 for (i, &(_x, _y, angle, _mag)) in boundary_points.iter().enumerate() {
503 let mut best_cluster = 0;
504 let mut best_dist = f32::MAX;
505 for (c, ¢roid) in centroids.iter().enumerate() {
506 let diff = angle_diff(angle, centroid);
507 if diff < best_dist {
508 best_dist = diff;
509 best_cluster = c;
510 }
511 }
512 assignments[i] = best_cluster;
513 }
514
515 for (c, centroid) in centroids.iter_mut().enumerate() {
517 let mut sum_sin = 0.0f32;
518 let mut sum_cos = 0.0f32;
519 for (i, &(_x, _y, angle, mag)) in boundary_points.iter().enumerate() {
520 if assignments[i] == c {
521 sum_sin += angle.sin() * mag;
522 sum_cos += angle.cos() * mag;
523 }
524 }
525 if sum_sin.abs() > 1e-6 || sum_cos.abs() > 1e-6 {
526 *centroid = sum_sin.atan2(sum_cos);
527 }
528 }
529 }
530 assignments
531 }
532
533 #[allow(clippy::similar_names)]
534 fn fit_lines(
535 &self,
536 boundary_points: &[(usize, usize, f32, f32)],
537 assignments: &[usize],
538 ) -> BumpVec<'a, LineSegment> {
539 let mut lines = BumpVec::new_in(self.arena);
540 for c in 0..4 {
542 let mut cluster_count = 0;
543 let mut sum_w = 0.0f32;
544 let mut sum_wx = 0.0f32;
545 let mut sum_wy = 0.0f32;
546
547 for (i, &(x, y, _, mag)) in boundary_points.iter().enumerate() {
548 if assignments[i] == c {
549 cluster_count += 1;
550 let w = mag * mag;
551 sum_w += w;
552 sum_wx += x as f32 * w;
553 sum_wy += y as f32 * w;
554 }
555 }
556
557 if cluster_count < 3 {
558 continue;
559 }
560
561 let mean_x = sum_wx / sum_w;
562 let mean_y = sum_wy / sum_w;
563
564 let mut cov_xx = 0.0f32;
565 let mut cov_yy = 0.0f32;
566 let mut cov_xy = 0.0f32;
567 for (i, &(x, y, _, mag)) in boundary_points.iter().enumerate() {
568 if assignments[i] == c {
569 let w = mag * mag;
570 let dx = x as f32 - mean_x;
571 let dy = y as f32 - mean_y;
572 cov_xx += dx * dx * w;
573 cov_yy += dy * dy * w;
574 cov_xy += dx * dy * w;
575 }
576 }
577
578 let direction = 0.5 * (2.0 * cov_xy).atan2(cov_xx - cov_yy);
579 let nx = direction.cos();
580 let ny = direction.sin();
581
582 let mut min_t = f32::MAX;
583 let mut max_t = f32::MIN;
584 for (i, &(x, y, _, _)) in boundary_points.iter().enumerate() {
585 if assignments[i] == c {
586 let t = (x as f32 - mean_x) * nx + (y as f32 - mean_y) * ny;
587 min_t = min_t.min(t);
588 max_t = max_t.max(t);
589 }
590 }
591
592 let mut grad_angle = direction + std::f32::consts::FRAC_PI_2;
593 if grad_angle > std::f32::consts::PI {
594 grad_angle -= 2.0 * std::f32::consts::PI;
595 }
596
597 lines.push(LineSegment {
598 x0: mean_x + nx * min_t,
599 y0: mean_y + ny * min_t,
600 x1: mean_x + nx * max_t,
601 y1: mean_y + ny * max_t,
602 angle: grad_angle,
603 });
604 }
605 lines
606 }
607}
608
609#[allow(clippy::too_many_arguments)]
611#[must_use]
612pub fn fit_quad_from_component(
613 arena: &Bump,
614 img: &ImageView,
615 labels: &[u32],
616 label: u32,
617 min_x: usize,
618 min_y: usize,
619 max_x: usize,
620 max_y: usize,
621) -> Option<[[f32; 2]; 4]> {
622 QuadFitter::new(arena, img, labels, label, min_x, min_y, max_x, max_y).fit()
623}
624
625#[allow(clippy::needless_range_loop)]
631fn solve_quad_from_boundary_points(
632 boundary_points: &[(f32, f32, f32)], _img_width: usize, min_pixels: usize,
635) -> Option<[[f32; 2]; 4]> {
636 let mut centroids = [
639 0.0f32,
640 std::f32::consts::FRAC_PI_2,
641 std::f32::consts::PI,
642 -std::f32::consts::FRAC_PI_2,
643 ];
644 let mut assignments = vec![0usize; boundary_points.len()];
645
646 for _ in 0..5 {
647 for (i, (_x, _y, angle)) in boundary_points.iter().enumerate() {
650 let mut best_cluster = 0;
651 let mut best_dist = f32::MAX;
652 for (c, ¢roid) in centroids.iter().enumerate() {
653 let diff = angle_diff(*angle, centroid);
654 if diff < best_dist {
655 best_dist = diff;
656 best_cluster = c;
657 }
658 }
659 assignments[i] = best_cluster;
660 }
661
662 for c in 0..4 {
664 let mut sum_sin = 0.0f32;
665 let mut sum_cos = 0.0f32;
666 for (i, (_x, _y, angle)) in boundary_points.iter().enumerate() {
667 if assignments[i] == c {
668 sum_sin += angle.sin();
669 sum_cos += angle.cos();
670 }
671 }
672 if sum_sin.abs() > 1e-6 || sum_cos.abs() > 1e-6 {
673 centroids[c] = sum_sin.atan2(sum_cos);
674 }
675 }
676 }
677
678 let mut lines = [LineSegment {
680 x0: 0.0,
681 y0: 0.0,
682 x1: 0.0,
683 y1: 0.0,
684 angle: 0.0,
685 }; 4];
686
687 for c in 0..4 {
689 let mut sum_x = 0.0f32;
692 let mut sum_y = 0.0f32;
693 let mut count = 0usize;
694
695 for (i, (x, y, _)) in boundary_points.iter().enumerate() {
698 if assignments[i] == c {
699 sum_x += *x;
700 sum_y += *y;
701 count += 1;
702 }
703 }
704
705 if count < min_pixels {
706 return None;
707 }
708
709 let mean_x = sum_x / count as f32;
710 let mean_y = sum_y / count as f32;
711
712 let angle = centroids[c];
715 let nx = angle.cos();
716 let ny = angle.sin();
717
718 lines[c] = LineSegment {
721 x0: mean_x - nx * 100.0,
722 y0: mean_y - ny * 100.0,
723 x1: mean_x + nx * 100.0,
724 y1: mean_y + ny * 100.0,
725 angle,
726 };
727 }
728
729 let tl = line_intersection(&lines[2], &lines[3])?;
739 let tr = line_intersection(&lines[3], &lines[0])?;
740 let br = line_intersection(&lines[0], &lines[1])?;
741 let bl = line_intersection(&lines[1], &lines[2])?;
742
743 let corners = [tl, tr, br, bl];
744
745 let area = quad_area(&corners);
747 if !(16.0..=1_000_000.0).contains(&area) {
748 return None;
749 }
750
751 Some(corners)
752}
753
754#[allow(clippy::cast_possible_wrap)]
756#[allow(clippy::cast_sign_loss)]
757#[allow(clippy::similar_names)]
758#[must_use]
759pub fn fit_quad_from_gradients(
760 grads: &[Gradient],
761 labels: &[u32],
762 label: u32,
763 width: usize,
764 height: usize,
765 min_edge_pixels: usize,
766) -> Option<[[f32; 2]; 4]> {
767 let mut boundary_points: Vec<(f32, f32, f32)> = Vec::with_capacity(min_edge_pixels * 4); for y in 1..height - 1 {
771 for x in 1..width - 1 {
772 let idx = y * width + x;
773 if labels[idx] != label {
774 continue;
775 }
776
777 let is_boundary = labels[idx - 1] != label
779 || labels[idx + 1] != label
780 || labels[idx - width] != label
781 || labels[idx + width] != label;
782
783 if is_boundary && grads[idx].mag > 20 {
784 let angle = f32::from(grads[idx].gy).atan2(f32::from(grads[idx].gx));
785 boundary_points.push((x as f32, y as f32, angle));
786 }
787 }
788 }
789
790 if boundary_points.len() < min_edge_pixels * 4 {
791 return None; }
793
794 solve_quad_from_boundary_points(&boundary_points, width, min_edge_pixels)
795}
796
797fn angle_diff(a: f32, b: f32) -> f32 {
799 let diff = (a - b).abs();
800 let diff = diff % std::f32::consts::PI;
801 diff.min(std::f32::consts::PI - diff)
802}
803
804#[cfg(test)]
805#[allow(clippy::unwrap_used, clippy::float_cmp)]
806mod tests {
807 use super::*;
808 use crate::image::ImageView;
809 use proptest::prelude::*;
810
811 proptest! {
812 #[test]
813 fn prop_sobel_magnitude_bounds(
814 width in 3..64usize,
815 height in 3..64usize,
816 data in prop::collection::vec(0..=255u8, 64*64)
817 ) {
818 let slice = &data[..width * height];
819 let view = ImageView::new(slice, width, height, width).unwrap();
820 let grads = compute_sobel(&view);
821
822 for g in grads {
823 assert!(g.mag <= 1000);
824 }
825 }
826
827 #[test]
828 fn prop_sobel_orientation_ramp(
829 width in 3..10usize,
830 height in 3..10usize,
831 is_horizontal in prop::bool::ANY
832 ) {
833 let mut data = vec![0u8; width * height];
834 for y in 0..height {
835 for x in 0..width {
836 data[y * width + x] = if is_horizontal { x as u8 * 10 } else { y as u8 * 10 };
837 }
838 }
839
840 let view = ImageView::new(&data, width, height, width).unwrap();
841 let grads = compute_sobel(&view);
842
843 for y in 1..height-1 {
845 for x in 1..width-1 {
846 let g = grads[y * width + x];
847 if is_horizontal {
848 assert!(g.gx > 0);
849 assert_eq!(g.gy, 0);
850 } else {
851 assert_eq!(g.gx, 0);
852 assert!(g.gy > 0);
853 }
854 }
855 }
856 }
857
858 #[test]
859 fn prop_quad_area_invariants(
860 c in prop::collection::vec((0.0..100.0f32, 0.0..100.0f32), 4)
861 ) {
862 let corners = [
863 [c[0].0, c[0].1],
864 [c[1].0, c[1].1],
865 [c[2].0, c[2].1],
866 [c[3].0, c[3].1],
867 ];
868 let area = quad_area(&corners);
869
870 let mut corners_rev = corners;
874 corners_rev.reverse();
875 let area_rev = quad_area(&corners_rev);
876
877 assert!((area + area_rev).abs() < 0.01);
878
879 let identical_corners = [[0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0]];
881 assert_eq!(quad_area(&identical_corners), 0.0);
882 }
883 }
884}