feat: port two-color thick line rasterizer, all 21 snapshot tests pass
Browse filesPure Rust port of 0x4F7A60 (draw_line_two_color): 2px-wide lines with
separate outline and fill colors. First pass draws 8 surrounding pixels
(color2), second pass overwrites 4 center pixels (color1). Both single-
color (11 tests) and two-color (8 tests) snapshot tests pass pixel-
perfect against WA's native implementation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
crates/openwa-core/src/display/line_draw.rs
CHANGED
|
@@ -15,8 +15,6 @@
|
|
| 15 |
//! └── DDA rasterizer — 1 pixel per step (horizontal or vertical major)
|
| 16 |
//! ```
|
| 17 |
|
| 18 |
-
use crate::fixed::Fixed;
|
| 19 |
-
|
| 20 |
/// Trait for pixel-addressable surfaces used by line-drawing algorithms.
|
| 21 |
///
|
| 22 |
/// Implemented for `PixelGrid` (test-only, pure Rust) and can be implemented
|
|
@@ -337,6 +335,219 @@ pub fn draw_line_clipped(
|
|
| 337 |
}
|
| 338 |
}
|
| 339 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
// =========================================================================
|
| 341 |
// Tests
|
| 342 |
// =========================================================================
|
|
@@ -533,4 +744,98 @@ mod tests {
|
|
| 533 |
draw_line_clipped(&mut grid, f(10), f(10), f(118), f(118), 11);
|
| 534 |
assert_matches_snapshot(&grid, "clipped_restricted_clip");
|
| 535 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 536 |
}
|
|
|
|
| 15 |
//! └── DDA rasterizer — 1 pixel per step (horizontal or vertical major)
|
| 16 |
//! ```
|
| 17 |
|
|
|
|
|
|
|
| 18 |
/// Trait for pixel-addressable surfaces used by line-drawing algorithms.
|
| 19 |
///
|
| 20 |
/// Implemented for `PixelGrid` (test-only, pure Rust) and can be implemented
|
|
|
|
| 335 |
}
|
| 336 |
}
|
| 337 |
|
| 338 |
+
// =========================================================================
|
| 339 |
+
// Two-color thick line rasterizer (port of 0x4F7A60)
|
| 340 |
+
// =========================================================================
|
| 341 |
+
|
| 342 |
+
/// Horizontal-major thick outline rasterizer (port of 0x4F77C0).
|
| 343 |
+
///
|
| 344 |
+
/// Draws 8 outline pixels per step around a 2x2 body center.
|
| 345 |
+
fn raster_thick_hmajor_outline(
|
| 346 |
+
writer: &mut dyn PixelWriter,
|
| 347 |
+
x1: i32,
|
| 348 |
+
y1: i32,
|
| 349 |
+
x2: i32,
|
| 350 |
+
y2: i32,
|
| 351 |
+
color: u8,
|
| 352 |
+
) {
|
| 353 |
+
let start = (x1 & !0xFFFF_i32) + 0x8000;
|
| 354 |
+
let end = (x2 & !0xFFFF_i32) + 0x8000;
|
| 355 |
+
if start == end {
|
| 356 |
+
return;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
// slope = ((y2 - y1) << 16) / (x2 - x1)
|
| 360 |
+
let slope = fixed_div(y2 - y1, x2 - x1);
|
| 361 |
+
// Sub-pixel y adjustment for pixel-center start
|
| 362 |
+
let mut y = y1 + fixed_mul_shift(slope, start - x1);
|
| 363 |
+
let mut px = start >> 16;
|
| 364 |
+
let count = (end - start) >> 16;
|
| 365 |
+
if count == 0 {
|
| 366 |
+
return;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
for _ in 0..count {
|
| 370 |
+
let py = y >> 16;
|
| 371 |
+
// 8 outline pixels around the 2x2 body
|
| 372 |
+
writer.put_pixel_clipped(px, py - 1, color);
|
| 373 |
+
writer.put_pixel_clipped(px + 1, py - 1, color);
|
| 374 |
+
writer.put_pixel_clipped(px - 1, py, color);
|
| 375 |
+
writer.put_pixel_clipped(px + 2, py, color);
|
| 376 |
+
writer.put_pixel_clipped(px - 1, py + 1, color);
|
| 377 |
+
writer.put_pixel_clipped(px + 2, py + 1, color);
|
| 378 |
+
writer.put_pixel_clipped(px, py + 2, color);
|
| 379 |
+
writer.put_pixel_clipped(px + 1, py + 2, color);
|
| 380 |
+
|
| 381 |
+
y += slope;
|
| 382 |
+
px += 1;
|
| 383 |
+
}
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
/// Horizontal-major thick fill rasterizer (port of 0x4F75F0).
|
| 387 |
+
///
|
| 388 |
+
/// Draws 4 body pixels per step (2x2 center).
|
| 389 |
+
fn raster_thick_hmajor_fill(
|
| 390 |
+
writer: &mut dyn PixelWriter,
|
| 391 |
+
x1: i32,
|
| 392 |
+
y1: i32,
|
| 393 |
+
x2: i32,
|
| 394 |
+
y2: i32,
|
| 395 |
+
color: u8,
|
| 396 |
+
) {
|
| 397 |
+
let start = (x1 & !0xFFFF_i32) + 0x8000;
|
| 398 |
+
let end = (x2 & !0xFFFF_i32) + 0x8000;
|
| 399 |
+
if start == end {
|
| 400 |
+
return;
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
let slope = fixed_div(y2 - y1, x2 - x1);
|
| 404 |
+
let mut y = y1 + fixed_mul_shift(slope, start - x1);
|
| 405 |
+
let mut px = start >> 16;
|
| 406 |
+
let count = (end - start) >> 16;
|
| 407 |
+
if count == 0 {
|
| 408 |
+
return;
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
for _ in 0..count {
|
| 412 |
+
let py = y >> 16;
|
| 413 |
+
// 2x2 body
|
| 414 |
+
writer.put_pixel_clipped(px, py, color);
|
| 415 |
+
writer.put_pixel_clipped(px + 1, py, color);
|
| 416 |
+
writer.put_pixel_clipped(px, py + 1, color);
|
| 417 |
+
writer.put_pixel_clipped(px + 1, py + 1, color);
|
| 418 |
+
|
| 419 |
+
y += slope;
|
| 420 |
+
px += 1;
|
| 421 |
+
}
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
/// Vertical-major thick outline rasterizer (port of 0x4F7910).
|
| 425 |
+
///
|
| 426 |
+
/// Transposed version of the horizontal-major outline — draws 8 pixels per step.
|
| 427 |
+
fn raster_thick_vmajor_outline(
|
| 428 |
+
writer: &mut dyn PixelWriter,
|
| 429 |
+
x1: i32,
|
| 430 |
+
y1: i32,
|
| 431 |
+
x2: i32,
|
| 432 |
+
y2: i32,
|
| 433 |
+
color: u8,
|
| 434 |
+
) {
|
| 435 |
+
let start = (y1 & !0xFFFF_i32) + 0x8000;
|
| 436 |
+
let end = (y2 & !0xFFFF_i32) + 0x8000;
|
| 437 |
+
if start == end {
|
| 438 |
+
return;
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
let slope = fixed_div(x2 - x1, y2 - y1);
|
| 442 |
+
let mut x = x1 + fixed_mul_shift(slope, start - y1);
|
| 443 |
+
let mut py = start >> 16;
|
| 444 |
+
let count = (end - start) >> 16;
|
| 445 |
+
|
| 446 |
+
for _ in 0..count {
|
| 447 |
+
let px = x >> 16;
|
| 448 |
+
// 8 outline pixels (transposed pattern)
|
| 449 |
+
writer.put_pixel_clipped(px, py - 1, color);
|
| 450 |
+
writer.put_pixel_clipped(px + 1, py - 1, color);
|
| 451 |
+
writer.put_pixel_clipped(px - 1, py, color);
|
| 452 |
+
writer.put_pixel_clipped(px + 2, py, color);
|
| 453 |
+
writer.put_pixel_clipped(px - 1, py + 1, color);
|
| 454 |
+
writer.put_pixel_clipped(px + 2, py + 1, color);
|
| 455 |
+
writer.put_pixel_clipped(px, py + 2, color);
|
| 456 |
+
writer.put_pixel_clipped(px + 1, py + 2, color);
|
| 457 |
+
|
| 458 |
+
x += slope;
|
| 459 |
+
py += 1;
|
| 460 |
+
}
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
/// Vertical-major thick fill rasterizer (port of 0x4F76D0).
|
| 464 |
+
///
|
| 465 |
+
/// Transposed version — draws 4 body pixels per step.
|
| 466 |
+
fn raster_thick_vmajor_fill(
|
| 467 |
+
writer: &mut dyn PixelWriter,
|
| 468 |
+
x1: i32,
|
| 469 |
+
y1: i32,
|
| 470 |
+
x2: i32,
|
| 471 |
+
y2: i32,
|
| 472 |
+
color: u8,
|
| 473 |
+
) {
|
| 474 |
+
let start = (y1 & !0xFFFF_i32) + 0x8000;
|
| 475 |
+
let end = (y2 & !0xFFFF_i32) + 0x8000;
|
| 476 |
+
if start == end {
|
| 477 |
+
return;
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
let slope = fixed_div(x2 - x1, y2 - y1);
|
| 481 |
+
let mut x = x1 + fixed_mul_shift(slope, start - y1);
|
| 482 |
+
let mut py = start >> 16;
|
| 483 |
+
let count = (end - start) >> 16;
|
| 484 |
+
|
| 485 |
+
for _ in 0..count {
|
| 486 |
+
let px = x >> 16;
|
| 487 |
+
// 2x2 body
|
| 488 |
+
writer.put_pixel_clipped(px, py, color);
|
| 489 |
+
writer.put_pixel_clipped(px + 1, py, color);
|
| 490 |
+
writer.put_pixel_clipped(px, py + 1, color);
|
| 491 |
+
writer.put_pixel_clipped(px + 1, py + 1, color);
|
| 492 |
+
|
| 493 |
+
x += slope;
|
| 494 |
+
py += 1;
|
| 495 |
+
}
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
/// Draw a two-color thick clipped line. Port of 0x4F7A60.
|
| 499 |
+
///
|
| 500 |
+
/// All coordinates are Fixed-point (16.16). Draws a 2px-wide line with
|
| 501 |
+
/// `color1` as outline and `color2` as body fill.
|
| 502 |
+
pub fn draw_line_two_color(
|
| 503 |
+
writer: &mut dyn PixelWriter,
|
| 504 |
+
mut x1: i32,
|
| 505 |
+
mut y1: i32,
|
| 506 |
+
mut x2: i32,
|
| 507 |
+
mut y2: i32,
|
| 508 |
+
color1: u8,
|
| 509 |
+
color2: u8,
|
| 510 |
+
) {
|
| 511 |
+
let abs_dx = (x2 - x1).unsigned_abs();
|
| 512 |
+
let abs_dy = (y2 - y1).unsigned_abs();
|
| 513 |
+
|
| 514 |
+
if abs_dx < abs_dy {
|
| 515 |
+
// Vertical-major
|
| 516 |
+
// Special case: zero horizontal extent → nudge x coords back by 1 pixel
|
| 517 |
+
if abs_dx == 0 {
|
| 518 |
+
x1 -= 0x10000;
|
| 519 |
+
x2 -= 0x10000;
|
| 520 |
+
}
|
| 521 |
+
// Sort so y1 <= y2
|
| 522 |
+
if y1 > y2 {
|
| 523 |
+
core::mem::swap(&mut x1, &mut x2);
|
| 524 |
+
core::mem::swap(&mut y1, &mut y2);
|
| 525 |
+
}
|
| 526 |
+
if clip_line(&mut x1, &mut y1, &mut x2, &mut y2, writer) {
|
| 527 |
+
// First pass: color2 wide (8 pixels/step), then color1 narrow (4 pixels/step)
|
| 528 |
+
raster_thick_vmajor_outline(writer, x1, y1, x2, y2, color2);
|
| 529 |
+
raster_thick_vmajor_fill(writer, x1, y1, x2, y2, color1);
|
| 530 |
+
}
|
| 531 |
+
} else {
|
| 532 |
+
// Horizontal-major
|
| 533 |
+
// Special case: zero vertical extent → nudge y coords back by 1 pixel
|
| 534 |
+
if abs_dy == 0 {
|
| 535 |
+
y1 -= 0x10000;
|
| 536 |
+
y2 -= 0x10000;
|
| 537 |
+
}
|
| 538 |
+
// Sort so x1 <= x2
|
| 539 |
+
if x1 > x2 {
|
| 540 |
+
core::mem::swap(&mut x1, &mut x2);
|
| 541 |
+
core::mem::swap(&mut y1, &mut y2);
|
| 542 |
+
}
|
| 543 |
+
if clip_line(&mut x1, &mut y1, &mut x2, &mut y2, writer) {
|
| 544 |
+
// First pass: color2 wide (8 pixels/step), then color1 narrow (4 pixels/step)
|
| 545 |
+
raster_thick_hmajor_outline(writer, x1, y1, x2, y2, color2);
|
| 546 |
+
raster_thick_hmajor_fill(writer, x1, y1, x2, y2, color1);
|
| 547 |
+
}
|
| 548 |
+
}
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
// =========================================================================
|
| 552 |
// Tests
|
| 553 |
// =========================================================================
|
|
|
|
| 744 |
draw_line_clipped(&mut grid, f(10), f(10), f(118), f(118), 11);
|
| 745 |
assert_matches_snapshot(&grid, "clipped_restricted_clip");
|
| 746 |
}
|
| 747 |
+
|
| 748 |
+
// Two-color snapshot tests
|
| 749 |
+
macro_rules! snapshot_test_twocol {
|
| 750 |
+
($name:ident, $snap:expr, $x1:expr, $y1:expr, $x2:expr, $y2:expr, $c1:expr, $c2:expr) => {
|
| 751 |
+
#[test]
|
| 752 |
+
fn $name() {
|
| 753 |
+
let mut grid = PixelGrid::new(128, 128);
|
| 754 |
+
draw_line_two_color(&mut grid, $x1, $y1, $x2, $y2, $c1, $c2);
|
| 755 |
+
assert_matches_snapshot(&grid, $snap);
|
| 756 |
+
}
|
| 757 |
+
};
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
snapshot_test_twocol!(
|
| 761 |
+
snap_twocol_horizontal,
|
| 762 |
+
"twocol_horizontal",
|
| 763 |
+
f(10),
|
| 764 |
+
f(64),
|
| 765 |
+
f(118),
|
| 766 |
+
f(64),
|
| 767 |
+
1,
|
| 768 |
+
2
|
| 769 |
+
);
|
| 770 |
+
snapshot_test_twocol!(
|
| 771 |
+
snap_twocol_vertical,
|
| 772 |
+
"twocol_vertical",
|
| 773 |
+
f(64),
|
| 774 |
+
f(10),
|
| 775 |
+
f(64),
|
| 776 |
+
f(118),
|
| 777 |
+
1,
|
| 778 |
+
2
|
| 779 |
+
);
|
| 780 |
+
snapshot_test_twocol!(
|
| 781 |
+
snap_twocol_diagonal_45,
|
| 782 |
+
"twocol_diagonal_45",
|
| 783 |
+
f(10),
|
| 784 |
+
f(10),
|
| 785 |
+
f(118),
|
| 786 |
+
f(118),
|
| 787 |
+
1,
|
| 788 |
+
2
|
| 789 |
+
);
|
| 790 |
+
snapshot_test_twocol!(
|
| 791 |
+
snap_twocol_steep,
|
| 792 |
+
"twocol_steep",
|
| 793 |
+
f(60),
|
| 794 |
+
f(10),
|
| 795 |
+
f(68),
|
| 796 |
+
f(118),
|
| 797 |
+
3,
|
| 798 |
+
4
|
| 799 |
+
);
|
| 800 |
+
snapshot_test_twocol!(
|
| 801 |
+
snap_twocol_shallow,
|
| 802 |
+
"twocol_shallow",
|
| 803 |
+
f(10),
|
| 804 |
+
f(60),
|
| 805 |
+
f(118),
|
| 806 |
+
f(68),
|
| 807 |
+
3,
|
| 808 |
+
4
|
| 809 |
+
);
|
| 810 |
+
snapshot_test_twocol!(
|
| 811 |
+
snap_twocol_negative,
|
| 812 |
+
"twocol_negative",
|
| 813 |
+
f(118),
|
| 814 |
+
f(10),
|
| 815 |
+
f(10),
|
| 816 |
+
f(118),
|
| 817 |
+
5,
|
| 818 |
+
6
|
| 819 |
+
);
|
| 820 |
+
snapshot_test_twocol!(
|
| 821 |
+
snap_twocol_subpixel,
|
| 822 |
+
"twocol_subpixel",
|
| 823 |
+
f(10) + 0x8000,
|
| 824 |
+
f(20) + 0x4000,
|
| 825 |
+
f(100) + 0xC000,
|
| 826 |
+
f(80) + 0x2000,
|
| 827 |
+
7,
|
| 828 |
+
8
|
| 829 |
+
);
|
| 830 |
+
|
| 831 |
+
#[test]
|
| 832 |
+
fn snap_twocol_restricted_clip() {
|
| 833 |
+
let mut grid = PixelGrid::new(128, 128);
|
| 834 |
+
grid.clip_left = 30;
|
| 835 |
+
grid.clip_top = 30;
|
| 836 |
+
grid.clip_right = 98;
|
| 837 |
+
grid.clip_bottom = 98;
|
| 838 |
+
draw_line_two_color(&mut grid, f(10), f(10), f(118), f(118), 9, 10);
|
| 839 |
+
assert_matches_snapshot(&grid, "twocol_restricted_clip");
|
| 840 |
+
}
|
| 841 |
}
|