nitai/projects
noxstrap / src / crates / nox-gfx / tests / correctness.rs
696 lines · 23.4 KB Raw
1use nox_gfx::canvas::{Canvas, Paint, Stroke, TOL};
2use nox_gfx::color::{blend_over, lerp_premul, rgba, scale_premul, Color};
3use nox_gfx::geom::{pt, Point, Rect, Transform, IDENTITY};
4use nox_gfx::path::Path;
5use nox_gfx::raster::Rasterizer;
6
7fn unpack(v: u32) -> [f64; 4] {
8 [
9 ((v >> 24) & 0xff) as f64 / 255.0,
10 ((v >> 16) & 0xff) as f64 / 255.0,
11 ((v >> 8) & 0xff) as f64 / 255.0,
12 (v & 0xff) as f64 / 255.0,
13 ]
14}
15
16fn chan(v: u32, sh: u32) -> i32 {
17 ((v >> sh) & 0xff) as i32
18}
19
20fn assert_close(got: u32, want: [f64; 4], tol: i32, what: &str) {
21 for (i, sh) in [24u32, 16, 8, 0].iter().enumerate() {
22 let g = chan(got, *sh);
23 let w = (want[i] * 255.0).round() as i32;
24 assert!(
25 (g - w).abs() <= tol,
26 "{what}: channel {i} got {g} want {w} (got 0x{got:08x})"
27 );
28 }
29}
30
31#[test]
32fn blend_over_matches_float_reference() {
33 let mut cases = Vec::new();
34 for a in [0u32, 1, 7, 64, 128, 200, 254, 255] {
35 for c in [0u32, 1, 33, 128, 254, 255] {
36 let c = c.min(a);
37 cases.push((a << 24) | (c << 16) | (c << 8) | c);
38 }
39 }
40 for &dst in &cases {
41 for &src in &cases {
42 let got = blend_over(dst, src);
43 let d = unpack(dst);
44 let s = unpack(src);
45 let inv = 1.0 - s[0];
46 let want = [
47 s[0] + d[0] * inv,
48 s[1] + d[1] * inv,
49 s[2] + d[2] * inv,
50 s[3] + d[3] * inv,
51 ];
52 assert_close(got, want, 1, "blend_over");
53 }
54 }
55}
56
57#[test]
58fn scale_premul_matches_float_reference() {
59 for v in [0u32, 0x8040_2010, 0xffff_ffff, 0xff00_0000, 0x7f3f_1f0f] {
60 for f in 0u32..=255 {
61 let got = scale_premul(v, f);
62 let a = unpack(v);
63 let k = f as f64 / 255.0;
64 assert_close(got, [a[0] * k, a[1] * k, a[2] * k, a[3] * k], 1, "scale_premul");
65 }
66 }
67}
68
69#[test]
70fn lerp_premul_matches_float_reference() {
71 for a in [0u32, 0xff00_0000, 0xffff_ffff, 0x8040_2010] {
72 for b in [0u32, 0xff80_4020, 0xffff_ffff, 0x40201008] {
73 for t in [0u32, 1, 63, 128, 200, 254, 255] {
74 let got = lerp_premul(a, b, t);
75 let x = unpack(a);
76 let y = unpack(b);
77 let k = t as f64 / 255.0;
78 let want = [
79 x[0] + (y[0] - x[0]) * k,
80 x[1] + (y[1] - x[1]) * k,
81 x[2] + (y[2] - x[2]) * k,
82 x[3] + (y[3] - x[3]) * k,
83 ];
84 assert_close(got, want, 2, "lerp_premul");
85 }
86 }
87 }
88}
89
90#[test]
91fn premul_roundtrip_is_stable() {
92 for a in [255u8, 254, 200, 128, 64, 8, 1] {
93 for c in [0u8, 1, 50, 128, 200, 255] {
94 let orig = Color { r: c, g: c / 2, b: 255 - c, a };
95 let back = Color::from_premul(orig.premul());
96 assert_eq!(back.a, orig.a);
97 let d = |x: u8, y: u8| (x as i32 - y as i32).abs();
98 let slack = if a >= 128 { 2 } else { 255 / a as i32 + 1 };
99 assert!(d(back.r, orig.r) <= slack, "r {:?} -> {:?}", orig, back);
100 assert!(d(back.g, orig.g) <= slack, "g {:?} -> {:?}", orig, back);
101 assert!(d(back.b, orig.b) <= slack, "b {:?} -> {:?}", orig, back);
102 }
103 }
104}
105
106#[test]
107fn opaque_over_anything_is_identity() {
108 for dst in [0u32, 0x1234_5678, 0xffff_ffff] {
109 for src in [0xff00_0000u32, 0xffff_ffff, 0xff12_3456] {
110 assert_eq!(blend_over(dst, src), src);
111 }
112 }
113}
114
115#[test]
116fn transparent_over_anything_is_noop() {
117 for dst in [0u32, 0x1234_5678, 0xffff_ffff] {
118 assert_eq!(blend_over(dst, 0), dst);
119 }
120}
121
122fn winding_at(segs: &[(Point, Point)], x: f32, y: f32) -> i32 {
123 let mut w = 0;
124 for (a, b) in segs {
125 if a.y <= y {
126 if b.y > y && (b.x - a.x) * (y - a.y) - (x - a.x) * (b.y - a.y) > 0.0 {
127 w += 1;
128 }
129 } else if b.y <= y && (b.x - a.x) * (y - a.y) - (x - a.x) * (b.y - a.y) < 0.0 {
130 w -= 1;
131 }
132 }
133 w
134}
135
136fn brute_force_coverage(path: &Path, w: usize, h: usize, ss: usize) -> Vec<f32> {
137 let mut segs = Vec::new();
138 path.flatten(&IDENTITY, TOL, |a, b| segs.push((a, b)));
139 let mut out = vec![0.0f32; w * h];
140 let step = 1.0 / ss as f32;
141 for y in 0..h {
142 for x in 0..w {
143 let mut hits = 0;
144 for sy in 0..ss {
145 for sx in 0..ss {
146 let px = x as f32 + (sx as f32 + 0.5) * step;
147 let py = y as f32 + (sy as f32 + 0.5) * step;
148 if winding_at(&segs, px, py) != 0 {
149 hits += 1;
150 }
151 }
152 }
153 out[y * w + x] = hits as f32 / (ss * ss) as f32;
154 }
155 }
156 out
157}
158
159fn rasterize(path: &Path, w: usize, h: usize) -> Vec<f32> {
160 let mut ras = Rasterizer::new(w, h);
161 path.flatten(&IDENTITY, TOL, |a, b| ras.line(a, b));
162 let mut out = vec![0.0f32; w * h];
163 ras.spans(|x, y, c| out[y * w + x] = c);
164 out
165}
166
167fn compare_coverage(path: &Path, w: usize, h: usize, name: &str, max_tol: f32, mean_tol: f32) {
168 let got = rasterize(path, w, h);
169 let want = brute_force_coverage(path, w, h, 16);
170 let mut worst = 0.0f32;
171 let mut sum = 0.0f64;
172 let mut worst_at = (0usize, 0usize);
173 for y in 0..h {
174 for x in 0..w {
175 let d = (got[y * w + x] - want[y * w + x]).abs();
176 sum += d as f64;
177 if d > worst {
178 worst = d;
179 worst_at = (x, y);
180 }
181 }
182 }
183 let mean = sum / (w * h) as f64;
184 assert!(
185 worst <= max_tol,
186 "{name}: worst coverage error {worst:.4} at {worst_at:?} (limit {max_tol})"
187 );
188 assert!(mean <= mean_tol as f64, "{name}: mean coverage error {mean:.5} (limit {mean_tol})");
189}
190
191#[test]
192fn raster_matches_supersampled_rect() {
193 let mut p = Path::new();
194 p.rect(Rect::new(3.25, 2.6, 28.75, 21.4));
195 compare_coverage(&p, 32, 24, "rect", 0.04, 0.002);
196}
197
198#[test]
199fn raster_matches_supersampled_circle() {
200 let mut p = Path::new();
201 p.circle(pt(24.3, 20.7), 17.6);
202 compare_coverage(&p, 48, 42, "circle", 0.04, 0.003);
203}
204
205#[test]
206fn raster_matches_supersampled_rrect() {
207 let mut p = Path::new();
208 p.rrect(Rect::new(2.5, 3.5, 45.5, 30.5), 9.0);
209 compare_coverage(&p, 48, 34, "rrect", 0.04, 0.003);
210}
211
212#[test]
213fn raster_matches_supersampled_triangle() {
214 let mut p = Path::new();
215 p.move_to(pt(2.0, 30.0));
216 p.line_to(pt(31.0, 25.0));
217 p.line_to(pt(17.5, 1.5));
218 p.close();
219 compare_coverage(&p, 34, 32, "triangle", 0.04, 0.003);
220}
221
222#[test]
223fn raster_matches_supersampled_star() {
224 let mut p = Path::new();
225 let c = pt(30.0, 30.0);
226 for i in 0..10 {
227 let a = -core::f32::consts::FRAC_PI_2 + i as f32 * core::f32::consts::PI / 5.0;
228 let r = if i % 2 == 0 { 27.0 } else { 11.0 };
229 let q = pt(c.x + r * a.cos(), c.y + r * a.sin());
230 if i == 0 {
231 p.move_to(q);
232 } else {
233 p.line_to(q);
234 }
235 }
236 p.close();
237 compare_coverage(&p, 60, 60, "star", 0.05, 0.004);
238}
239
240#[test]
241fn shape_fully_outside_canvas_draws_nothing() {
242 let mut c = Canvas::new(16, 16);
243 c.clear(rgba(0, 0, 0, 255));
244 let before = c.px.clone();
245 let mut p = Path::new();
246 p.rect(Rect::new(-500.0, -500.0, -100.0, -100.0));
247 c.fill(&p, &IDENTITY, &Paint::Solid(rgba(255, 255, 255, 255)));
248 p.clear();
249 p.rect(Rect::new(900.0, 900.0, 1200.0, 1200.0));
250 c.fill(&p, &IDENTITY, &Paint::Solid(rgba(255, 255, 255, 255)));
251 assert_eq!(before, c.px);
252}
253
254#[test]
255fn shape_larger_than_canvas_fills_everything() {
256 let mut c = Canvas::new(24, 18);
257 c.clear(rgba(0, 0, 0, 255));
258 let mut p = Path::new();
259 p.rect(Rect::new(-40.0, -40.0, 80.0, 80.0));
260 c.fill(&p, &IDENTITY, &Paint::Solid(rgba(255, 255, 255, 255)));
261 for (i, v) in c.px.iter().enumerate() {
262 assert_eq!(*v, 0xffff_ffff, "pixel {i} not filled: 0x{v:08x}");
263 }
264}
265
266#[test]
267fn integer_rect_fill_is_exact_and_bounded() {
268 let mut c = Canvas::new(20, 20);
269 c.clear(rgba(0, 0, 0, 255));
270 c.fill_rect(Rect::new(5.0, 4.0, 15.0, 12.0), rgba(255, 0, 0, 255));
271 for y in 0..20 {
272 for x in 0..20 {
273 let v = c.px[y * 20 + x];
274 let inside = (5..15).contains(&x) && (4..12).contains(&y);
275 let want = if inside { 0xffff_0000 } else { 0xff00_0000 };
276 assert_eq!(v, want, "pixel ({x},{y}) = 0x{v:08x}");
277 }
278 }
279}
280
281#[test]
282fn clip_confines_drawing() {
283 let mut c = Canvas::new(32, 32);
284 c.clear(rgba(0, 0, 0, 255));
285 c.push_clip(Rect::new(8.0, 8.0, 24.0, 24.0));
286 let mut p = Path::new();
287 p.rect(Rect::new(0.0, 0.0, 32.0, 32.0));
288 c.fill(&p, &IDENTITY, &Paint::Solid(rgba(255, 255, 255, 255)));
289 c.pop_clip();
290 for y in 0..32 {
291 for x in 0..32 {
292 let inside = (8..24).contains(&x) && (8..24).contains(&y);
293 let v = c.px[y * 32 + x];
294 assert_eq!(v, if inside { 0xffff_ffff } else { 0xff00_0000 }, "({x},{y})");
295 }
296 }
297}
298
299#[test]
300fn nested_clips_intersect_and_restore() {
301 let mut c = Canvas::new(32, 32);
302 c.push_clip(Rect::new(4.0, 4.0, 28.0, 28.0));
303 c.push_clip(Rect::new(0.0, 0.0, 10.0, 10.0));
304 assert_eq!(c.clip(), Rect::new(4.0, 4.0, 10.0, 10.0));
305 c.pop_clip();
306 assert_eq!(c.clip(), Rect::new(4.0, 4.0, 28.0, 28.0));
307 c.pop_clip();
308 assert_eq!(c.clip(), Rect::new(0.0, 0.0, 32.0, 32.0));
309}
310
311#[test]
312fn opaque_fill_is_independent_of_background() {
313 let mut a = Canvas::new(24, 24);
314 let mut b = Canvas::new(24, 24);
315 a.clear(rgba(0, 0, 0, 255));
316 b.clear(rgba(255, 255, 0, 255));
317 let mut p = Path::new();
318 p.rrect(Rect::new(2.0, 2.0, 22.0, 22.0), 6.0);
319 let paint = Paint::Solid(rgba(10, 20, 30, 255));
320 a.fill(&p, &IDENTITY, &paint);
321 b.fill(&p, &IDENTITY, &paint);
322 for y in 6..18 {
323 for x in 6..18 {
324 let i = y * 24 + x;
325 assert_eq!(a.px[i], b.px[i], "interior ({x},{y}) depends on backdrop");
326 }
327 }
328}
329
330#[test]
331fn gradient_endpoints_are_exact() {
332 let mut c = Canvas::new(64, 8);
333 c.clear(rgba(0, 0, 0, 255));
334 let r = Rect::new(0.0, 0.0, 64.0, 8.0);
335 let mut p = Path::new();
336 p.rect(r);
337 c.fill(&p, &IDENTITY, &Paint::hgrad(r, rgba(255, 0, 0, 255), rgba(0, 0, 255, 255)));
338 let left = c.px[4 * 64];
339 let right = c.px[4 * 64 + 63];
340 assert!(chan(left, 16) > 250 && chan(left, 0) < 5, "left 0x{left:08x}");
341 assert!(chan(right, 0) > 250 && chan(right, 16) < 5, "right 0x{right:08x}");
342 for x in 1..64 {
343 let a = chan(c.px[4 * 64 + x - 1], 16);
344 let b = chan(c.px[4 * 64 + x], 16);
345 assert!(b <= a, "gradient not monotonic at x={x}: {a} then {b}");
346 }
347}
348
349#[test]
350fn stroke_is_centred_on_the_path() {
351 let mut c = Canvas::new(32, 32);
352 c.clear(rgba(0, 0, 0, 255));
353 let mut p = Path::new();
354 p.move_to(pt(0.0, 16.0));
355 p.line_to(pt(32.0, 16.0));
356 c.stroke(&p, &IDENTITY, &Paint::Solid(rgba(255, 255, 255, 255)), &Stroke::new(4.0));
357 for x in 4..28 {
358 for y in 14..18 {
359 assert_eq!(c.px[y * 32 + x], 0xffff_ffff, "({x},{y}) should be inside stroke");
360 }
361 assert_eq!(c.px[13 * 32 + x], 0xff00_0000, "({x},13) should be outside stroke");
362 assert_eq!(c.px[18 * 32 + x], 0xff00_0000, "({x},18) should be outside stroke");
363 }
364}
365
366#[test]
367fn closed_stroke_leaves_a_hole() {
368 let mut c = Canvas::new(40, 40);
369 c.clear(rgba(0, 0, 0, 255));
370 let mut p = Path::new();
371 p.rect(Rect::new(8.0, 8.0, 32.0, 32.0));
372 c.stroke(&p, &IDENTITY, &Paint::Solid(rgba(255, 255, 255, 255)), &Stroke::new(4.0));
373 assert_eq!(c.px[20 * 40 + 20], 0xff00_0000, "centre of a stroked rect must stay empty");
374 assert_eq!(c.px[8 * 40 + 20], 0xffff_ffff, "top edge should be painted");
375}
376
377#[test]
378fn shadow_is_darkest_under_the_shape_and_fades_out() {
379 let mut c = Canvas::new(120, 120);
380 c.clear(rgba(0, 0, 0, 0));
381 let mut p = Path::new();
382 p.rrect(Rect::new(40.0, 40.0, 80.0, 80.0), 8.0);
383 c.shadow(&p, &IDENTITY, 8.0, rgba(0, 0, 0, 255), pt(0.0, 0.0));
384 let centre = c.px[60 * 120 + 60] >> 24;
385 let edge = c.px[60 * 120 + 36] >> 24;
386 let far = c.px[60 * 120 + 5] >> 24;
387 assert!(centre > 240, "shadow centre alpha {centre}");
388 assert!(edge > 20 && edge < 240, "shadow edge alpha {edge}");
389 assert!(far < 4, "shadow should have faded by 35px out, got {far}");
390}
391
392#[test]
393fn shadow_is_symmetric_for_a_symmetric_shape() {
394 let mut c = Canvas::new(100, 100);
395 c.clear(rgba(0, 0, 0, 0));
396 let mut p = Path::new();
397 p.circle(pt(50.5, 50.5), 20.0);
398 c.shadow(&p, &IDENTITY, 6.0, rgba(0, 0, 0, 255), pt(0.0, 0.0));
399 for d in 1..40 {
400 let l = (c.px[50 * 100 + (50 - d)] >> 24) as i32;
401 let r = (c.px[50 * 100 + (50 + d)] >> 24) as i32;
402 assert!((l - r).abs() <= 2, "asymmetric at d={d}: {l} vs {r}");
403 let u = (c.px[(50 - d) * 100 + 50] >> 24) as i32;
404 assert!((l - u).abs() <= 3, "not radially symmetric at d={d}: {l} vs {u}");
405 }
406}
407
408#[test]
409fn blur_preserves_total_energy() {
410 let w = 64;
411 let h = 64;
412 let mut buf = vec![0u32; w * h];
413 for y in 24..40 {
414 for x in 24..40 {
415 buf[y * w + x] = 0xffff_ffff;
416 }
417 }
418 let before: u64 = buf.iter().map(|v| (v >> 24) as u64).sum();
419 nox_gfx::canvas::blur_premul(&mut buf, w, h, 5.0);
420 let after: u64 = buf.iter().map(|v| (v >> 24) as u64).sum();
421 let drift = (before as f64 - after as f64).abs() / before as f64;
422 assert!(drift < 0.05, "blur lost energy: {before} -> {after} ({drift:.3})");
423 let centre = buf[32 * w + 32] >> 24;
424 assert!(centre > 200, "blur centre too dark: {centre}");
425 assert!(buf[2 * w + 2] >> 24 < 8, "blur bled too far");
426}
427
428#[test]
429fn transform_compose_and_invert() {
430 let a = Transform::translate(13.0, -4.0);
431 let b = Transform::scale(2.0, 3.0);
432 let c = Transform::rotate(0.7);
433 let m = a.then(&b).then(&c);
434 let inv = m.invert().expect("invertible");
435 for p in [pt(0.0, 0.0), pt(1.0, -2.0), pt(100.0, 55.0)] {
436 let q = inv.apply(m.apply(p));
437 assert!((q.x - p.x).abs() < 1e-3 && (q.y - p.y).abs() < 1e-3, "{p:?} -> {q:?}");
438 }
439 let step = a.then(&b);
440 for p in [pt(3.0, 4.0), pt(-9.0, 2.5)] {
441 let want = b.apply(a.apply(p));
442 let got = step.apply(p);
443 assert!((want.x - got.x).abs() < 1e-4 && (want.y - got.y).abs() < 1e-4);
444 }
445}
446
447#[test]
448fn flatten_stays_within_tolerance() {
449 let mut p = Path::new();
450 p.move_to(pt(0.0, 0.0));
451 p.cubic_to(pt(0.0, 80.0), pt(120.0, 80.0), pt(120.0, 0.0));
452 let mut segs = Vec::new();
453 p.flatten(&IDENTITY, 0.1, |a, b| segs.push((a, b)));
454 assert!(segs.len() > 8, "too few segments: {}", segs.len());
455 for t in 0..=200 {
456 let t = t as f32 / 200.0;
457 let mt = 1.0 - t;
458 let exact = pt(
459 3.0 * mt * t * t * 120.0 + t * t * t * 120.0,
460 3.0 * mt * mt * t * 80.0 + 3.0 * mt * t * t * 80.0,
461 );
462 let mut best = f32::MAX;
463 for (a, b) in &segs {
464 let d = b.sub_dist(*a, exact);
465 best = best.min(d);
466 }
467 assert!(best <= 0.15, "curve point {exact:?} is {best} from the polyline");
468 }
469}
470
471trait SegDist {
472 fn sub_dist(self, a: Point, p: Point) -> f32;
473}
474
475impl SegDist for Point {
476 fn sub_dist(self, a: Point, p: Point) -> f32 {
477 let ab = self - a;
478 let len2 = ab.len_sq();
479 if len2 < 1e-9 {
480 return p.dist(a);
481 }
482 let t = ((p - a).dot(ab) / len2).clamp(0.0, 1.0);
483 p.dist(pt(a.x + ab.x * t, a.y + ab.y * t))
484 }
485}
486
487#[test]
488fn png_structure_is_valid() {
489 let mut c = Canvas::new(9, 7);
490 c.clear(rgba(30, 60, 90, 255));
491 let png = c.to_png();
492 assert_eq!(&png[..8], &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]);
493 let mut i = 8;
494 let mut tags = Vec::new();
495 while i + 12 <= png.len() {
496 let len = u32::from_be_bytes(png[i..i + 4].try_into().unwrap()) as usize;
497 let tag = String::from_utf8_lossy(&png[i + 4..i + 8]).to_string();
498 let body = &png[i + 8..i + 8 + len];
499 let want = u32::from_be_bytes(png[i + 8 + len..i + 12 + len].try_into().unwrap());
500 let mut crc = 0xffff_ffffu32;
501 for &b in png[i + 4..i + 8 + len].iter() {
502 crc ^= b as u32;
503 for _ in 0..8 {
504 let m = (crc & 1).wrapping_neg();
505 crc = (crc >> 1) ^ (0xEDB8_8320 & m);
506 }
507 }
508 assert_eq!(!crc, want, "bad CRC on chunk {tag}");
509 if tag == "IHDR" {
510 assert_eq!(u32::from_be_bytes(body[0..4].try_into().unwrap()), 9);
511 assert_eq!(u32::from_be_bytes(body[4..8].try_into().unwrap()), 7);
512 assert_eq!(body[8], 8);
513 assert_eq!(body[9], 6);
514 }
515 tags.push(tag);
516 i += 12 + len;
517 }
518 assert_eq!(i, png.len(), "trailing bytes after last chunk");
519 assert_eq!(tags, vec!["IHDR", "IDAT", "IEND"]);
520}
521
522fn lcg(state: &mut u64) -> u32 {
523 *state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
524 (*state >> 33) as u32
525}
526
527fn random_row(seed: &mut u64, n: usize) -> (Vec<u32>, Vec<f32>) {
528 let mut dst = Vec::with_capacity(n);
529 let mut cov = Vec::with_capacity(n);
530 for _ in 0..n {
531 let a = (lcg(seed) & 0xff) as u32;
532 let c = |s: &mut u64| ((lcg(s) & 0xff) * a / 255) & 0xff;
533 dst.push((a << 24) | (c(seed) << 16) | (c(seed) << 8) | c(seed));
534 cov.push(match lcg(seed) % 5 {
535 0 => 0.0,
536 1 => 1.0,
537 _ => (lcg(seed) % 1001) as f32 / 1000.0,
538 });
539 }
540 (dst, cov)
541}
542
543fn opaque_lut() -> [u32; 256] {
544 let mut lut = [0u32; 256];
545 for (i, v) in lut.iter_mut().enumerate() {
546 *v = 0xff00_0000 | ((i as u32) << 16) | ((255 - i as u32) << 8) | 0x40;
547 }
548 lut
549}
550
551fn translucent_lut() -> [u32; 256] {
552 let mut lut = [0u32; 256];
553 for (i, v) in lut.iter_mut().enumerate() {
554 let a = i as u32;
555 *v = (a << 24) | ((a / 2) << 16) | ((a / 3) << 8) | (a / 4);
556 }
557 lut
558}
559
560#[test]
561fn simd_solid_matches_scalar() {
562 let mut seed = 0x1234_5678_9abc_def0;
563 for n in [0usize, 1, 3, 4, 7, 8, 16, 31, 64, 129] {
564 for color in [0xff00_0000u32, 0xffff_ffff, 0x8040_2010, 0x0100_0000, 0] {
565 let (dst, cov) = random_row(&mut seed, n);
566 let mut a = dst.clone();
567 let mut b = dst;
568 nox_gfx::simd::blend_solid_cov(&mut a, color, &cov);
569 nox_gfx::simd::blend_solid_cov_scalar(&mut b, color, &cov);
570 assert_eq!(a, b, "solid n={n} color=0x{color:08x}");
571 }
572 }
573}
574
575#[test]
576fn simd_alpha_matches_scalar() {
577 let mut seed = 0xfeed_face_cafe_babe;
578 for n in [0usize, 1, 5, 8, 33, 100] {
579 for color in [0xff00_0000u32, 0x8040_2010, 0xffff_ffff] {
580 let (dst, _) = random_row(&mut seed, n);
581 let alpha: Vec<u8> = (0..n).map(|_| (lcg(&mut seed) & 0xff) as u8).collect();
582 let mut a = dst.clone();
583 let mut b = dst;
584 nox_gfx::simd::blend_solid_alpha(&mut a, color, &alpha);
585 nox_gfx::simd::blend_solid_alpha_scalar(&mut b, color, &alpha);
586 assert_eq!(a, b, "alpha n={n} color=0x{color:08x}");
587 }
588 }
589}
590
591#[test]
592fn simd_linear_gradient_matches_scalar() {
593 let mut seed = 0x0bad_c0de_dead_beef;
594 for lut in [opaque_lut(), translucent_lut()] {
595 for n in [0usize, 2, 4, 9, 17, 64, 130] {
596 for (t0, dt) in [(0.0f32, 0.01f32), (-0.5, 0.003), (0.9, -0.02), (0.5, 0.0)] {
597 let (dst, cov) = random_row(&mut seed, n);
598 let mut a = dst.clone();
599 let mut b = dst;
600 nox_gfx::simd::blend_lut_cov(&mut a, &lut, t0, dt, &cov, lut[0] >> 24 == 255);
601 nox_gfx::simd::blend_lut_cov_scalar(&mut b, &lut, t0, dt, &cov);
602 for i in 0..n {
603 let d = |sh: u32| {
604 (((a[i] >> sh) & 0xff) as i32 - ((b[i] >> sh) & 0xff) as i32).abs()
605 };
606 assert!(
607 d(24) <= 1 && d(16) <= 1 && d(8) <= 1 && d(0) <= 1,
608 "linear n={n} i={i} t0={t0} dt={dt}: 0x{:08x} vs 0x{:08x}",
609 a[i],
610 b[i]
611 );
612 }
613 }
614 }
615 }
616}
617
618#[test]
619fn simd_radial_gradient_matches_scalar() {
620 let mut seed = 0x5eed_1234_5678_9abc;
621 for lut in [opaque_lut(), translucent_lut()] {
622 for n in [0usize, 3, 4, 11, 40] {
623 for (px0, py2, inv_r) in [(-20.0f32, 100.0f32, 0.02f32), (0.0, 0.0, 0.1), (5.0, 25.0, 0.005)] {
624 let (dst, cov) = random_row(&mut seed, n);
625 let mut a = dst.clone();
626 let mut b = dst;
627 nox_gfx::simd::blend_radial_cov(&mut a, &lut, px0, py2, inv_r, &cov, lut[0] >> 24 == 255);
628 nox_gfx::simd::blend_radial_cov_scalar(&mut b, &lut, px0, py2, inv_r, &cov);
629 for i in 0..n {
630 let d = |sh: u32| {
631 (((a[i] >> sh) & 0xff) as i32 - ((b[i] >> sh) & 0xff) as i32).abs()
632 };
633 assert!(d(24) <= 1 && d(16) <= 1 && d(8) <= 1 && d(0) <= 1, "radial n={n} i={i}");
634 }
635 }
636 }
637 }
638}
639
640#[test]
641fn shadow_occluder_leaves_covered_region_untouched() {
642 let mut a = Canvas::new(140, 140);
643 let mut b = Canvas::new(140, 140);
644 a.clear(rgba(0, 0, 0, 0));
645 b.clear(rgba(0, 0, 0, 0));
646 let mut p = Path::new();
647 p.rrect(Rect::new(40.0, 40.0, 100.0, 100.0), 10.0);
648 let hole = Rect::new(45.0, 45.0, 95.0, 95.0);
649 a.shadow(&p, &IDENTITY, 10.0, rgba(0, 0, 0, 255), pt(0.0, 6.0));
650 b.shadow_occluded(&p, &IDENTITY, 10.0, rgba(0, 0, 0, 255), pt(0.0, 6.0), hole);
651 for y in 0..140 {
652 for x in 0..140 {
653 let i = y * 140 + x;
654 let inside = (45..95).contains(&x) && (45..95).contains(&y);
655 if inside {
656 assert_eq!(b.px[i], 0, "occluded pixel ({x},{y}) should be skipped");
657 } else {
658 assert_eq!(a.px[i], b.px[i], "visible pixel ({x},{y}) changed");
659 }
660 }
661 }
662}
663
664#[test]
665fn png_round_trips_through_a_real_inflater() {
666 // Written out and read back by an independent decoder (python zlib) in
667 // tests/decode_png.py, driven by the shell test below. Here we at least
668 // guarantee the deflate stream is self-consistent for a decoder we write
669 // ourselves: fixed-Huffman blocks, correct adler32, correct crc32.
670 let mut c = Canvas::new(97, 53);
671 c.clear(rgba(20, 30, 40, 255));
672 let mut p = Path::new();
673 p.rrect(Rect::new(8.5, 6.25, 80.0, 44.0), 11.0);
674 c.fill(&p, &IDENTITY, &Paint::hgrad(Rect::new(0.0, 0.0, 97.0, 1.0), rgba(255, 0, 0, 255), rgba(0, 128, 255, 255)));
675 let png = c.to_png();
676 let raw_size = 97 * 53 * 4;
677 assert!(png.len() < raw_size / 2, "png did not compress: {} vs {raw_size} raw", png.len());
678 assert_eq!(&png[..8], &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]);
679 let idat_start = png.windows(4).position(|w| w == b"IDAT").expect("IDAT");
680 assert_eq!(png[idat_start + 4], 0x78, "zlib CMF byte");
681 assert_eq!(png[idat_start + 5], 0x9c, "zlib FLG byte");
682}
683
684#[test]
685fn deflate_output_is_smaller_than_input_for_repetitive_data() {
686 let mut data = Vec::new();
687 for i in 0..8000u32 {
688 data.extend_from_slice(&(i % 61).to_le_bytes());
689 }
690 let z = nox_gfx::deflate::zlib(&data, 24);
691 assert!(z.len() < data.len() / 4, "expected strong compression, got {} of {}", z.len(), data.len());
692 assert_eq!(
693 u32::from_be_bytes(z[z.len() - 4..].try_into().unwrap()),
694 nox_gfx::deflate::adler32(&data)
695 );
696}