Files
pdf-odin-llm/stroke_test.odin
2026-09-25 23:32:29 +02:00

1374 lines
53 KiB
Odin

package pdf
import "core:math"
import "core:testing"
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@(private = "file")
run_stroke :: proc(p: ^Path, st: Stroke_State, ctm := MATRIX_IDENTITY,
flatness := FLATNESS) -> (Flat_Path, Error) {
fp := flat_path_make()
state := st
err := stroke_flatten(&fp, p, &state, ctm, flatness)
return fp, err
}
@(private = "file")
seg_path :: proc(x0, y0, x1, y1: f64) -> Path {
p := path_make()
path_move_to(&p, x0, y0)
path_line_to(&p, x1, y1)
return p
}
@(private = "file")
sub_bounds :: proc(fp: ^Flat_Path, i: int) -> Rect {
pts := flat_path_slice(fp, i)
if len(pts) == 0 { return RECT_EMPTY }
r := Rect{pts[0].x, pts[0].y, pts[0].x, pts[0].y}
for pt in pts[1:] {
r.x0 = min(r.x0, pt.x); r.y0 = min(r.y0, pt.y)
r.x1 = max(r.x1, pt.x); r.y1 = max(r.y1, pt.y)
}
return r
}
// winding_at is the nonzero winding number of the whole outline at a point,
// counted the way the rasteriser will count it. Every contour the stroker
// emits is closed, so the edge from the last point back to the first counts.
@(private = "file")
winding_at :: proc(fp: ^Flat_Path, px, py: f64) -> int {
w := 0
for i in 0 ..< flat_path_count(fp) {
pts := flat_path_slice(fp, i)
if len(pts) < 2 { continue }
for k in 0 ..< len(pts) {
a := pts[k]
b := pts[(k + 1) % len(pts)]
if a.y <= py && b.y > py {
if a.x + (py - a.y) * (b.x - a.x) / (b.y - a.y) > px { w += 1 }
} else if b.y <= py && a.y > py {
if a.x + (py - a.y) * (b.x - a.x) / (b.y - a.y) > px { w -= 1 }
}
}
}
return w
}
@(private = "file")
covered :: proc(fp: ^Flat_Path, px, py: f64) -> bool {
return winding_at(fp, px, py) != 0
}
@(private = "file")
has_point :: proc(fp: ^Flat_Path, px, py, eps: f64) -> bool {
for pt in fp.points {
if abs(pt.x - px) <= eps && abs(pt.y - py) <= eps { return true }
}
return false
}
@(private = "file")
near :: proc(a, b, eps: f64) -> bool {
return abs(a - b) <= eps
}
// ---------------------------------------------------------------------------
// Caps
// ---------------------------------------------------------------------------
// A 100-long horizontal segment of width 10 has an outline whose box is
// determined exactly by the cap style: butt stops at the ends, projecting
// square overhangs by half the width, round reaches the same overhang but as
// a polygon inscribed in the semicircle, so it falls short by at most the
// flatness tolerance.
@(test)
test_stroke_cap_bounds :: proc(t: ^testing.T) {
p := seg_path(0, 0, 100, 0)
defer path_destroy(&p)
st := STROKE_STATE_DEFAULT
st.line_width = 10
{
st.line_cap = .Butt
fp, err := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect_value(t, err, Error.None)
testing.expect_value(t, flat_path_count(&fp), 1)
testing.expect_value(t, flat_path_bounds(&fp), Rect{0, -5, 100, 5})
}
{
st.line_cap = .Square
fp, err := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect_value(t, err, Error.None)
testing.expect_value(t, flat_path_bounds(&fp), Rect{-5, -5, 105, 5})
}
{
st.line_cap = .Round
fp, err := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect_value(t, err, Error.None)
b := flat_path_bounds(&fp)
// The round cap never overshoots and never falls short by more than
// the flatness tolerance.
testing.expect(t, b.x0 <= -5 + FLATNESS && b.x0 >= -5, "round cap start overhang")
testing.expect(t, b.x1 >= 105 - FLATNESS && b.x1 <= 105, "round cap end overhang")
testing.expect(t, near(b.y0, -5, 1e-12) && near(b.y1, 5, 1e-12), "round cap sides")
// Every cap point is on the circle of radius 5 about an end point.
for pt in fp.points {
d0 := stroke_len(pt.x - 0, pt.y)
d1 := stroke_len(pt.x - 100, pt.y)
on_body := pt.x >= -1e-12 && pt.x <= 100 + 1e-12 && near(abs(pt.y), 5, 1e-12)
testing.expect(t, on_body || near(d0, 5, 1e-9) || near(d1, 5, 1e-9),
"round cap points lie on the end circles")
}
}
}
// ---------------------------------------------------------------------------
// Joins
// ---------------------------------------------------------------------------
// A right angle: the miter ratio is sqrt(2), so a limit of 10 keeps the spike
// and a limit of 1.2 replaces it with a bevel. The three styles are told apart
// by two probe points in the outer corner rather than by the bounding box,
// which is the same for all of them.
@(test)
test_stroke_joins :: proc(t: ^testing.T) {
p := path_make()
defer path_destroy(&p)
path_move_to(&p, 0, 0)
path_line_to(&p, 10, 0)
path_line_to(&p, 10, 10)
st := STROKE_STATE_DEFAULT
st.line_width = 2 // radius 1: the outer corner is the unit square
// [10,11] x [-1,0]
st.line_cap = .Butt
// Inside the bevel triangle, inside the round arc, inside the miter.
IN_ALL_X :: 10.3
IN_ALL_Y :: -0.2
// Outside the bevel, inside the round arc and the miter.
ROUND_X :: 10.6
ROUND_Y :: -0.6
// Outside the bevel and the round arc, inside the miter only.
MITER_X :: 10.9
MITER_Y :: -0.9
{
st.line_join = .Miter
st.miter_limit = 10
fp, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect(t, has_point(&fp, 11, -1, 1e-9), "miter point should be emitted")
testing.expect(t, covered(&fp, IN_ALL_X, IN_ALL_Y), "miter covers the corner")
testing.expect(t, covered(&fp, ROUND_X, ROUND_Y), "miter covers past the bevel")
testing.expect(t, covered(&fp, MITER_X, MITER_Y), "miter covers past the arc")
}
{
// sqrt(2) > 1.2, so the limit is exceeded and the join bevels.
st.line_join = .Miter
st.miter_limit = 1.2
fp, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect(t, !has_point(&fp, 11, -1, 1e-9), "miter limit should suppress the spike")
testing.expect(t, covered(&fp, IN_ALL_X, IN_ALL_Y), "bevel still covers the corner")
testing.expect(t, !covered(&fp, ROUND_X, ROUND_Y), "bevel stops short of the arc")
}
{
st.line_join = .Bevel
st.miter_limit = 10
fp, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect(t, !has_point(&fp, 11, -1, 1e-9), "bevel emits no miter point")
testing.expect(t, covered(&fp, IN_ALL_X, IN_ALL_Y), "bevel covers the corner")
testing.expect(t, !covered(&fp, ROUND_X, ROUND_Y), "bevel stops short of the arc")
}
{
st.line_join = .Round
fp, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect(t, covered(&fp, ROUND_X, ROUND_Y), "round join covers past the bevel")
testing.expect(t, !covered(&fp, MITER_X, MITER_Y), "round join stops short of the miter")
// Everything the join adds sits on the circle of radius 1 about the
// corner.
for pt in fp.points {
if pt.x > 10 + 1e-9 && pt.y < -1e-9 {
testing.expect(t, near(stroke_len(pt.x - 10, pt.y), 1, 1e-9),
"round join points lie on the corner circle")
}
}
}
}
// The miter limit is compared against the miter length over the line width, so
// it must fire on the angle and not on the width: the same corner at a
// different width behaves identically.
@(test)
test_stroke_miter_limit_is_scale_free :: proc(t: ^testing.T) {
p := path_make()
defer path_destroy(&p)
path_move_to(&p, 0, 0)
path_line_to(&p, 100, 0)
path_line_to(&p, 100, 100)
st := STROKE_STATE_DEFAULT
st.line_cap = .Butt
st.line_join = .Miter
st.line_width = 20 // radius 10, miter point at (110, -10)
st.miter_limit = 1.5 // sqrt(2) = 1.414 < 1.5, so the spike survives
fp, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect(t, has_point(&fp, 110, -10, 1e-9), "miter survives at 1.5")
st.miter_limit = 1.4 // sqrt(2) = 1.414 > 1.4, so it does not
fp2, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp2)
testing.expect(t, !has_point(&fp2, 110, -10, 1e-9), "miter is cut at 1.4")
}
// A 30-degree corner has a miter ratio of 1/sin(15 deg) = 3.86, so the PDF
// default limit of 10 keeps it and a limit of 3 does not. This is the case
// the limit exists for: without it a nearly-doubled-back path grows a spike
// hundreds of times the line width.
@(test)
test_stroke_miter_sharp_corner :: proc(t: ^testing.T) {
p := path_make()
defer path_destroy(&p)
ang := 30.0 * math.PI / 180
path_move_to(&p, 100, 0)
path_line_to(&p, 0, 0)
path_line_to(&p, 100 * math.cos(ang), 100 * math.sin(ang))
st := STROKE_STATE_DEFAULT
st.line_width = 2
st.line_join = .Miter
st.line_cap = .Butt
ratio := 1 / math.sin(ang / 2) // 3.8637...
st.miter_limit = 10
fp, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp)
b := flat_path_bounds(&fp)
// The spike runs backwards out of the corner along the bisector, to a
// distance of ratio * radius: x = -cos(15 deg) / sin(15 deg).
testing.expectf(t, near(b.x0, -math.cos(ang / 2) * ratio, 1e-9),
"sharp miter should reach x = %v, got %v", -math.cos(ang / 2) * ratio, b.x0)
testing.expect(t, has_point(&fp, -math.cos(ang / 2) * ratio, -1, 1e-9),
"the miter point itself is emitted")
st.miter_limit = 3
fp2, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp2)
b2 := flat_path_bounds(&fp2)
// Bevelled, so the outline stops at the offset point of the second
// segment: x = -sin(30 deg) * radius.
testing.expect(t, near(b2.x0, -math.sin(ang), 1e-9),
"miter limit 3 cuts the spike back to a bevel")
}
// ---------------------------------------------------------------------------
// Hairlines
// ---------------------------------------------------------------------------
// "0 w" means the thinnest line the device can draw (8.4.3.2), and so does any
// width that would come out under a pixel. Both must be exactly one device
// pixel across whatever the CTM does; a width that is genuinely visible must
// not be promoted.
@(test)
test_stroke_hairline :: proc(t: ^testing.T) {
p := seg_path(0, 0, 1, 0)
defer path_destroy(&p)
st := STROKE_STATE_DEFAULT
st.line_cap = .Butt
Case :: struct { width: f64, scale: f64, want_h: f64, want_w: f64 }
cases := []Case{
{0, 1, 1, 1}, // 0 w at 1x
{0, 100, 1, 100}, // 0 w at 100x
{0.001, 1, 1, 1}, // sub-pixel at 1x
{0.001, 100, 1, 100}, // still sub-pixel at 100x (0.1 device units)
{0.02, 100, 2, 100}, // 2 device units: a real stroke, not promoted
{2, 1, 2, 1}, // 2 units at 1x: not promoted
}
for c in cases {
st.line_width = c.width
fp, err := run_stroke(&p, st, scale_matrix(c.scale, c.scale))
defer flat_path_destroy(&fp)
testing.expect_value(t, err, Error.None)
b := flat_path_bounds(&fp)
testing.expectf(t, near(rect_height(b), c.want_h, 1e-9),
"width %v at %vx should be %v device units thick, got %v",
c.width, c.scale, c.want_h, rect_height(b))
testing.expectf(t, near(rect_width(b), c.want_w, 1e-9),
"width %v at %vx should be %v device units long, got %v",
c.width, c.scale, c.want_w, rect_width(b))
}
}
// A hairline is one pixel across in *every* direction, so it is built in
// device space: under an anisotropic CTM it must not come out elliptical.
@(test)
test_stroke_hairline_is_isotropic :: proc(t: ^testing.T) {
st := STROKE_STATE_DEFAULT
st.line_width = 0
st.line_cap = .Butt
ctm := scale_matrix(1, 40)
h := seg_path(0, 0, 10, 0)
defer path_destroy(&h)
fp, _ := run_stroke(&h, st, ctm)
defer flat_path_destroy(&fp)
testing.expect(t, near(rect_height(flat_path_bounds(&fp)), 1, 1e-9),
"hairline across a horizontal line is one pixel")
v := seg_path(0, 0, 0, 10)
defer path_destroy(&v)
fp2, _ := run_stroke(&v, st, ctm)
defer flat_path_destroy(&fp2)
testing.expect(t, near(rect_width(flat_path_bounds(&fp2)), 1, 1e-9),
"hairline across a vertical line is one pixel")
}
// ---------------------------------------------------------------------------
// Anisotropy
// ---------------------------------------------------------------------------
// Line width is a user-space quantity, so under `1 0 0 4 0 0 cm` a horizontal
// stroke is four times as thick as a vertical one. Scaling the width by
// matrix_expansion -- sqrt(|det|) = 2 here -- would make both 20 units and be
// wrong for both.
@(test)
test_stroke_anisotropic_ctm :: proc(t: ^testing.T) {
st := STROKE_STATE_DEFAULT
st.line_width = 10
st.line_cap = .Butt
ctm := scale_matrix(1, 4)
h := seg_path(0, 0, 100, 0)
defer path_destroy(&h)
fp, _ := run_stroke(&h, st, ctm)
defer flat_path_destroy(&fp)
testing.expect_value(t, flat_path_bounds(&fp), Rect{0, -20, 100, 20})
v := seg_path(0, 0, 0, 100)
defer path_destroy(&v)
fp2, _ := run_stroke(&v, st, ctm)
defer flat_path_destroy(&fp2)
testing.expect_value(t, flat_path_bounds(&fp2), Rect{-5, 0, 5, 400})
}
// The device thickness of a stroke depends on the direction it runs in, so a
// pen that is a whole pixel across one axis and a fiftieth across the other
// has to be floored on the narrow axis alone. Testing only the widest
// thickness leaves the result non-monotonic in the line width: the same rule
// comes out a clean hairline at `0 w` and invisible -- 0.02 of a pixel, a 1%
// grey -- at `1 w`, so widening the pen makes the line disappear.
@(test)
test_stroke_anisotropic_hairline_floor :: proc(t: ^testing.T) {
h := seg_path(0, 400, 40, 400)
defer path_destroy(&h)
st := STROKE_STATE_DEFAULT
st.line_cap = .Butt
ctm := Matrix{1, 0, 0, 0.02, 0, 0} // `1 0 0 0.02 0 0 cm`
// Every width whose thickness across this line is under a pixel draws the
// same hairline, in the same place: 40 device units long, one device
// pixel tall, centred on y = 8.
for w in ([5]f64{0, 0.999, 1, 1.5, 3}) {
st.line_width = w
fp, err := run_stroke(&h, st, ctm)
defer flat_path_destroy(&fp)
testing.expect_value(t, err, Error.None)
b := flat_path_bounds(&fp)
testing.expectf(t, near(rect_height(b), 1, 1e-9) && near(rect_width(b), 40, 1e-9) &&
near(b.y0, 7.5, 1e-9) && near(b.x0, 0, 1e-9),
"width %v under a 1:50 squash should draw a 40x1 hairline at y 7.5, got %v", w, b)
}
// Past a pixel the true thickness takes over: 60 * 0.02 = 1.2.
st.line_width = 60
wide, _ := run_stroke(&h, st, ctm)
defer flat_path_destroy(&wide)
testing.expect(t, near(rect_height(flat_path_bounds(&wide)), 1.2, 1e-9),
"a stroke over a pixel thick keeps its own thickness")
// The floor is on the short axis only. Under a CTM that squashes x by 50
// and stretches y by 10, a horizontal line is stretched ACROSS and must
// keep all ten pixels of it; the vertical one, a fiftieth of a pixel
// across, is the one that gets promoted. An averaged scale factor --
// sqrt(|det|) is 0.45 here -- would thin the horizontal line to 1.
ctm2 := Matrix{0.02, 0, 0, 10, 0, 0}
flat := seg_path(0, 2, 400, 2)
defer path_destroy(&flat)
tall := seg_path(400, 0, 400, 4)
defer path_destroy(&tall)
Case :: struct { width, want_flat_h, want_tall_w: f64 }
for c in ([3]Case{{0, 1, 1}, {1, 10, 1}, {3, 30, 1}}) {
st.line_width = c.width
fh, _ := run_stroke(&flat, st, ctm2)
defer flat_path_destroy(&fh)
ft, _ := run_stroke(&tall, st, ctm2)
defer flat_path_destroy(&ft)
testing.expectf(t, near(rect_height(flat_path_bounds(&fh)), c.want_flat_h, 1e-9),
"width %v across the stretched axis: want %v, got %v",
c.width, c.want_flat_h, rect_height(flat_path_bounds(&fh)))
testing.expectf(t, near(rect_width(flat_path_bounds(&ft)), c.want_tall_w, 1e-9),
"width %v across the squashed axis: want %v, got %v",
c.width, c.want_tall_w, rect_width(flat_path_bounds(&ft)))
}
}
// A rotation must not change the thickness, and a rotated stroke's corners
// must land where the rotation puts them.
@(test)
test_stroke_rotated_ctm :: proc(t: ^testing.T) {
p := seg_path(0, 0, 100, 0)
defer path_destroy(&p)
st := STROKE_STATE_DEFAULT
st.line_width = 10
st.line_cap = .Butt
fp, _ := run_stroke(&p, st, rotate_matrix(90))
defer flat_path_destroy(&fp)
b := flat_path_bounds(&fp)
testing.expect(t, near(rect_width(b), 10, 1e-9), "rotated stroke keeps its width")
testing.expect(t, near(rect_height(b), 100, 1e-9), "rotated stroke keeps its length")
}
// ---------------------------------------------------------------------------
// Dashes
// ---------------------------------------------------------------------------
// [10 10] on a 100-long line draws five 10-long dashes: 50 units of ink in
// five separate pieces, each a plain rectangle under butt caps.
@(test)
test_stroke_dash_pieces :: proc(t: ^testing.T) {
p := seg_path(0, 0, 100, 0)
defer path_destroy(&p)
st := STROKE_STATE_DEFAULT
st.line_width = 2
st.line_cap = .Butt
st.dash = {}
st.dash[0] = 10
st.dash[1] = 10
st.dash_count = 2
fp, err := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect_value(t, err, Error.None)
testing.expect_value(t, flat_path_count(&fp), 5)
drawn := 0.0
for i in 0 ..< flat_path_count(&fp) {
b := sub_bounds(&fp, i)
testing.expect_value(t, len(flat_path_slice(&fp, i)), 4)
testing.expectf(t, near(b.x0, f64(i) * 20, 1e-9), "dash %d starts at %v", i, b.x0)
testing.expectf(t, near(b.x1, f64(i) * 20 + 10, 1e-9), "dash %d ends at %v", i, b.x1)
testing.expect(t, near(b.y0, -1, 1e-9) && near(b.y1, 1, 1e-9), "dash keeps its width")
drawn += rect_width(b)
}
testing.expect(t, near(drawn, 50, 1e-9), "half the line is inked")
}
// The phase starts the pattern part-way in: five units of "on" are already
// spent, so the first dash is short and a sixth appears at the far end.
@(test)
test_stroke_dash_phase :: proc(t: ^testing.T) {
p := seg_path(0, 0, 100, 0)
defer path_destroy(&p)
st := STROKE_STATE_DEFAULT
st.line_width = 2
st.line_cap = .Butt
st.dash[0] = 10
st.dash[1] = 10
st.dash_count = 2
st.dash_phase = 5
fp, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect_value(t, flat_path_count(&fp), 6)
want_x0 := []f64{0, 15, 35, 55, 75, 95}
want_x1 := []f64{5, 25, 45, 65, 85, 100}
drawn := 0.0
for i in 0 ..< flat_path_count(&fp) {
b := sub_bounds(&fp, i)
testing.expectf(t, near(b.x0, want_x0[i], 1e-9), "dash %d starts at %v", i, b.x0)
testing.expectf(t, near(b.x1, want_x1[i], 1e-9), "dash %d ends at %v", i, b.x1)
drawn += rect_width(b)
}
testing.expect(t, near(drawn, 50, 1e-9), "half the line is still inked")
// A phase of a whole cycle is the same as no phase at all.
st.dash_phase = 20
fp2, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp2)
testing.expect_value(t, flat_path_count(&fp2), 5)
testing.expect(t, near(sub_bounds(&fp2, 0).x0, 0, 1e-9), "a full-cycle phase is a no-op")
}
// An odd-length pattern only repeats after twice its sum, because the second
// pass through it swaps on for off: [10] draws 0..10, 20..30, 40..50, and a
// phase of 10 shifts every dash by 10 rather than leaving it alone.
@(test)
test_stroke_dash_odd_pattern :: proc(t: ^testing.T) {
p := seg_path(0, 0, 60, 0)
defer path_destroy(&p)
st := STROKE_STATE_DEFAULT
st.line_width = 2
st.line_cap = .Butt
st.dash[0] = 10
st.dash_count = 1
fp, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect_value(t, flat_path_count(&fp), 3)
testing.expect(t, near(sub_bounds(&fp, 0).x0, 0, 1e-9), "first dash at 0")
testing.expect(t, near(sub_bounds(&fp, 1).x0, 20, 1e-9), "second dash at 20")
testing.expect(t, near(sub_bounds(&fp, 2).x0, 40, 1e-9), "third dash at 40")
st.dash_phase = 10
fp2, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp2)
testing.expect_value(t, flat_path_count(&fp2), 3)
testing.expect(t, near(sub_bounds(&fp2, 0).x0, 10, 1e-9),
"an odd pattern's phase of one entry inverts it rather than repeating")
}
// Every dash end gets a cap, so a round-capped dash pattern overhangs its own
// on-length at both ends.
@(test)
test_stroke_dash_caps_each_piece :: proc(t: ^testing.T) {
p := seg_path(0, 0, 100, 0)
defer path_destroy(&p)
st := STROKE_STATE_DEFAULT
st.line_width = 4 // radius 2
st.line_cap = .Square
st.dash[0] = 10
st.dash[1] = 10
st.dash_count = 2
fp, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect_value(t, flat_path_count(&fp), 5)
b := sub_bounds(&fp, 1)
testing.expect(t, near(b.x0, 18, 1e-9) && near(b.x1, 32, 1e-9),
"projecting square caps overhang both ends of every dash")
}
// A zero-length "on" entry is a dot, which is how `[0 5] 0 d` with round caps
// writes a dotted line. Under butt caps the same pattern paints nothing.
@(test)
test_stroke_dash_zero_length_dots :: proc(t: ^testing.T) {
p := seg_path(0, 0, 22, 0)
defer path_destroy(&p)
st := STROKE_STATE_DEFAULT
st.line_width = 4 // radius 2
st.dash[0] = 0
st.dash[1] = 5
st.dash_count = 2
st.line_cap = .Round
fp, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect_value(t, flat_path_count(&fp), 5)
for i in 0 ..< flat_path_count(&fp) {
// The polygon that approximates a circle is not symmetric about its
// centre, so check the vertices against the centre rather than the
// midpoint of the box: every one is exactly a pen radius away from
// where the dash pattern put the dot.
want := f64(i) * 5
pts := flat_path_slice(&fp, i)
testing.expect(t, len(pts) >= 4, "a round dot is a polygon")
for pt in pts {
testing.expectf(t, near(stroke_len(pt.x - want, pt.y), 2, 1e-9),
"dot %d: point %v is not on the circle of radius 2 at x = %v", i, pt, want)
}
testing.expectf(t, covered(&fp, want, 0), "dot %d is filled", i)
}
st.line_cap = .Butt
fp2, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp2)
testing.expect_value(t, flat_path_count(&fp2), 0)
}
// An all-zero or negative dash array is solid: a dasher that never advances
// would loop forever (8.4.3.6).
@(test)
test_stroke_dash_degenerate_is_solid :: proc(t: ^testing.T) {
p := seg_path(0, 0, 100, 0)
defer path_destroy(&p)
st := STROKE_STATE_DEFAULT
st.line_width = 2
st.line_cap = .Butt
st.dash[0] = 0
st.dash[1] = 0
st.dash_count = 2
fp, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect_value(t, flat_path_count(&fp), 1)
testing.expect_value(t, flat_path_bounds(&fp), Rect{0, -1, 100, 1})
st.dash[0] = 5
st.dash[1] = -5
fp2, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp2)
testing.expect_value(t, flat_path_count(&fp2), 1)
testing.expect_value(t, flat_path_bounds(&fp2), Rect{0, -1, 100, 1})
}
// A pattern whose whole cycle is smaller than a device pixel is drawn solid.
// Dashing it would emit one piece per sub-pixel step for no visible
// difference -- 5 million rectangles for this line.
@(test)
test_stroke_dash_subpixel_is_solid :: proc(t: ^testing.T) {
p := seg_path(0, 0, 1000, 0)
defer path_destroy(&p)
st := STROKE_STATE_DEFAULT
st.line_width = 2
st.line_cap = .Butt
st.dash[0] = 0.0001
st.dash[1] = 0.0001
st.dash_count = 2
fp, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect_value(t, flat_path_count(&fp), 1)
testing.expect_value(t, flat_path_bounds(&fp), Rect{0, -1, 1000, 1})
}
// A pattern that is visible in one direction but repeats far more often than
// there are pixels along the path is cut off by the budget, and the rest of
// the path is drawn solid rather than left out. The CTM here squashes x by
// 100 and stretches y by 100, so the cycle is 4 device units across a vertical
// stroke -- not sub-pixel -- while the line is only 100 device units long.
@(test)
test_stroke_dash_output_is_bounded :: proc(t: ^testing.T) {
p := seg_path(0, 0, 10000, 0)
defer path_destroy(&p)
st := STROKE_STATE_DEFAULT
st.line_width = 1
st.line_cap = .Butt
st.dash[0] = 0.02
st.dash[1] = 0.02
st.dash_count = 2
fp, err := run_stroke(&p, st, scale_matrix(0.01, 100))
defer flat_path_destroy(&fp)
testing.expect_value(t, err, Error.None)
// 250000 dashes would fit; the budget is the path's device length plus a
// small margin.
testing.expect(t, flat_path_count(&fp) > 1, "dashing happened at all")
testing.expectf(t, flat_path_count(&fp) <= 140,
"dash count is capped by device length, got %d", flat_path_count(&fp))
// The remainder is drawn, solid, all the way to the end of the path.
testing.expect(t, near(flat_path_bounds(&fp).x1, 100, 1e-6),
"the path is drawn to its end after the budget runs out")
}
// A dash is measured in user space, so the same pattern under a scaled CTM
// still produces the same number of pieces.
@(test)
test_stroke_dash_is_user_space :: proc(t: ^testing.T) {
p := seg_path(0, 0, 100, 0)
defer path_destroy(&p)
st := STROKE_STATE_DEFAULT
st.line_width = 2
st.line_cap = .Butt
st.dash[0] = 10
st.dash[1] = 10
st.dash_count = 2
fp, _ := run_stroke(&p, st, scale_matrix(3, 3))
defer flat_path_destroy(&fp)
testing.expect_value(t, flat_path_count(&fp), 5)
testing.expect(t, near(sub_bounds(&fp, 1).x0, 60, 1e-9),
"dash lengths scale with the CTM, the pattern does not")
}
// A dash that is still on when the pattern walks back round to the start of a
// closed subpath is one dash crossing that vertex, so the vertex gets a join
// like every other one a dash crosses (8.4.3.6). Emitting the two halves as
// separate capped pieces instead chips the corner of every dashed rectangle
// -- table cells, form boxes, cut-here frames -- at exactly one of its four
// corners.
@(test)
test_stroke_dash_closed_seam_is_joined :: proc(t: ^testing.T) {
p := path_make()
defer path_destroy(&p)
// Perimeter 80, seam at the corner (8, 8). The pattern's cycle is 12 and
// 80 = 6*12 + 8, so the walk comes back round with the dash still on.
path_rect(&p, 8, 8, 20, 20)
st := STROKE_STATE_DEFAULT
st.line_width = 2 // radius 1
st.line_join = .Miter
st.miter_limit = 10
st.line_cap = .Butt
st.dash[0] = 8
st.dash[1] = 4
st.dash_count = 2
fp, err := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect_value(t, err, Error.None)
// Seven dashes' worth of ink, but the first and the last are one dash
// wrapped round the seam, so six pieces come out.
testing.expect_value(t, flat_path_count(&fp), 6)
// The corners sit at 0, 20, 40 and 60 along the perimeter. A dash crosses
// the one at 40 and the seam at 0, so both get a miter; the dash ending at
// 20 and the one starting at 60 land exactly on their corners, so those
// two are butt-capped and their outer wedge stays clear. The test point is
// inside the miter and outside the bevel that would replace it.
testing.expect(t, covered(&fp, 7.4, 7.4), "the seam corner is mitered")
testing.expect(t, covered(&fp, 28.6, 28.6), "a corner a dash crosses is mitered")
testing.expect(t, !covered(&fp, 28.6, 7.4), "a corner a dash ends on is capped")
testing.expect(t, !covered(&fp, 7.4, 28.6), "a corner a dash starts on is capped")
testing.expect_value(t, flat_path_bounds(&fp), Rect{7, 7, 29, 29})
}
// The same seam with the pattern never going off at all: the whole closed
// subpath is a single dash, which is the solid closed stroke exactly -- two
// rings with a hole between them, not one capped strip.
@(test)
test_stroke_dash_closed_all_on_is_a_ring :: proc(t: ^testing.T) {
p := path_make()
defer path_destroy(&p)
path_rect(&p, 8, 8, 20, 20)
st := STROKE_STATE_DEFAULT
st.line_width = 2
st.line_join = .Miter
st.miter_limit = 10
st.line_cap = .Square // would show at once as a bulge at the seam
solid, _ := run_stroke(&p, st)
defer flat_path_destroy(&solid)
st.dash[0] = 200 // longer than the 80-unit perimeter
st.dash[1] = 5
st.dash_count = 2
fp, err := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect_value(t, err, Error.None)
testing.expect_value(t, flat_path_count(&fp), flat_path_count(&solid))
testing.expect_value(t, flat_path_bounds(&fp), flat_path_bounds(&solid))
testing.expect(t, covered(&fp, 7.4, 7.4), "the seam corner is mitered")
testing.expect_value(t, winding_at(&fp, 18, 18), 0)
testing.expect(t, len(fp.points) == len(solid.points), "the same outline as a solid stroke")
}
// ---------------------------------------------------------------------------
// Degenerate subpaths
// ---------------------------------------------------------------------------
// A single-point subpath paints a dot under round and projecting-square caps
// and nothing under butt caps. So does a closed subpath with no length.
@(test)
test_stroke_single_point_subpath :: proc(t: ^testing.T) {
st := STROKE_STATE_DEFAULT
st.line_width = 10 // radius 5
build :: proc(closed: bool) -> Path {
p := path_make()
path_move_to(&p, 5, 5)
if closed { path_close(&p) }
return p
}
for closed in ([2]bool{false, true}) {
p := build(closed)
defer path_destroy(&p)
st.line_cap = .Butt
fp, err := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect_value(t, err, Error.None)
testing.expect_value(t, flat_path_count(&fp), 0)
st.line_cap = .Square
fp2, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp2)
testing.expect_value(t, flat_path_count(&fp2), 1)
testing.expect_value(t, flat_path_bounds(&fp2), Rect{0, 0, 10, 10})
testing.expect(t, covered(&fp2, 5, 5), "the square dot is filled")
st.line_cap = .Round
fp3, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp3)
testing.expect_value(t, flat_path_count(&fp3), 1)
testing.expect(t, covered(&fp3, 5, 5), "the round dot is filled")
testing.expect(t, !covered(&fp3, 5 + 5.5, 5), "the round dot has radius 5")
for pt in fp3.points {
testing.expect(t, near(stroke_len(pt.x - 5, pt.y - 5), 5, 1e-9),
"every point of a round dot is on the circle")
}
}
}
// A subpath whose points all coincide is the same degenerate case, whether it
// arrived as one point or as several.
@(test)
test_stroke_repeated_point_subpath :: proc(t: ^testing.T) {
p := path_make()
defer path_destroy(&p)
path_move_to(&p, 5, 5)
path_line_to(&p, 5, 5)
path_line_to(&p, 5, 5)
st := STROKE_STATE_DEFAULT
st.line_width = 10
st.line_cap = .Square
fp, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect_value(t, flat_path_count(&fp), 1)
testing.expect_value(t, flat_path_bounds(&fp), Rect{0, 0, 10, 10})
}
// A closed subpath that doubles back on itself has joins at both ends rather
// than caps, and a 180-degree miter falls back to a bevel -- which is the same
// shape a butt cap would have given, so the outline is exactly the rectangle.
@(test)
test_stroke_closed_degenerate_line :: proc(t: ^testing.T) {
p := path_make()
defer path_destroy(&p)
path_move_to(&p, 0, 0)
path_line_to(&p, 10, 0)
path_close(&p)
st := STROKE_STATE_DEFAULT
st.line_width = 2
st.line_cap = .Square // ignored: a closed subpath is never capped
fp, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect_value(t, flat_path_bounds(&fp), Rect{0, -1, 10, 1})
testing.expect(t, covered(&fp, 5, 0), "the doubled-back stroke is filled")
}
// ---------------------------------------------------------------------------
// Closed subpaths
// ---------------------------------------------------------------------------
// A closed subpath joins its end to its start instead of capping, and needs
// two rings so the nonzero rule leaves the middle of the shape unpainted.
@(test)
test_stroke_closed_triangle :: proc(t: ^testing.T) {
p := path_make()
defer path_destroy(&p)
path_move_to(&p, 0, 0)
path_line_to(&p, 30, 0)
path_line_to(&p, 15, 20)
path_close(&p)
st := STROKE_STATE_DEFAULT
st.line_width = 2 // radius 1
st.line_join = .Miter
st.miter_limit = 10
st.line_cap = .Butt
fp, err := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect_value(t, err, Error.None)
// One outer ring and one inner ring. A capped (i.e. not closed) stroke
// would be a single contour.
testing.expect_value(t, flat_path_count(&fp), 2)
// The middle of the triangle is a hole: the two rings cancel exactly.
testing.expect_value(t, winding_at(&fp, 15, 20.0 / 3), 0)
// The edges are inked, on both sides of the path.
testing.expect(t, covered(&fp, 15, 0), "on the bottom edge")
testing.expect(t, covered(&fp, 15, -0.5), "outside the bottom edge")
testing.expect(t, covered(&fp, 15, 0.5), "inside the bottom edge")
// Just past half the line width is clear again.
testing.expect(t, !covered(&fp, 15, -1.5), "beyond the outer edge")
testing.expect(t, !covered(&fp, 15, 1.5), "beyond the inner edge")
// Joined, not capped: the corner at the origin grows a miter spike
// outwards. A butt cap would have stopped at x = 0.
b := flat_path_bounds(&fp)
testing.expect(t, b.x0 < -1.0, "the corner is mitered, not capped")
testing.expect(t, b.y0 < -0.999, "the outer ring clears the bottom edge")
}
// The same closed shape under round joins has no spikes at all: every point of
// the outline is within half a line width plus the tolerance of the path.
@(test)
test_stroke_closed_round_join_has_no_spikes :: proc(t: ^testing.T) {
p := path_make()
defer path_destroy(&p)
path_rect(&p, 0, 0, 20, 20)
st := STROKE_STATE_DEFAULT
st.line_width = 4 // radius 2
st.line_join = .Round
fp, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect_value(t, flat_path_count(&fp), 2)
b := flat_path_bounds(&fp)
testing.expect(t, b.x0 >= -2 - 1e-9 && b.y0 >= -2 - 1e-9, "round joins do not overshoot")
testing.expect(t, b.x1 <= 22 + 1e-9 && b.y1 <= 22 + 1e-9, "round joins do not overshoot")
testing.expect(t, near(b.x0, -2, FLATNESS) && near(b.x1, 22, FLATNESS),
"round joins reach the full offset")
testing.expect_value(t, winding_at(&fp, 10, 10), 0)
testing.expect(t, covered(&fp, 0, 10), "the left edge is inked")
}
// A closed rectangle is the commonest closed stroke there is; check the ink
// lands where it should on both sides of every edge.
@(test)
test_stroke_closed_rect_coverage :: proc(t: ^testing.T) {
p := path_make()
defer path_destroy(&p)
path_rect(&p, 10, 10, 80, 40)
st := STROKE_STATE_DEFAULT
st.line_width = 4 // radius 2
fp, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect(t, covered(&fp, 50, 10), "on the bottom edge")
testing.expect(t, covered(&fp, 50, 8.5), "outside the bottom edge")
testing.expect(t, covered(&fp, 50, 11.5), "inside the bottom edge")
testing.expect(t, !covered(&fp, 50, 7.5), "clear below")
testing.expect(t, !covered(&fp, 50, 13), "clear above")
testing.expect(t, !covered(&fp, 50, 30), "the middle is a hole")
testing.expect(t, covered(&fp, 10, 50), "the top-left corner is inked")
}
// ---------------------------------------------------------------------------
// Curves
// ---------------------------------------------------------------------------
// A stroked curve is an annulus about the flattened curve: every point of the
// outline is half a line width from the curve, give or take the tolerance.
@(test)
test_stroke_curve_band :: proc(t: ^testing.T) {
p := path_make()
defer path_destroy(&p)
path_move_to(&p, 0, 0)
path_curve_to(&p, 0, 100, 100, 100, 100, 0)
st := STROKE_STATE_DEFAULT
st.line_width = 10
st.line_join = .Round
st.line_cap = .Butt
fp, err := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect_value(t, err, Error.None)
testing.expect_value(t, flat_path_count(&fp), 1)
// The curve peaks at y = 75 (t = 0.5), so the outline must reach 80.
b := flat_path_bounds(&fp)
testing.expect(t, near(b.y1, 80, 0.5), "the outline clears the curve by half the width")
testing.expect(t, b.x0 >= -5 - 1e-9 && b.x1 <= 105 + 1e-9, "the outline does not overshoot")
// The centre of the curve is inked and a point well outside the band is
// not.
testing.expect(t, covered(&fp, 50, 75), "the crest of the curve is inked")
testing.expect(t, !covered(&fp, 50, 40), "the middle of the arc is empty")
testing.expect(t, !covered(&fp, 50, 90), "above the curve is empty")
for pt in fp.points {
testing.expect(t, is_finite(pt.x) && is_finite(pt.y), "no non-finite points")
}
}
// A thin pen turns the near-collinear joins that a flattened curve is full of
// into sub-tolerance detail, and they must then cost nothing: two offset
// points per segment per side is the floor, and a join point at every vertex
// would push the outline half as large again.
@(test)
test_stroke_curve_stays_compact :: proc(t: ^testing.T) {
p := path_make()
defer path_destroy(&p)
path_move_to(&p, 0, 0)
path_curve_to(&p, 0, 100, 100, 100, 100, 0)
flat := flat_path_make()
defer flat_path_destroy(&flat)
path_flatten(&flat, &p, MATRIX_IDENTITY)
segments := len(flat.points) - 1
st := STROKE_STATE_DEFAULT
st.line_width = 1 // radius 0.5: every join is under the tolerance
st.line_join = .Miter
fp, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expectf(t, len(fp.points) <= 4 * segments + 4,
"stroked curve used %d points for %d segments", len(fp.points), segments)
// A wide pen is a different matter: at radius 5 the same turns move the
// offset by half a unit, so the joins are real geometry and must be kept.
st.line_width = 10
wide, _ := run_stroke(&p, st)
defer flat_path_destroy(&wide)
testing.expect(t, len(wide.points) > 4 * segments + 4,
"a wide pen keeps the joins it needs")
}
// ---------------------------------------------------------------------------
// Accumulation, robustness
// ---------------------------------------------------------------------------
// Existing contents of `out` survive, so several strokes can be accumulated
// into one path.
@(test)
test_stroke_appends_to_out :: proc(t: ^testing.T) {
rect := path_make()
defer path_destroy(&rect)
path_rect(&rect, 0, 0, 10, 10)
fp := flat_path_make()
defer flat_path_destroy(&fp)
path_flatten(&fp, &rect, MATRIX_IDENTITY)
testing.expect_value(t, flat_path_count(&fp), 1)
line := seg_path(100, 100, 200, 100)
defer path_destroy(&line)
st := STROKE_STATE_DEFAULT
st.line_width = 2
st.line_cap = .Butt
err := stroke_flatten(&fp, &line, &st, MATRIX_IDENTITY)
testing.expect_value(t, err, Error.None)
testing.expect_value(t, flat_path_count(&fp), 2)
testing.expect_value(t, sub_bounds(&fp, 0), Rect{0, 0, 10, 10})
testing.expect_value(t, sub_bounds(&fp, 1), Rect{100, 99, 200, 101})
}
@(test)
test_stroke_rejects_bad_arguments :: proc(t: ^testing.T) {
p := seg_path(0, 0, 10, 0)
defer path_destroy(&p)
fp := flat_path_make()
defer flat_path_destroy(&fp)
st := STROKE_STATE_DEFAULT
testing.expect_value(t, stroke_flatten(nil, &p, &st, MATRIX_IDENTITY), Error.Invalid_Argument)
testing.expect_value(t, stroke_flatten(&fp, nil, &st, MATRIX_IDENTITY), Error.Invalid_Argument)
nan := math.nan_f64()
bad := Matrix{1, 0, 0, 1, nan, 0}
testing.expect_value(t, stroke_flatten(&fp, &p, &st, bad), Error.Invalid_Argument)
testing.expect_value(t, flat_path_count(&fp), 0)
// A nil state is the PDF default state, not a crash.
testing.expect_value(t, stroke_flatten(&fp, &p, nil, MATRIX_IDENTITY), Error.None)
testing.expect_value(t, flat_path_bounds(&fp), Rect{0, -0.5, 10, 0.5})
// An empty path is nothing to do, not an error.
empty := path_make()
defer path_destroy(&empty)
fp2 := flat_path_make()
defer flat_path_destroy(&fp2)
testing.expect_value(t, stroke_flatten(&fp2, &empty, &st, MATRIX_IDENTITY), Error.None)
testing.expect_value(t, flat_path_count(&fp2), 0)
}
// A CTM that collapses the plane onto a line or a point maps the outline to
// zero area. Content is hidden this way on purpose, so it is not an error --
// but nothing may be emitted, and certainly nothing non-finite.
@(test)
test_stroke_singular_ctm :: proc(t: ^testing.T) {
p := seg_path(0, 0, 10, 10)
defer path_destroy(&p)
st := STROKE_STATE_DEFAULT
st.line_width = 5
for ctm in ([3]Matrix{
Matrix{1, 0, 0, 0, 0, 0}, // flattened onto the x axis
Matrix{0, 0, 0, 0, 0, 0}, // collapsed to a point
Matrix{1, 1, 2, 2, 0, 0}, // rank one
}) {
fp, err := run_stroke(&p, st, ctm)
defer flat_path_destroy(&fp)
testing.expect_value(t, err, Error.None)
testing.expect_value(t, flat_path_count(&fp), 0)
}
}
// A CTM and a line width that are each finite, and each accepted by every
// guard on the way in, can still multiply into an infinity -- or, once a
// rotation adds +Inf to -Inf, into a NaN. The finiteness check in stroke_emit
// runs in BUILD space and cannot see it, so the product must be caught where
// the outline is transformed. Nothing below the Device line may hold either
// (ARCHITECTURE.md rule 5); a Flat_Path is read by the clip-mask builder and
// the display list as well as by the rasteriser, and only the rasteriser
// clamps. Both literals are ones `parse_number` accepts out of a real file.
@(test)
test_stroke_overflowing_ctm_emits_nothing :: proc(t: ^testing.T) {
p := seg_path(0, 0, 1, 1)
defer path_destroy(&p)
st := STROKE_STATE_DEFAULT
st.line_width = 1e160
for ctm in ([3]Matrix{
Matrix{1e150, 0, 0, 1e150, 0, 0}, // the offsets overflow
Matrix{1e150, 1e150, -1e150, 1e150, 0, 0}, // rotated: +Inf plus -Inf
Matrix{1e300, 0, 0, 1e300, 0, 0},
}) {
fp, err := run_stroke(&p, st, ctm)
defer flat_path_destroy(&fp)
testing.expect_value(t, err, Error.None)
for pt in fp.points {
testing.expectf(t, is_finite(pt.x) && is_finite(pt.y),
"ctm %v let the non-finite point %v through", ctm, pt)
}
// Every contour of this stroke overflows, so there is nothing left to
// paint and no bounds to compute from.
testing.expect_value(t, flat_path_count(&fp), 0)
testing.expect_value(t, flat_path_bounds(&fp), RECT_EMPTY)
}
}
// Nonsense in the stroke state must degrade, not crash or hang: a negative
// width, a non-finite width, a miter limit below the geometric minimum of 1,
// a non-finite phase and an over-long dash count are all things files carry.
@(test)
test_stroke_bad_state_degrades :: proc(t: ^testing.T) {
p := seg_path(0, 0, 100, 0)
defer path_destroy(&p)
st := STROKE_STATE_DEFAULT
st.line_cap = .Butt
st.line_width = -4
fp, err := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect_value(t, err, Error.None)
// A negative width is treated as zero, so it draws a hairline.
testing.expect_value(t, flat_path_bounds(&fp), Rect{0, -0.5, 100, 0.5})
st.line_width = math.nan_f64()
fp2, _ := run_stroke(&p, st)
defer flat_path_destroy(&fp2)
testing.expect_value(t, flat_path_bounds(&fp2), Rect{0, -0.5, 100, 0.5})
// A non-finite miter limit falls back to the default rather than
// silently bevelling every join in the document.
st.line_width = 2
st.line_cap = .Butt
st.line_join = .Miter
st.miter_limit = math.nan_f64()
corner := path_make()
defer path_destroy(&corner)
path_move_to(&corner, 0, 0)
path_line_to(&corner, 10, 0)
path_line_to(&corner, 10, 10)
fpm, _ := run_stroke(&corner, st)
defer flat_path_destroy(&fpm)
testing.expect(t, has_point(&fpm, 11, -1, 1e-9), "a NaN miter limit uses the default of 10")
st.line_width = 10
st.miter_limit = 0
st.dash_phase = math.inf_f64(1)
st.dash[0] = 10
st.dash[1] = 10
st.dash_count = 999 // beyond MAX_DASH_ENTRIES
fp3, err3 := run_stroke(&p, st)
defer flat_path_destroy(&fp3)
testing.expect_value(t, err3, Error.None)
testing.expect(t, flat_path_count(&fp3) > 0, "a garbage state still draws something")
for pt in fp3.points {
testing.expect(t, is_finite(pt.x) && is_finite(pt.y), "no non-finite points escape")
}
}
// A path with thousands of subpaths and joins must terminate and stay within
// the output bound.
@(test)
test_stroke_many_subpaths :: proc(t: ^testing.T) {
p := path_make()
defer path_destroy(&p)
for i in 0 ..< 2000 {
x := f64(i)
path_move_to(&p, x, 0)
path_line_to(&p, x + 0.5, 1)
path_line_to(&p, x + 1, 0)
}
st := STROKE_STATE_DEFAULT
st.line_width = 3
st.line_join = .Round
st.line_cap = .Round
fp, err := run_stroke(&p, st)
defer flat_path_destroy(&fp)
testing.expect_value(t, err, Error.None)
testing.expect_value(t, flat_path_count(&fp), 2000)
testing.expect(t, len(fp.points) < MAX_STROKE_POINTS, "output stays inside its bound")
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// The hairline test and the dash-visibility test both key off the extreme
// scale factors of the CTM, so they had better be the real singular values.
@(test)
test_stroke_scale_extremes :: proc(t: ^testing.T) {
check :: proc(t: ^testing.T, m: Matrix, want_lo, want_hi: f64) {
lo, hi := stroke_scale_extremes(m)
testing.expectf(t, near(lo, want_lo, 1e-9) && near(hi, want_hi, 1e-9),
"extremes of %v: got (%v, %v) want (%v, %v)", m, lo, hi, want_lo, want_hi)
// Brute force: no direction is stretched outside [lo, hi].
for i in 0 ..< 360 {
ang := f64(i) * math.PI / 180
v := transform_vector(m, Point{math.cos(ang), math.sin(ang)})
l := stroke_len(v.x, v.y)
testing.expect(t, l >= lo - 1e-9 && l <= hi + 1e-9, "direction stretched outside range")
}
}
check(t, MATRIX_IDENTITY, 1, 1)
check(t, scale_matrix(100, 100), 100, 100)
check(t, scale_matrix(1, 4), 1, 4)
check(t, scale_matrix(-3, 2), 2, 3)
check(t, rotate_matrix(37), 1, 1)
check(t, matrix_mul(scale_matrix(2, 5), rotate_matrix(30)), 2, 5)
check(t, Matrix{0, 0, 0, 0, 9, 9}, 0, 0)
}
// The floored pen must not move the path an inch: `post` composed after
// `to_build` is the CTM itself. What changes is only the pen, whose short
// device semi-axis comes out at half a pixel and whose long one is left at
// half the line width times the CTM's largest stretch.
@(test)
test_stroke_squashed_pen :: proc(t: ^testing.T) {
for m in ([5]Matrix{
Matrix{1, 0, 0, 0.02, 0, 0},
Matrix{0.02, 0, 0, 10, 0, 0},
matrix_mul(Matrix{1, 0, 0, 0.02, 0, 0}, rotate_matrix(30)),
matrix_mul(rotate_matrix(-72), Matrix{0.5, 0, 0, 0.001, 3, -4}),
Matrix{1, 0.4, -0.3, 0.01, 12, 7},
}) {
width := 1.0
lo, hi := stroke_scale_extremes(m)
post, to_build, ok := stroke_squashed_pen(m, width, lo, hi)
testing.expectf(t, ok, "no pen for %v", m)
c := matrix_mul(to_build, post)
testing.expectf(t,
near(c.a, m.a, 1e-9) && near(c.b, m.b, 1e-9) && near(c.c, m.c, 1e-9) &&
near(c.d, m.d, 1e-9) && near(c.e, m.e, 1e-9) && near(c.f, m.f, 1e-9),
"%v: post after to_build came out %v", m, c)
// post maps the build-space pen -- a circle of radius 1/2 -- onto the
// device ellipse, so its singular values are the semi-axes doubled.
plo, phi := stroke_scale_extremes(post)
testing.expectf(t, near(plo * 0.5, HAIRLINE_WIDTH * 0.5, 1e-9),
"%v: short semi-axis is %v, want half a pixel", m, plo * 0.5)
testing.expectf(t, near(phi * 0.5, max(width * 0.5 * hi, HAIRLINE_WIDTH * 0.5), 1e-9),
"%v: long semi-axis is %v, want %v", m, phi * 0.5, width * 0.5 * hi)
}
}
// The chord count for an arc has to come from the tolerance, and the result
// has to stay inside it whatever the radius.
@(test)
test_stroke_arc_steps :: proc(t: ^testing.T) {
for r in ([5]f64{0.5, 1, 10, 1000, 100000}) {
n := stroke_arc_steps(math.PI, r, 0.25, MAX_ARC_SEGMENTS)
testing.expect(t, n >= 1 && n <= MAX_ARC_SEGMENTS, "step count is bounded")
if n < MAX_ARC_SEGMENTS {
sagitta := r * (1 - math.cos(math.PI / f64(n) / 2))
testing.expectf(t, sagitta <= 0.25 + 1e-9,
"radius %v: %d chords leave a sagitta of %v", r, n, sagitta)
}
}
// A zero tolerance must saturate rather than divide by zero.
testing.expect_value(t, stroke_arc_steps(math.PI, 10, 0, MAX_ARC_SEGMENTS), MAX_ARC_SEGMENTS)
}
// ---------------------------------------------------------------------------
// Flattening through the clip
// ---------------------------------------------------------------------------
// The path is flattened before the pen is applied, so the caller's clip has to
// be grown by everything the pen adds -- half the widest device thickness and
// the spike a miter join can put on a corner -- before a curve may be judged
// out of reach. Too small a margin loses ink, and it is ink at the edge of the
// clip, where it is least likely to be noticed. The outline built with the
// clip in hand is therefore held to the one built without it, point by point
// over the whole clip.
//
// Dashes are the case where the shortcut is not available at all: they are
// laid out by arc length along the flattened polyline, so replacing an
// off-screen excursion with its chord shifts the phase of every dash after it.
// Both spellings are checked here.
@(test)
test_stroke_cull_keeps_the_same_ink :: proc(t: ^testing.T) {
// Two shapes: one whose curves leave the clip by thousands of units, which
// is what the cull is for, and one that sits just outside it -- close
// enough that a nine-wide pen paints back in, which is what the margin is
// for. The second is stroked on its own, because ink from the rest of a
// path would hide a difference at the edge.
far := path_make()
defer path_destroy(&far)
path_move_to(&far, 10, 10)
path_curve_to(&far, 3000, -3000, -3000, 3000, 90, 60)
path_curve_to(&far, -800, 1200, 1500, 900, 40, 20)
path_curve_to(&far, 5000, 5000, 6000, 5000, 80, 70)
path_line_to(&far, 10, 10)
near_edge := path_make()
defer path_destroy(&near_edge)
path_move_to(&near_edge, -2, 20)
path_curve_to(&near_edge, -6, 25, -6, 35, -2, 40)
paths := [2]^Path{&far, &near_edge}
for path, pi in paths {
for dashed in ([2]bool{false, true}) {
st := STROKE_STATE_DEFAULT
st.line_width = 9
st.line_cap = .Round
st.miter_limit = 10
if dashed {
st.dash_count = 2
st.dash[0] = 7
st.dash[1] = 3
}
full := flat_path_make()
defer flat_path_destroy(&full)
culled := flat_path_make()
defer flat_path_destroy(&culled)
testing.expect_value(t, stroke_flatten(&full, path, &st, MATRIX_IDENTITY), Error.None)
testing.expect_value(t, stroke_flatten(&culled, path, &st, MATRIX_IDENTITY, FLATNESS,
Rect{-1, -1, 121, 91}), Error.None)
ink, differ := 0, 0
for y := 0.5; y < 90; y += 1 {
for x := 0.5; x < 120; x += 1 {
a := covered(&full, x, y)
if a { ink += 1 }
if a != covered(&culled, x, y) { differ += 1 }
}
}
testing.expectf(t, ink > 20, "path %d dashed=%v: it has to reach the clip", pi, dashed)
testing.expectf(t, differ == 0, "path %d dashed=%v: %d sampled pixels differ",
pi, dashed, differ)
}
}
}