Walldye

Ubuntu logo

after Marcus Haslam, Circle of Friends, 20221

Ubuntu’s logo, the Circle of Friends, is three heads in a broken ring.

Made with Claude Opus 5.5

Unofficial fan tribute, not affiliated with or endorsed by Canonical Limited. Ubuntu and its characters are trademarks of their owners. Non-commercial; contact takedown@walldye.com for takedown.

Technique
technical drawing
Shape
Any screen
Added
28 September 2026

Versions

Export

Shape
cropped from 16:9
Across
Down
Scale
Crop
Size
Format

Colors

  • #1C1B1Abackground
  • #DAD8CEforeground
  • #CF6A4Caccent

Notes

Every radius in the mark is a whole number of one small unit, measured off the official 2022 artwork: the ring runs from 27 to 36, the head centers sit on a circle of 30, the heads have a radius of 11, and a cut of 16 round each head breaks the ring. In the official file the heads sit slightly off an even spacing; here they are exactly 120 degrees apart.

Sources

  1. Marcus Haslam, Circle of Friends, 2022. ↑

Source code

wallpapers/ubuntu-logo/design.py, 116 lines

"""The Ubuntu Circle of Friends logo as a compass construction."""

from shapely import LinearRing, LineString, Point, Polygon
from shapely.geometry.base import BaseGeometry
from shapely.ops import unary_union

from walldye import ACCENT, BG, BG_ALT, UI, UI_ALT, Canvas, P, Params, Vec, design, knob, polar
from walldye.geom import parts

Q = 6  # the unit every radius is a whole number of
# Radii in Q, snapped from the official 2022 artwork: the ring, the orbit of the head centers,
# a head, and the clearance cut round each head.
RING_IN, RING_OUT, ORBIT, HEAD, CLEAR = 27, 36, 30, 11, 16
PITCH = RING_OUT - RING_IN  # the ring's width, stepped outward as the grid
TURN = 63  # the first head's angle (deg, clockwise from east)
R = (ORBIT + HEAD) * Q  # the circumscribed circle, touching the heads
FADE = 560  # construction lines dissolve into the background by this radius
# where the angle and the head's width are dimensioned, clear of the cuts round the heads
DIM = (ORBIT + CLEAR + 4) * Q
DASHDOT = (24, 6, 3, 6)
ARROW, ARROW_W = 13, 4.2


def strands(g: BaseGeometry) -> list[LineString]:
    """The line parts of `g` longer than 2 units; shorter ones are boolean slivers."""
    return [q for q in parts(g) if isinstance(q, LineString) and q.length > 2]


def circle_line(c: Vec, r: float) -> LinearRing:
    """A circle as a closed line, for boolean cuts."""
    return LinearRing(Point(c.x, c.y).buffer(r, quad_segs=128).exterior.coords)


class Drawing(Params):
    dimensions: bool = knob(default=True, doc="the angle and width dimensions")


