The Three-Stroke Problem
Sign in<br>Subscribe
When three bodies orbit each other, the resulting dynamical system is chaotic for most initial conditions<br>This is the statement of the well-known physics problem used to explain the instability and chaotic nature of three celestial bodies. The three-body problem shows that, according to Newtonian mechanics, the way these three bodies interact with one another is unpredictable and causes a rapid growth of uncertainty<br>Squares and circles with different stroke types applied at the same time, forming celestial objects.It strikes me as a great analogy for some of the challenges we faced in Penpot while migrating to WebGL rendering, with a focus on what I've named "The Three-Stroke Problem" . Paraphrasing the original statement, we could say this problem poses the following:<br>When three kinds of strokes entangle with each other, the resulting design system is chaotic for most initial conditions.<br>Let's start with the basics: what is a stroke?<br>A stroke, also referred to as a border, is a line that wraps around a layer. In design software, the stroke properties can be updated to customize its style, size, and color. ([1])<br>This definition is correct and precise: it's the line that surrounds and delimits a shape. What characterizes this line is that it doesn't have to be a solid line; it can have different styles (dashed, dotted), and the definition also mentions other properties such as size and color.<br>Here's an example of shapes in Penpot using different stroke styles. ([6])<br>Different and simple stroke combinations in PenpotStrokes across formats<br>In CSS, the equivalent would be the border property. Its main properties can be used like this: border: . The border-radius property defines the corner-rounding radius and is applied to the shape. There are also independent CSS properties for each border property: border-width, border-style, and border-color ([3])
Border Styles
.box {<br>width: 200px;<br>height: 200px;<br>padding: 16px;<br>margin: 12px 0;<br>font-family: sans-serif;<br>background: #c7ff4fFF;
.solid {<br>border: 10px solid #f76e28FF;
.dotted {<br>border: 10px dotted #f76e28FF;<br>border-radius: 50px;
.dashed {<br>border: 10px dashed #f76e28FF;<br>border-radius: 100%;
In SVG, the format used by Penpot's original renderer, which we're replacing with the WebGL renderer, the equivalent of these styles would be the following ([2]):
Enter the three strokes<br>There's one more relevant property to add to this definition: the type of stroke according to its position relative to the shape it delimits. And this is where we run into the three-stroke problem: inner, center, and outer stroke. ([7])<br>Inner Stroke: Sits completely inside the shape. Its width runs from the edge of the shape inward<br>Center Stroke: Half of the stroke sits inside the shape and the other half outside. Half of the defined width falls inside the shape, and the other half outside<br>Outer Stroke: Sits completely outside the shape. Its width runs from the edge of the shape outward<br>Different stroke styles combinations in Penpot with solid fill colors<br>This is where the party starts, because the paths that bring us together begin to diverge.<br>First of all: in CSS, the default border is of the inner type. For this reason, if we want outer or center borders, we have to "push" the borders outward or inward (using negative values) with the outline and outline-offset properties. outline is the equivalent of border but with an outer-type stroke.<br>For the "center" border, we'll have to move the border inward by half of the width we've used:<br>.outer {<br>outline: 10px solid #f76e28FF;
.center {<br>outline: 10px solid #f76e28FF;<br>outline-offset: -5px;
On the other hand, while in CSS you need to juggle to get a centered stroke, in SVG the centered stroke is the default. To get inner and outer strokes you have to apply clips and masks:
How we do it in the WebGL renderer<br>So what are we doing in the WebGL renderer? In this case, at Penpot we decided to use the well-known Skia 2D rendering library. At a high level, Skia offers the SkPaint class ([4]), which lets you define how a shape is painted. The Paint type can be Fill, Stroke, or StrokeFill. For what we do in Penpot, we end up using a combination of the different Paints and their stroke methods ([5]). Here's how we do it in Skia:<br>Different kinds of strokes in Penpot combining inner, outer, and center, with different fill types Inner Stroke<br>// Inner stroke: Skia strokes are always centered on the geometry,<br>// so we inset the rect by width/2. The stroke then spans from the<br>// shape edge inward -> fully inside the shape.<br>SkPaint stroke;<br>stroke.setAntiAlias(true);<br>stroke.setStyle(SkPaint::kStroke_Style);<br>stroke.setStrokeWidth(strokeWidth);<br>stroke.setColor(0xFFF76E28);
const SkRect innerRect = rect.makeInset(strokeWidth / 2, strokeWidth / 2);<br>canvas->drawRect(innerRect, stroke);
inner stroke in SkiaCenter Stroke<br>// Center stroke: this is Skia's native behavior. The stroke<br>// straddles the boundary, half...