Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Painting

✅ verified · all of it measured on Krita 6.0.2.1 with PyQt6 and Xvfb

Node.paint*

node.paintLine(QPoint(x0, y0), QPoint(x1, y1), pressure0, pressure1)
node.paintPath(QPainterPath(...))
node.paintPolygon([QPointF(...), ...], strokeStyle, fillStyle)
node.paintRectangle(QRectF(...), strokeStyle, fillStyle)
node.paintEllipse(QRectF(...), strokeStyle, fillStyle)
node.paintAbility()          # "PAINT" / "VECTOR" / "UNPAINTABLE"

It uses the current brush preset and the current foreground colour (view.setCurrentBrushPreset, setForeGroundColor, setBrushSize, setPaintingOpacity).

  • It paints on the node you pass it, and the active node is irrelevant. After setActiveNode(L2), n1.paintLine(...) changes 5438 px of n1 and nothing at all of L2.
  • Pressure really does drive the brush engine: a pressure curve tapered at both ends gives a width profile of 14, 29, 29, 25 px. The joins between segments are invisible, so tapering can be done segment by segment with paintLine.
  • paintLine takes QPoint (integers) and paintPath takes QPointF (floats). Session.line() uses several paintPath calls to get both floating-point precision and a varying width.
  • fillStyle defaults to "None"; pass "ForegroundColor" to fill. The fill is binary and not antialiased (a boundary-band deviation of 0.498).
  • A group’s paintAbility() is "UNPAINTABLE".

What the session wrapper gives you

methodwhat it does
stroke(pts, pressures, node)paintLine segment by segment, with pressure interpolated linearly
path(pts, node, smooth=True)one paintPath, smoothed with quadTo
line(pts, width, node, segments=3)three paintPath calls, changing the brush size for each. More segments is not better (5 segments read aliased 2.6%, 12 segments 14.5%)
dab(x, y, pressure, node)one dab
brush(color, size, opacity, flow, preset)the fast path with no event loop, skipping values that have not changed
set_preset(name, size, opacity, flow)matched by substring, printing a warning when not found; runs the event loop
set_color(r, g, b)runs the event loop

Choosing between stroke() and path() depends on the geometry: on long smooth curves path() is 23 to 47% cleaner; on short segments it is the other way round (stroke() is best at K90 0.032 and path() worst at 0.0437), and with few points quadTo changes the shape.

Throughput

8192x12288, a blank single-layer document, measured by the throughput probe (appendix C):

operationThroughput
paintLine(~300px)450 to 850 strokes a second, regardless of size
paintPath (8 points)310 to 390 strokes a second
changing colour with no event loop, plus paintLine605 strokes a second
changing colour with an event loop round, plus paintLine71 strokes a second

On a real layer tree of several hundred clipped nodes: path() runs at 297 strokes a second and stroke() segment by segment at 130. About 233 KB of undo stack per call that cannot be cleared – the budget is memory (ch. 18). (That figure was itself a measurement of the wrong thing; see ch. 17, case 26.)

Filling an area with a smooth edge at 8K

Four approaches on the same task (the fill probe, judged by the absolute difference between coverage and the analytic ground truth inside the boundary band, where perfect is about 0.02 to 0.08 and binary about 0.5):

approachboundary-band deviationtime
a vector layer with addShapesFromSvg0.08281.5s
select_mask with an antialiased mask, plus 42 strokes0.33056.4s
select_mask with a binary mask, plus 42 strokes0.36681.4s
paintPolygon(fill)0.49810.8s
unconstrained, painted over with a big brush0.4361—

Only a vector layer comes close to analytic. select_mask cannot keep fractional coverage, and paintPolygon fills binarily.

Erasing

“Erasing with a brush” cannot be done: view.setEraserMode(True), setCurrentBlendingMode("erase") and node.setBlendingMode("erase") all do nothing for paintLine (0 px erased). The only thing that works is clearing alpha (s.erase(box=, mask=)).

This was judged wrongly once: a measurement showed erase removing 2198 px. That was an artefact of reading stale pixels – reading once it had settled, all three read a flat 0.

Asynchronous readings

Image operations are asynchronous. waitForDone() plus refreshProjection() plus a round of the event loop does not guarantee that pixelData() has been updated. Judging whether something took effect means reading in a loop until two consecutive readings agree (s.settle() / s.stable()). Concluding from a single read can give the opposite answer.