treeform / pixie

Full-featured 2d graphics library for Nim.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Gaps sometimes visible between filled paths which share an edge

patternspandemic opened this issue · comments

Greetings, and thank you for sharing Pixie!

I am not sure if this is to be expected or not (or even whether cairo shows similar behavior), although there are inconsistencies within pixie's behavior at minimum. Here is a 4x2 grid showcasing the behavior. Each cell is essentially filled with two white triangles, and sometimes a gap is visible between them even though their shared edge is specified from the same points. In case it is not obvious which cells due to monitor dpi or brightness:

|--------|-----------|-----------|--------|
| no gap | light gap | light gap | no gap |
|--------|-----------|-----------|--------|
|   gap  | light gap | light gap |   gap  |
|--------|-----------|-----------|--------|

fillIssue

For the top row, cw, and ccw refers to whether a triangle's path was specified in a clockwise or counter-clockwise manner. Each top row example is a single filled path.

For the second row, each example left to right:

  • Two paths separately filled (one for each triangle).
  • The same two paths added to another path which is filled.
  • The triangles defined in a single SVG string which is filled.
  • Each triangle filled from a separate SVG string.

Vertical and horizontal fills don't seem to exhibit this behavior, only diagonals.

The code which produced this image:

import pixie

var
  image = newImage(800, 400)
  cwcw, cwccw, ccwcw, ccwccw, p1, p2, p = newPath()
  font = readFont("Hack-Regular.ttf")

font.size = 18
image.fill("black")

#[ ROW 1 ]#

# cw, cw: no gap visible
cwcw.moveTo(0.0, 0.0)
cwcw.lineTo(200.0, 0.0)
cwcw.lineTo(200.0, 200.0)
cwcw.lineTo(0.0, 0.0)
cwcw.moveTo(200.0, 200.0)
cwcw.lineTo(0.0, 200.0)
cwcw.lineTo(0.0, 0.0)
cwcw.lineTo(200.0, 200.0)
image.fillPath(cwcw, "white")
image.fillText(font.typeset("cw, cw", vec2(200, 100), hAlign = CenterAlign, vAlign = MiddleAlign), translate(vec2(0, 100)))

# cw, ccw: light gap visible
cwccw.moveTo(0.0, 0.0)
cwccw.lineTo(200.0, 200.0)
cwccw.lineTo(0.0, 200.0)
cwccw.lineTo(0.0, 0.0)
cwccw.moveTo(200.0, 200.0)
cwccw.lineTo(200.0, 0.0)
cwccw.lineTo(0.0, 0.0)
cwccw.lineTo(200.0, 200.0)
image.fillPath(cwccw, "white", translate(vec2(200, 0)))
image.fillText(font.typeset("cw, ccw", vec2(200, 100), hAlign = CenterAlign, vAlign = MiddleAlign), translate(vec2(200, 100)))

# ccw, cw: light gap visible
ccwcw.moveTo(0.0, 0.0)
ccwcw.lineTo(0.0, 200.0)
ccwcw.lineTo(200.0, 200.0)
ccwcw.lineTo(0.0, 0.0)
ccwcw.moveTo(200.0, 200.0)
ccwcw.lineTo(0.0, 0.0)
ccwcw.lineTo(200.0, 0.0)
ccwcw.lineTo(200.0, 200.0)
image.fillPath(ccwcw, "white", translate(vec2(400, 0)))
image.fillText(font.typeset("ccw, cw", vec2(200, 100), hAlign = CenterAlign, vAlign = MiddleAlign), translate(vec2(400, 100)))

# ccw, ccw: no gap visible
ccwccw.moveTo(0.0, 0.0)
ccwccw.lineTo(0.0, 200.0)
ccwccw.lineTo(200.0, 200.0)
ccwccw.lineTo(0.0, 0.0)
ccwccw.moveTo(200.0, 200.0)
ccwccw.lineTo(200.0, 0.0)
ccwccw.lineTo(0.0, 0.0)
ccwccw.lineTo(200.0, 200.0)
image.fillPath(ccwccw, "white", translate(vec2(600, 0)))
image.fillText(font.typeset("ccw, ccw", vec2(200, 100), hAlign = CenterAlign, vAlign = MiddleAlign), translate(vec2(600, 100)))


