Het Three-Stroke Probleem

Wanneer drie lichamen om elkaar heen draaien, is het resulterende dynamische systeem voor de meeste begincondities chaotisch. Dit is de stelling van het bekende natuurkundige probleem dat wordt gebruikt om de instabiliteit en chaotische aard van drie hemellichamen uit te leggen. Het drielichamenprobleem laat zien dat, volgens de Newtoniaanse mechanica, de manier waarop deze drie lichamen met elkaar communiceren onvoorspelbaar is en leidt tot een snelle groei van onzekerheid.

Dit is een uitstekende analogie voor enkele van de uitdagingen waar we bij Penpot tegenaan liepen tijdens de migratie naar WebGL-rendering, met een focus op wat ik het "Three-Stroke Probleem" heb genoemd. Parafraserend kunnen we stellen: wanneer drie soorten strokes (omtreklijnen) met elkaar verstrengeld raken, is het resulterende designsysteem voor de meeste begincondities chaotisch.

Wat is een stroke?

Laten we beginnen bij de basis: wat is een stroke? Een stroke, ook wel een border of omtreklijn genoemd, is een lijn die een laag omhult. In designsoftware kunnen de eigenschappen van de stroke worden aangepast om de stijl, grootte en kleur te personaliseren. ([1])

Deze definitie is correct en precies: het is de lijn die een vorm omringt en afbakent. Kenmerkend voor deze lijn is dat hij niet massief hoeft te zijn; hij kan verschillende stijlen hebben (gestippeld, gestreept) en andere eigenschappen zoals dikte en kleur.

Strokes in verschillende formaten

In CSS is het equivalent hiervan de border-eigenschap. De belangrijkste eigenschappen kunnen als volgt worden gebruikt: border: <width> <style> <color>. De eigenschap border-radius definieert de afrondingsstraal van de hoeken en wordt op de vorm toegepast. Er zijn ook onafhankelijke CSS-eigenschappen voor elke border-instelling: border-width, border-style en border-color. ([3])

Voorbeeld HTML/CSS

