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) } } }