#[ ROW 2 ]#

# p1, p2
# clear gap visible
p1.moveTo(0.0, 0.0)
p1.lineTo(0.0, 200.0)
p1.lineTo(200.0, 200.0)
p1.lineTo(0.0, 0.0)
p2.moveTo(200.0, 200.0)
p2.lineTo(0.0, 0.0)
p2.lineTo(200.0, 0.0)
p2.lineTo(200.0, 200.0)
image.fillPath(p1, "white", translate(vec2(0, 200)))
image.fillPath(p2, "white", translate(vec2(0, 200)))
image.fillText(font.typeset("p1, p2", vec2(200, 100), hAlign = CenterAlign, vAlign = MiddleAlign), translate(vec2(0, 300)))

# p
# light gap visible
p.addPath(p1)
p.addPath(p2)
image.fillPath(p, "white", translate(vec2(200, 200)))
image.fillText(font.typeset("p", vec2(200, 100), hAlign = CenterAlign, vAlign = MiddleAlign), translate(vec2(200, 300)))

# 1 SVG string
# light gap visible
image.fillPath(
  """
    M 0.0 0.0
    L 200.0 200.0
    L 0.0 200.0
    L 0.0 0.0
    M 200.0 200.0
    L 200.0 0.0
    L 0.0 0.0
    L 200.0 200.0
  """, "white", translate(vec2(400, 200)))
image.fillText(font.typeset("1 SVG string", vec2(200, 100), hAlign = CenterAlign, vAlign = MiddleAlign), translate(vec2(400, 300)))

# 2 SVG string
# clear gap visible
image.fillPath(
  """
    M 0 0
    L 200 200
    L 0 200
    L 0 0
  """, "white", translate(vec2(600, 200)))
image.fillPath(
  """
    M 200 200
    L 200 0
    L 0 0
    L 200 200
  """, "white", translate(vec2(600, 200)))
image.fillText(font.typeset("2 SVG string", vec2(200, 100), hAlign = CenterAlign, vAlign = MiddleAlign), translate(vec2(600, 300)))

image.writeFile("fillIssue.png")

In an image full of filled paths, this behavior makes it look as though strokes were applied when they were not. For example:

example

If you look closely, there are a few spots in the above image where 'strokes' seem to be missing. These are areas where shared edges are closely vertical or horizontal.

I assume this has everything to do with AA of fills. Thanks for taking a look.

Wow a very good bug report! I want to look at this more in what Cairo or Context do. I want to see if this issue can be fixed. Some on the edge of our fills with AA is not quite right.

I only have a minute to reply but the short version is that this is two separate issues:

For when there are two paths (or two SVG strings), the issue is premultiplied alpha compositing, this is basically unavoidable due to Pixie storing premulitplied alpha color values. I think filling each separate path into separate images and then compositing those images may produce better output but I would need to test. (Don't fill the image with black first if you want good color AA as well, draw the final composited image in top of a black image at the very end to avoid the black tainting the compositing steps.)

For CW-CCW alternation, this is intersection floating point values not perfectly matching and thus having tiny tiny tiny gaps leading to non-255 coverage.

Both are extremely hard or impossible to fix without changing internal things to Pixie.

These are basically the antagonistic cases for complex path filling.

One quick example I could find https://graphicdesign.stackexchange.com/questions/65058/image-looks-embossed-when-converted-to-svg

Upon a quick test, I could not see a difference with either compositing onto a black backing image, or drawing each shape into a separate image composited into one, and then onto black. But indeed those are good practices, and I will play around more.

While I don't know anything about Pixie's internals, I'm wondering if something like Fast Robust Predicates for Computational Geometry may be of any use around the floating point issues. I recently ported Mapbox's Delaunator to Nim, and it uses a ported version to achieve robustness around degenerate input. A tough problem indeed.

For a possibly imprecise solution, here is the above image with added strokes at a width of 1.5, composited shape by shape as suggested above:

example2