<!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, het formaat dat werd gebruikt door de oorspronkelijke renderer van Penpot (die we nu vervangen door WebGL), zou het equivalent van deze stijlen als volgt zijn ([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>

De introductie van de drie strokes

Er is nog één relevante eigenschap om aan deze definitie toe te voegen: het type stroke op basis van de positie ten opzichte van de vorm die het afbakent. En dit is waar we stuiten op het "three-stroke problem": inner, center en outer stroke. ([7])

  • Inner Stroke: Bevindt zich volledig binnen de vorm. De breedte loopt vanaf de rand van de vorm naar binnen.
  • Center Stroke: De helft van de stroke bevindt zich binnen de vorm en de andere helft erbuiten. De gedefinieerde breedte wordt dus verdeeld over beide zijden van de rand.
  • Outer Stroke: Bevindt zich volledig buiten de vorm. De breedte loopt vanaf de rand van de vorm naar buiten.

Hier beginnen de paden uiteen te lopen. In CSS is de standaard border van het type inner. Als we outer- of center-borders willen, moeten we de borders "naar buiten" of "naar binnen" duwen (met negatieve waarden) met de eigenschappen outline en outline-offset. De outline is het equivalent van de border, maar dan met een stroke van het type outer.

Voor een "center" border moeten we de border naar binnen verplaatsen met de helft van de gebruikte breedte:

.outer {
  outline: 10px solid #f76e28FF;
}

.center {
  outline: 10px solid #f76e28FF;
  outline-offset: -5px;
}

Daarentegen is in SVG de centered stroke de standaard. Om inner- en outer strokes te krijgen, moet je clips en masks toepassen:

<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"/>
      <!-- verwijder alles op/binnen de oorspronkelijke vorm -->
      <rect x="380" y="20" width="120" height="120" rx="20" fill="black"/>
    </mask>
  </defs>
  <!-- CENTER (standaard) -->
  <rect x="200" y="20" width="120" height="120" rx="20"
    fill="#c7ff4f" stroke="#f76e28" stroke-width="12"/>
  <!-- INNER (geclippte 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 (gemaskerde 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>

Implementatie in de WebGL renderer

Wat doen we nu in de WebGL renderer? Bij Penpot hebben we besloten om de bekende 2D rendering library Skia te gebruiken. Op hoog niveau biedt Skia de SkPaint-klasse ([4]), waarmee je kunt definiëren hoe een vorm wordt getekend. Het type Paint kan Fill, Stroke of StrokeFill zijn. In Penpot gebruiken we een combinatie van verschillende Paints en hun stroke-methoden ([5]).

Inner Stroke

In Skia zijn strokes altijd gecentreerd op de geometrie. Daarom verplaatsen we het rechthoek (rect) met width/2. De stroke loopt dan vanaf de rand van de vorm naar binnen.

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

Center Stroke

Dit is het standaardgedrag van Skia: de stroke bevindt zich op de grens, half binnen en half buiten. Er is geen offset nodig.

SkPaint stroke;
stroke.setAntiAlias(true);
stroke.setStyle(SkPaint::kStroke_Style);
stroke.setStrokeWidth(strokeWidth);
stroke.setColor(0xFF14CECA);
canvas->drawRect(rect, stroke);

Outer Stroke

We vergroten de rechthoek met width/2 via makeOutset, zodat de gecentreerde stroke vanaf de rand van de vorm naar buiten loopt.

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

Paths: niet alles is een vierkant

Tot nu toe lijkt het eenvoudig, maar in de designwereld bestaat niet alles uit vierkanten. Wanneer we complexere vormen zoals Paths introduceren, is het simpelweg tekenen van elke vorm twee keer met verschillende Paints niet meer voldoende. We moeten andere Skia-functies gebruiken voor inner en outer strokes. De center stroke blijft standaard.

Stel we hebben dit driehoekspad:

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 op een Path

Een gecentreerde stroke met dubbele breedte, geclipt tot het pad.

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();

Center Stroke op een Path

Standaardgedrag van Skia.

SkPaint stroke;
stroke.setAntiAlias(true);
stroke.setStyle(SkPaint::kStroke_Style);
stroke.setStrokeWidth(strokeWidth);
stroke.setColor(0xFF14CECA);
canvas->drawPath(path, stroke);

Outer Stroke op een Path

We tekenen een gecentreerde stroke met dubbele breedte in een geïsoleerde laag en wissen vervolgens het binnenste van de vorm met kClear. Alleen de buitenste helft overleeft. De layer is essentieel: kClear moet de laag wissen, niet de fill eronder.

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();

Gestreepte en gestippelde stijlen

Afhankelijk van de stijl van de stroke moeten we gebruikmaken van de juiste SkPathEffect wanneer we te maken hebben met gestippelde of gestreepte lijnen.

Dashed Stroke (Gestreepte lijn)

SkPaint stroke;
stroke.setAntiAlias(true);
stroke.setStyle(SkPaint::kStroke_Style);
stroke.setStrokeWidth(strokeWidth);
stroke.setColor(0xFFFC8CFF);
const SkScalar intervals[] = {strokeWidth + 10, strokeWidth + 10};
stroke.setPathEffect(SkDashPathEffect::Make(intervals, 0.0f));
canvas->drawRect(rect, stroke);

Dotted Stroke (Gestippelde lijn)

SkPaint stroke;
stroke.setAntiAlias(true);
stroke.setStyle(SkPaint::kStroke_Style);
stroke.setStrokeWidth(strokeWidth);
stroke.setColor(0xFFFC8CFF);
SkPath circle = SkPath::Circle(0, 0, strokeWidth / 2);
const float advance = strokeWidth + 5;  // afstand tussen stip-centra
stroke.setPathEffect(SkPath1DPathEffect::Make(
  circle, advance, /*phase=*/0.0f, SkPath1DPathEffect::kTranslate_Style));
canvas->drawRect(rect, stroke);

Meerdere strokes per vorm

Tot nu toe leken de variabelen beheersbaar, maar er is nog een variabele die niet een eigenschap van de stroke is, maar van de vorm: het aantal strokes dat deze kan hebben. In Penpot worden meerdere borders altijd ondersteund.

In de WebGL renderer voegen we simpelweg zoveel mogelijk borders toe als de vorm heeft, in de opgegeven volgorde.

void draw(SkCanvas* canvas) {
  canvas->clear(SK_ColorWHITE);
  const SkRect rect = SkRect::MakeXYWH(48, 48, 160, 160);
  const float w = 16;
  SkPaint p;
  p.setAntiAlias(true);

  // Fill
  p.setStyle(SkPaint::kFill_Style);
  p.setColor(0xFFA5F1EE);
  canvas->drawRect(rect, p);

  p.setStyle(SkPaint::kStroke_Style);
  p.setStrokeWidth(w);

  // Outer stroke: outset by w/2 -> loopt vanaf rand naar buiten
  p.setColor(0xFFFC8CFF); // roze
  canvas->drawRect(rect.makeOutset(w / 2, w / 2), p);

  // Center stroke: op de rect zelf -> half in, half uit
  p.setColor(0xFF14CECA);
  canvas->drawRect(rect, p);

  // Inner stroke: inset by w/2 -> loopt vanaf rand naar binnen
  p.setColor(0xFF9042FF);
  canvas->drawRect(rect.makeInset(w / 2, w / 2), p);
}

In CSS kan het ondersteunen van meerdere strokes op dezelfde vorm niet via een standaard border-eigenschap (hoewel dit binnenkort kan veranderen). Men gebruikt vaak een workaround zoals de box-shadow eigenschap zonder vervaging (blur) om dit effect te simuleren:

.multi-border {
  border-radius: 20px;
  background: #A5F1EE;
  box-shadow:
    inset 0 0 0 6px #9042FF,
    0 0 0 6px #A5F1EE,
    0 0 0 12px #14CECA,
    0 0 0 18px #FC8CFFAA;
}

Conclusie

We begonnen met een systeem dat, net als drie om elkaar heen draaiende lichamen, gedoemd leek tot chaos: drie soorten strokes en drie formaten die elk een andere mening hebben over wat de "standaard" stroke is.

De belangrijkste conclusie is dat we dankzij de WebGL renderer veel meer flexibiliteit hebben bij het implementeren van visuele elementen met betrekking tot borders vergeleken met wat mogelijk was in SVG.

En we zijn nog niet klaar. Er is nog veel ruimte om te experimenteren met wat strokes kunnen doen in Penpot, zoals functies als "stroke to path" (een stroke converteren naar een bewerkbaar pad) en individuele strokes per zijde.

***

Bronnen: (1) Codecademy: Stroke (2) W3Schools: SVG Stroking (3) GeeksforGeeks: CSS Borders (4) Skia: SkPaint Overview (5) Skia API: SkPaint Class (6) Penpot User Guide: Color & Stroke (7) Inkscape Manuals: Strokes