Files
pdf-odin-llm/geom_test.odin
T

283 lines
12 KiB
Odin
Raw Normal View History

2026-09-25 23:32:29 +02:00
package pdf
import "core:math"
import "core:mem/virtual"
import "core:testing"
// ---------------------------------------------------------------------------
// Matrices
//
// matrix_mul's operand order is the single thing every `cm` operator, form
// XObject and pattern matrix depends on. Getting it backwards transposes the
// whole page, so it is pinned here against worked examples rather than against
// the implementation's own arithmetic.
// ---------------------------------------------------------------------------
@(test)
test_matrix_mul_order :: proc(t: ^testing.T) {
// m applied first, then n.
m := matrix_mul(scale_matrix(2, 3), translate_matrix(5, 7))
testing.expect_value(t, m, Matrix{2, 0, 0, 3, 5, 7})
testing.expect_value(t, transform_point(m, Point{1, 1}), Point{7, 10})
// The reverse order is a different matrix -- if these were equal the test
// would pass with the operands transposed.
n := matrix_mul(translate_matrix(5, 7), scale_matrix(2, 3))
testing.expect_value(t, n, Matrix{2, 0, 0, 3, 10, 21})
testing.expect(t, m != n, "multiplication is not commutative")
testing.expect_value(t, matrix_mul(MATRIX_IDENTITY, m), m)
testing.expect_value(t, matrix_mul(m, MATRIX_IDENTITY), m)
}
// The row-vector convention the format uses: x' = a*x + c*y + e.
@(test)
test_transform_point_convention :: proc(t: ^testing.T) {
m := Matrix{2, 3, 5, 7, 11, 13}
testing.expect_value(t, transform_point(m, Point{1, 0}), Point{2 + 11, 3 + 13})
testing.expect_value(t, transform_point(m, Point{0, 1}), Point{5 + 11, 7 + 13})
testing.expect_value(t, transform_point(m, Point{0, 0}), Point{11, 13})
// A vector has direction and magnitude but no position, so the translation
// must not apply.
testing.expect_value(t, transform_vector(m, Point{1, 0}), Point{2, 3})
testing.expect_value(t, transform_vector(m, Point{0, 0}), Point{0, 0})
}
@(test)
test_matrix_rotate_and_skew :: proc(t: ^testing.T) {
r := rotate_matrix(90)
p := transform_point(r, Point{1, 0})
testing.expect(t, abs(p.x) < 1e-12, "90 degrees sends x to 0")
testing.expect(t, abs(p.y - 1) < 1e-12, "90 degrees sends x to +y")
// Four quarter turns is the identity, to floating-point tolerance.
full := matrix_mul(matrix_mul(r, r), matrix_mul(r, r))
testing.expect(t, abs(full.a - 1) < 1e-9 && abs(full.d - 1) < 1e-9, "four quarter turns")
s := skew_matrix(45, 0)
testing.expect(t, abs(s.c - 1) < 1e-12, "a 45 degree x-skew has c == 1")
}
@(test)
test_matrix_invert :: proc(t: ^testing.T) {
m := Matrix{2, 1, 1, 3, 10, 20}
inv, ok := matrix_invert(m)
testing.expect(t, ok, "a non-singular matrix inverts")
round := matrix_mul(m, inv)
testing.expect(t, abs(round.a - 1) < 1e-12 && abs(round.d - 1) < 1e-12, "m * m^-1 is identity")
testing.expect(t, abs(round.b) < 1e-12 && abs(round.c) < 1e-12, "")
testing.expect(t, abs(round.e) < 1e-12 && abs(round.f) < 1e-12, "translation cancels too")
// Round-tripping a point is the property callers actually rely on.
p := Point{3, -4}
testing.expect(t, abs(transform_point(inv, transform_point(m, p)).x - p.x) < 1e-12, "")
testing.expect(t, abs(transform_point(inv, transform_point(m, p)).y - p.y) < 1e-12, "")
}
// A degenerate CTM is legal content -- a zero-width scale is how some producers
// hide artwork -- so callers must be told rather than handed nonsense.
@(test)
test_matrix_invert_singular :: proc(t: ^testing.T) {
_, ok := matrix_invert(scale_matrix(0, 1))
testing.expect(t, !ok, "a zero scale is singular")
_, ok2 := matrix_invert(Matrix{1, 2, 2, 4, 0, 0})
testing.expect(t, !ok2, "collinear rows are singular")
_, ok3 := matrix_invert(Matrix{math.nan_f64(), 0, 0, 1, 0, 0})
testing.expect(t, !ok3, "a NaN determinant is not invertible")
inv, _ := matrix_invert(scale_matrix(0, 0))
testing.expect_value(t, inv, MATRIX_IDENTITY)
}
@(test)
test_matrix_expansion :: proc(t: ^testing.T) {
testing.expect_value(t, matrix_expansion(scale_matrix(2, 8)), 4)
testing.expect_value(t, matrix_expansion(MATRIX_IDENTITY), 1)
// Reflection scales lengths as much as rotation does; the sign is not it.
testing.expect_value(t, matrix_expansion(scale_matrix(-3, 3)), 3)
// The maximum stretch of any single direction, which is what flattening
// tolerance must use: the average would flatten the stretched axis coarsely.
testing.expect_value(t, matrix_max_expansion(scale_matrix(2, 8)), 8)
testing.expect_value(t, matrix_max_expansion(scale_matrix(8, 2)), 8)
}
@(test)
test_matrix_predicates :: proc(t: ^testing.T) {
testing.expect(t, matrix_is_identity(MATRIX_IDENTITY), "")
testing.expect(t, !matrix_is_identity(translate_matrix(1, 0)), "")
testing.expect(t, matrix_is_rectilinear(scale_matrix(2, 3)), "a scale keeps axes")
testing.expect(t, matrix_is_rectilinear(rotate_matrix(90)), "a quarter turn swaps axes")
testing.expect(t, !matrix_is_rectilinear(rotate_matrix(45)), "45 degrees does not")
testing.expect(t, !matrix_is_rectilinear(skew_matrix(10, 0)), "a skew does not")
testing.expect(t, matrix_is_finite(MATRIX_IDENTITY), "")
testing.expect(t, !matrix_is_finite(Matrix{1, 0, 0, 1, math.nan_f64(), 0}), "")
testing.expect(t, !matrix_is_finite(Matrix{math.INF_F64, 0, 0, 1, 0, 0}), "")
}
// ---------------------------------------------------------------------------
// Rectangles
// ---------------------------------------------------------------------------
@(test)
test_transform_rect_bounds_the_shape :: proc(t: ^testing.T) {
r := Rect{0, 0, 10, 10}
testing.expect_value(t, transform_rect(scale_matrix(2, 3), r), Rect{0, 0, 20, 30})
// A rotation gives the bounding box of the rotated shape, which is larger
// than the shape itself.
rot := transform_rect(rotate_matrix(45), r)
d := 10 * math.sqrt_f64(2)
testing.expect(t, abs(rect_width(rot) - d) < 1e-9, "a 45-degree box is sqrt(2) wider")
// Clipping against the infinite rect must stay a no-op after transforming.
testing.expect_value(t, transform_rect(scale_matrix(2, 2), RECT_INFINITE), RECT_INFINITE)
}
@(test)
test_rect_ops :: proc(t: ^testing.T) {
a := Rect{0, 0, 10, 10}
b := Rect{5, 5, 20, 20}
testing.expect_value(t, rect_intersect(a, b), Rect{5, 5, 10, 10})
testing.expect_value(t, rect_union(a, b), Rect{0, 0, 20, 20})
testing.expect_value(t, rect_intersect(a, Rect{100, 100, 110, 110}), RECT_EMPTY)
// The infinite rect is the identity for intersection, which is what an
// unset clip path needs to be.
testing.expect_value(t, rect_intersect(a, RECT_INFINITE), a)
testing.expect_value(t, rect_intersect(RECT_INFINITE, a), a)
// The empty rect is the identity for union.
testing.expect_value(t, rect_union(a, RECT_EMPTY), a)
testing.expect_value(t, rect_union(RECT_EMPTY, a), a)
testing.expect_value(t, rect_width(a), 10)
testing.expect_value(t, rect_height(a), 10)
testing.expect(t, rect_is_empty(Rect{5, 5, 5, 10}), "zero width is empty")
testing.expect(t, rect_is_empty(Rect{10, 0, 5, 10}), "inverted is empty")
testing.expect(t, !rect_is_empty(a), "")
testing.expect(t, rect_contains(a, Point{5, 5}), "")
testing.expect(t, rect_contains(a, Point{0, 0}), "the boundary counts")
testing.expect(t, !rect_contains(a, Point{11, 5}), "")
testing.expect_value(t, rect_expand(a, 2), Rect{-2, -2, 12, 12})
testing.expect_value(t, rect_include(a, Point{-5, 20}), Rect{-5, 0, 10, 20})
testing.expect_value(t, rect_include(RECT_EMPTY, Point{3, 4}), Rect{3, 4, 3, 4})
}
// A /MediaBox is explicitly allowed to give its corners in either order.
@(test)
test_rect_normalize :: proc(t: ^testing.T) {
testing.expect_value(t, rect_normalize(Rect{612, 792, 0, 0}), Rect{0, 0, 612, 792})
testing.expect_value(t, rect_normalize(Rect{0, 0, 612, 792}), Rect{0, 0, 612, 792})
}
// ---------------------------------------------------------------------------
// Reading geometry out of objects
// ---------------------------------------------------------------------------
@(test)
test_object_to_rect_and_matrix :: proc(t: ^testing.T) {
arena: virtual.Arena
_ = virtual.arena_init_growing(&arena)
defer virtual.arena_destroy(&arena)
a := virtual.arena_allocator(&arena)
box := new_array(a)
array_push(box, i64(612))
array_push(box, i64(792))
array_push(box, i64(0))
array_push(box, f64(0))
r, ok := object_to_rect(nil, box)
testing.expect(t, ok, "a four-number array is a rectangle")
testing.expect_value(t, r, Rect{0, 0, 612, 792})
short := new_array(a)
array_push(short, i64(1))
_, ok2 := object_to_rect(nil, short)
testing.expect(t, !ok2, "three numbers is not a rectangle")
_, ok3 := object_to_rect(nil, nil)
testing.expect(t, !ok3, "null is not a rectangle")
_, ok4 := object_to_rect(nil, i64(7))
testing.expect(t, !ok4, "an integer is not a rectangle")
// A non-finite entry must be refused, not passed on to the rasteriser.
bad := new_array(a)
for _ in 0 ..< 3 { array_push(bad, f64(0)) }
array_push(bad, math.inf_f64(1))
_, ok5 := object_to_rect(nil, bad)
testing.expect(t, !ok5, "an infinite corner is not a rectangle")
mtx := new_array(a)
for v in ([]f64{1, 2, 3, 4, 5, 6}) { array_push(mtx, v) }
m, ok6 := object_to_matrix(nil, mtx)
testing.expect(t, ok6, "a six-number array is a matrix")
testing.expect_value(t, m, Matrix{1, 2, 3, 4, 5, 6})
_, ok7 := object_to_matrix(nil, box)
testing.expect(t, !ok7, "four numbers is not a matrix")
}
@(test)
test_dict_rect_and_matrix_defaults :: proc(t: ^testing.T) {
arena: virtual.Arena
_ = virtual.arena_init_growing(&arena)
defer virtual.arena_destroy(&arena)
a := virtual.arena_allocator(&arena)
d := new_dict(a)
testing.expect_value(t, dict_rect(nil, d, "MediaBox", DEFAULT_PAGE_RECT), DEFAULT_PAGE_RECT)
testing.expect_value(t, dict_matrix(nil, d, "Matrix"), MATRIX_IDENTITY)
box := new_array(a)
for v in ([]i64{0, 0, 200, 100}) { array_push(box, v) }
dict_set(d, "MediaBox", box)
testing.expect_value(t, dict_rect(nil, d, "MediaBox", DEFAULT_PAGE_RECT), Rect{0, 0, 200, 100})
// A nil dictionary must fall back rather than crash.
testing.expect_value(t, dict_rect(nil, nil, "MediaBox", DEFAULT_PAGE_RECT), DEFAULT_PAGE_RECT)
}
@(test)
test_finite_guards :: proc(t: ^testing.T) {
testing.expect(t, is_finite(0), "")
testing.expect(t, is_finite(-1e300), "")
testing.expect(t, !is_finite(math.nan_f64()), "")
testing.expect(t, !is_finite(math.INF_F64), "")
testing.expect(t, !is_finite(math.NEG_INF_F64), "")
testing.expect_value(t, clamp_finite(5, 99), 5)
testing.expect_value(t, clamp_finite(math.nan_f64(), 99), 99)
testing.expect_value(t, clamp_finite(math.INF_F64, 99), 99)
testing.expect(t, rect_is_finite(Rect{0, 0, 1, 1}), "")
testing.expect(t, !rect_is_finite(Rect{0, 0, math.nan_f64(), 1}), "")
}
// rotate_matrix builds its entries from sin and cos, so a quarter turn carries
// a 6e-17 where an exact zero would be. Every /Rotate 90 page in existence goes
// through this path.
@(test)
test_matrix_is_rectilinear_tolerates_rotation_error :: proc(t: ^testing.T) {
for deg in ([]f64{0, 90, 180, 270, -90, 360}) {
testing.expect(t, matrix_is_rectilinear(rotate_matrix(deg)),
"a quarter turn maps axis-aligned rectangles to axis-aligned rectangles")
}
for deg in ([]f64{1, 30, 45, 89, 100}) {
testing.expect(t, !matrix_is_rectilinear(rotate_matrix(deg)),
"an off-axis rotation does not")
}
// Scale must not change the answer: the tolerance is relative.
tiny := matrix_mul(rotate_matrix(90), scale_matrix(1e-6, 1e-6))
testing.expect(t, matrix_is_rectilinear(tiny), "a tiny rotated matrix is still rectilinear")
huge := matrix_mul(rotate_matrix(45), scale_matrix(1e9, 1e9))
testing.expect(t, !matrix_is_rectilinear(huge), "a huge skewed matrix is still not")
}