Pangram verdict · v3.3
We believe that this entire text is human-written.
AI likelihood · overall
HumanArticle text · 1,312 words · 1 segments analyzed
When three bodies orbit each other, the resulting dynamical system is chaotic for most initial conditionsThis 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 uncertaintySquares 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:When three kinds of strokes entangle with each other, the resulting design system is chaotic for most initial conditions.Let's start with the basics: what is a stroke?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])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.Here's an example of shapes in Penpot using different stroke styles. ([6])Different and simple stroke combinations in PenpotStrokes across formatsIn CSS, the equivalent would be the border property. Its main properties can be used like this: border: <width> <style> <color>. 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])<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <link rel="stylesheet" href="./style.css"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Border Styles</title> </head> <body> <div class="box solid"></div> <div class="box dotted"></div> <div class="box dashed"></div> </body> </html> .box { width: 200px; height: 200px; padding: 16px; margin: 12px 0; font-family: sans-serif; background: #c7ff4fFF; } .solid { border: 10px solid #f76e28FF; } .dotted { border: 10px dotted #f76e28FF; border-radius: 50px; } .dashed { border: 10px dashed #f76e28FF; 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]):<svg width="240" height="700" viewBox="0 0 240 700" xmlns="http://www.w3.org/2000/svg"> <!-- 1) Solid --> <rect x="20" y="20" width="200" height="200" fill="#c7ff4f" stroke="#f76e28" stroke-width="10" /> <!-- 2) Dotted + border-radius: 50px --> <rect x="20" y="244" width="200" height="200" rx="50" ry="50" fill="#c7ff4f" stroke="#f76e28" stroke-width="10" stroke-linecap="round" stroke-dasharray="1 18" /> <!-- 3) Dashed circle --> <circle cx="120" cy="568" r="95" fill="#c7ff4f" stroke="#f76e28" stroke-width="10" stroke-dasharray="20 12" /> </svg> Enter the three strokesThere'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])Inner Stroke: Sits completely inside the shape. Its width runs from the edge of the shape inwardCenter 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 outsideOuter Stroke: Sits completely outside the shape. Its width runs from the edge of the shape outwardDifferent stroke styles combinations in Penpot with solid fill colorsThis is where the party starts, because the paths that bring us together begin to diverge. 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.For the "center" border, we'll have to move the border inward by half of the width we've used:.outer { outline: 10px solid #f76e28FF; } .center { outline: 10px solid #f76e28FF; 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:<svg width="520" height="170" viewBox="0 0 520 170" xmlns="http://www.w3.org/2000/svg"> <defs> <clipPath id="clipRect"> <rect x="20" y="20" width="120" height="120" rx="20"/> </clipPath> <mask id="outerOnly"> <rect width="100%" height="100%" fill="white"/> <!-- remove anything on/inside original shape --> <rect x="380" y="20" width="120" height="120" rx="20" fill="black"/> </mask> </defs> <!-- CENTER (default) --> <rect x="200" y="20" width="120" height="120" rx="20" fill="#c7ff4f" stroke="#f76e28" stroke-width="12"/> <!-- INNER (clipped centered stroke) --> <g clip-path="url(#clipRect)"> <rect x="20" y="20" width="120" height="120" rx="20" fill="#c7ff4f" stroke="#f76e28" stroke-width="12"/> </g> <!-- OUTER (masked centered stroke) --> <rect x="380" y="20" width="120" height="120" rx="20" fill="#c7ff4f"/> <rect x="380" y="20" width="120" height="120" rx="20" fill="none" stroke="#f76e28" stroke-width="12" mask="url(#outerOnly)"/> </svg> How we do it in the WebGL rendererSo 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:Different kinds of strokes in Penpot combining inner, outer, and center, with different fill types Inner Stroke// Inner stroke: Skia strokes are always centered on the geometry, // so we inset the rect by width/2. The stroke then spans from the // shape edge inward -> fully inside the shape. SkPaint stroke; stroke.setAntiAlias(true); stroke.setStyle(SkPaint::kStroke_Style); stroke.setStrokeWidth(strokeWidth); stroke.setColor(0xFFF76E28); const SkRect innerRect = rect.makeInset(strokeWidth / 2, strokeWidth / 2); canvas->drawRect(innerRect, stroke); inner stroke in SkiaCenter Stroke// Center stroke: this is Skia's native behavior. The stroke // straddles the boundary, half inside, half outside. We draw // directly on the shape rect, no offset needed. SkPaint stroke; stroke.setAntiAlias(true); stroke.setStyle(SkPaint::kStroke_Style); stroke.setStrokeWidth(strokeWidth); stroke.setColor(0xFF14CECA); canvas->drawRect(rect, stroke); center stroke in SkiaOuter Stroke// Outer stroke: outset the rect by width/2 so the centered stroke // spans from the shape edge outward -> fully outside the shape. SkPaint stroke; stroke.setAntiAlias(true); stroke.setStyle(SkPaint::kStroke_Style); stroke.setStrokeWidth(strokeWidth); stroke.setColor(0xFFFC8CFF); const SkRect outerRect = rect.makeOutset(strokeWidth / 2, strokeWidth / 2); canvas->drawRect(outerRect, stroke); outer stroke in SkiaPaths: not everything is a squareSo far it seems simple, or at least intuitive. However, in the design world not everything is squares. We don't live in Minecraft. That's why, once we start bringing in more complex shapes like Paths, adding Paints and drawing each shape twice is no longer enough. We have to reach for other Skia functions for the inner and outer strokes. By default, strokes in Skia are centered (as in SVG), so in that case there's nothing specific to do.Assuming this triangle path: const SkPath path = SkPathBuilder() .moveTo(128, 37) .lineTo(226, 219) .lineTo(30, 219) .close() .detach(); const float strokeWidth = 10; SkPaint fill; fill.setAntiAlias(true); fill.setStyle(SkPaint::kFill_Style); fill.setColor(0xFFA5F1EE); canvas->drawPath(path, fill); Inner Stroke// Inner stroke: centered stroke at double width, clipped to the path. SkPaint stroke; stroke.setAntiAlias(true); stroke.setStyle(SkPaint::kStroke_Style); stroke.setStrokeWidth(strokeWidth * 2); stroke.setColor(0xFF9042FF); canvas->save(); canvas->clipPath(path, SkClipOp::kIntersect, /*antialias=*/true); canvas->drawPath(path, stroke); canvas->restore(); inner stroke on a path using SkiaCenter Stroke// Center stroke: Skia's native behavior, nothing to correct. SkPaint stroke; stroke.setAntiAlias(true); stroke.setStyle(SkPaint::kStroke_Style); stroke.setStrokeWidth(strokeWidth); stroke.setColor(0xFF14CECA); canvas->drawPath(path, stroke); center stroke on a path using SkiaOuter Stroke// Outer stroke: draw a centered stroke at DOUBLE width into an // isolated layer, then erase the shape's interior with kClear. // Only the outside half of the stroke survives. The layer is // essential: kClear must erase the layer, not the fill below. SkPaint stroke; stroke.setAntiAlias(true); stroke.setStyle(SkPaint::kStroke_Style); stroke.setStrokeWidth(strokeWidth * 2); stroke.setColor(0xFFFC8CFF); canvas->saveLayer(nullptr, nullptr); canvas->drawPath(path, stroke); SkPaint clear; clear.setAntiAlias(true); clear.setBlendMode(SkBlendMode::kClear); canvas->drawPath(path, clear); canvas->restore(); outer stroke on a path using SkiaDashed and dotted stylesDepending on the stroke's style, we also have to get creative and use the appropriate SkPathEffect when dealing with dotted or dashed strokes:Dashed StrokeSkPaint stroke; stroke.setAntiAlias(true); stroke.setStyle(SkPaint::kStroke_Style); stroke.setStrokeWidth(strokeWidth); stroke.setColor(0xFFFC8CFF); const SkScalar intervals[] = {strokeWidth + 10, strokeWidth + 10};