@design(aspects="any", variants={"undimensioned": Drawing(dimensions=False)})
def draw(s: Canvas[Drawing]) -> None:
    at = s.pick(landscape=(31 / 48, 0.5), portrait=(0.5, 0.42))
    with s.motif(at, FADE, handle=R + 72):  # grabbed by the circle and its center lines
        c = Vec(0, 0)
        axes = [TURN + 120 * k for k in range(3)]
        heads = [polar(c, ORBIT * Q, deg=a) for a in axes]
        band = Point(c.x, c.y).buffer(RING_OUT * Q, quad_segs=128)
        band = band.difference(Point(c.x, c.y).buffer(RING_IN * Q, quad_segs=128))
        cut = unary_union([Point(h.x, h.y).buffer(CLEAR * Q, quad_segs=96) for h in heads])
        arms = list(parts(band.difference(cut)))
        discs = [Point(h.x, h.y).buffer(HEAD * Q, quad_segs=96) for h in heads]
        solid = unary_union([*arms, *discs]).buffer(2.5)
        field = Point(c.x, c.y).buffer(FADE, quad_segs=128)
        pocket = Point(c.x, c.y).buffer(RING_IN * Q - 1, quad_segs=128)

        ext, dl, tips = P(), P(), P()
        clear_of_dims: BaseGeometry = Polygon()
        if s.params.dimensions:
            # dimensions: the 120 degrees between the two upper heads; below, the overall width and
            # the picked-out head's width, arrows outside
            a0, a1 = axes[1], axes[2]
            dl.arc(c, DIM, deg=(a0, a1))
            tips.arrowhead(polar(c, DIM, deg=a0), ARROW, deg=a0 - 90, width=ARROW_W)
            tips.arrowhead(polar(c, DIM, deg=a1), ARROW, deg=a1 + 90, width=ARROW_W)
            yd = c.y + (RING_OUT + 2.5 * PITCH) * Q  # midway between the first two grid circles
            for x in (c.x - R, c.x + R):
                ext.M(x, c.y + 14).V(yd + 12)
            dl.M(c.x - R, yd).H(c.x + R)
            tips.arrowhead((c.x + R, yd), ARROW, deg=0, width=ARROW_W)
            tips.arrowhead((c.x - R, yd), ARROW, deg=180, width=ARROW_W)
            h = heads[0]
            x0, x1, yc = h.x - HEAD * Q, h.x + HEAD * Q, c.y + DIM
            for x in (x0, x1):
                ext.M(x, h.y + 14).V(yc + 12)
            dl.M(x0 - 34, yc).H(x0).M(x1, yc).H(x1 + 34)
            tips.arrowhead((x0, yc), ARROW, deg=0, width=ARROW_W)
            tips.arrowhead((x1, yc), ARROW, deg=180, width=ARROW_W)
            marks = [LineString([polar(c, DIM, deg=a0 + (a1 - a0) * k / 64) for k in range(65)])]
            marks += [LineString([(c.x - R, yd), (c.x + R, yd)])]
            marks += [LineString([(x0 - 34, yc), (x0, yc)]), LineString([(x1, yc), (x1 + 34, yc)])]
            clear_of_dims = unary_union(marks).buffer(7)

        # construction: the ring's edges run on through the gaps, each clearance closes round its
        # head, the ring's width steps outward, and spokes run out along the heads and the gaps
        runs: list[BaseGeometry] = [circle_line(c, RING_IN * Q), circle_line(c, RING_OUT * Q)]
        runs += [circle_line(h, CLEAR * Q) for h in heads]
        runs += [
            circle_line(c, r * Q)
            for r in range(RING_OUT + 2 * PITCH, FADE // Q, PITCH)
            if r * Q < FADE - 30
        ]
        runs += [LineString([c, polar(c, 2 * FADE, deg=TURN + 60 * k)]) for k in range(6)]
        lines = P()
        for g in runs:
            g = g.intersection(field).difference(solid).difference(pocket)
            for q in strands(g.difference(clear_of_dims)):
                lines.poly(q.coords)
        fade = s.radial_gradient([(0, BG_ALT), (R * 1.15 / FADE, BG_ALT), (1, BG)], c, FADE)
        s.stroke(lines, fade, 1.2)

        # center lines through the heads and the circumscribed circle
        cl = P()
        for a in axes:
            cl.M(polar(c, R + 72, deg=a)).L(polar(c, R + 72, deg=a + 180))
        s.stroke(cl.circle(c, R), UI, 1.2, dash=DASHDOT)
        if s.params.dimensions:
            s.stroke(ext, UI, 1.2)
            s.stroke(dl, UI_ALT, 1.2)
            s.fill(tips, UI_ALT)

        # the arms, then the heads; the first head carries the accent
        arm = P()
        for g in arms:
            arm.shape(g)
        s.fill(arm, UI)
        with s.buckets((ACCENT, UI_ALT), "fill") as b:
            for k, g in enumerate(discs):
                b[0 if k == 0 else 1].shape(g)

Run it yourself

$ git clone https://github.com/nickolaj-jepsen/walldye && cd walldye$ uv run walldye render ubuntu-logo --theme fireproof -o ubuntu-logo-fireproof-16x9.svg