Skip to content

geomotif.motifs.knots

Celtic knotwork: strands that cross over and under and never end.

A knot drawing is a closed curve plus a decision at every crossing about which strand is on top. This module keeps those two things apart. Each motif hands :func:_interlace a set of closed loops; the interlacer finds where they cross, works out the over-and-under, and draws the strand that goes underneath with a gap in it. Nothing is filled and nothing is hidden, which is what a pen plotter can actually draw and what the eye reads as weaving anyway.

Which strand goes over is not a free choice. Along one strand the crossings must alternate over, under, over, under -- and at each crossing the two strands must disagree. Written down, those are two kinds of "must differ", so the whole question is a two-coloring, and it has a solution for a single closed curve however tangled: between the two passes through any one crossing lies a closed loop, and a closed loop meets the rest of the curve an even number of times, so the two passes are always an odd number of crossings apart. What the interlacer does is that argument, run as a breadth-first search.

Four figures and one engine. :class:Triquetra is three circles. :class:CircularCelticKnot and :class:SquareCelticKnot are one strand wound several times round a ring or a square frame. :class:EndlessKnot is the woven grid whose ends are looped back so that the whole figure is a single strand. :class:CelticGrid is the plait: strands set off at 45 degrees, bounce off the frame, and bounce off whatever barriers you place inside it.

Classes:

Name Description
Triquetra

The trinity knot: three arcs that turn out to be one strand.

CircularCelticKnot

One strand wound several times round a ring, weaving with itself.

SquareCelticKnot

The same winding, but round a square frame instead of a ring.

EndlessKnot

The endless knot: four strands each way, woven, with their ends looped back.

CelticGrid

The plait: strands at 45 degrees, bouncing off the frame and off barriers.

Triquetra dataclass

Triquetra(radius: float = 90.0, gap: float = 0.07, rotation: float = -pi / 2.0, center: Point = (0.0, 0.0), *, ring: bool = False)

Bases: Motif

The trinity knot: three arcs that turn out to be one strand.

Three circles whose middles are one radius apart, each cut down to the half that faces the other two. Those three half-circles join end to end -- the joins are where the circles meet on the far side -- so the figure is a single closed strand crossing itself three times, which is to say a trefoil, drawn the way it was drawn on stone crosses.

Parameters:

Name Type Description Default
radius float

Radius of each of the three circles.

90.0
gap float

Length of the break in the under-strand, as a fraction of the radius.

0.07
rotation float

Turn the figure, in radians. Defaults to one lobe pointing up.

-pi / 2.0
ring bool

Draw the enclosing circle the knot is often set in, woven through the three lobes. It has the same radius as they do.

False
center (float, float)

Middle of the figure.

(0.0, 0.0)

Methods:

Name Description
centers

Return the three circle middles, one radius apart from each other.

loops

Return the closed curves the knot is woven from.

centers

centers() -> tuple[Point, ...]

Return the three circle middles, one radius apart from each other.

Source code in src/geomotif/motifs/knots.py
def centers(self) -> tuple[Point, ...]:
    """Return the three circle middles, one radius apart from each other."""
    return ring_points(3, self.radius / _ROOT3, center=self.center, rotation=self.rotation)

loops

loops() -> tuple[Loop, ...]

Return the closed curves the knot is woven from.

Source code in src/geomotif/motifs/knots.py
def loops(self) -> tuple[Loop, ...]:
    """Return the closed curves the knot is woven from."""
    strand: list[Point] = []
    for i, middle in enumerate(self.centers()):
        facing = self.rotation + i * math.tau / 3.0
        # The half of this circle that faces the other two, run backwards
        # so that each arc ends where the next one starts.
        arc = arc_points(middle, self.radius, facing + 1.5 * math.pi, -math.pi)
        strand.extend(arc[:-1])
    loops = [tuple(strand)]
    if self.ring:
        loops.append(arc_points(self.center, self.radius, 0.0, math.tau)[:-1])
    return tuple(loops)

CircularCelticKnot dataclass

CircularCelticKnot(radius: float = 110.0, amplitude: float = 34.0, lobes: int = 5, wraps: int = 2, resolution: int = 48, gap: float = 0.06, center: Point = (0.0, 0.0))

Bases: Motif

One strand wound several times round a ring, weaving with itself.

The radius rises and falls as the strand goes round, and because it wraps more than once the turns swap places and cross. With wraps and lobes sharing no factor the whole thing is a single closed strand -- the circular knot cut into stone crosses, and, read as a knot, a torus knot's standard diagram.

Parameters:

Name Type Description Default
radius float

Mean radius of the ring.

110.0
amplitude float

How far the strand wanders in and out.

34.0
lobes int

Cycles of that wandering over the whole journey.

5
wraps int

Times the strand goes round. Must share no factor with lobes.

2
resolution int

Samples per lobe.

48
gap float

Length of the break in the under-strand, as a fraction of the radius.

0.06
center (float, float)

Middle of the ring.

(0.0, 0.0)

Methods:

Name Description
loop

Return the strand as one closed polyline, before it is broken.

loop

loop() -> Loop

Return the strand as one closed polyline, before it is broken.

Source code in src/geomotif/motifs/knots.py
def loop(self) -> Loop:
    """Return the strand as one closed polyline, before it is broken."""
    return _wound(
        lambda _: self.radius,
        self.amplitude,
        self.lobes,
        self.wraps,
        self.resolution * self.lobes,
        self.center,
    )

SquareCelticKnot dataclass

SquareCelticKnot(size: float = 220.0, amplitude: float = 24.0, lobes: int = 8, wraps: int = 3, squareness: float = 6.0, resolution: int = 48, gap: float = 0.035, center: Point = (0.0, 0.0))

Bases: Motif

The same winding, but round a square frame instead of a ring.

The spine is a squircle, so the strand runs straight down each side and turns the corner without a kink -- the shape of a knotwork panel border.

Parameters:

Name Type Description Default
size float

Width of the frame, across the middle of the strand.

220.0
amplitude float

How far the strand wanders in and out.

24.0
lobes int

Cycles of that wandering over the whole journey.

8
wraps int

Times the strand goes round. Must share no factor with lobes.

3
squareness float

How square the frame is: 2 is a circle, and higher is squarer.

6.0
resolution int

Samples per lobe.

48
gap float

Length of the break in the under-strand, as a fraction of the size.

0.035
center (float, float)

Middle of the frame.

(0.0, 0.0)

Methods:

Name Description
spine

Return the frame's radius at theta: a squircle, not a circle.

loop

Return the strand as one closed polyline, before it is broken.

spine

spine(theta: float) -> float

Return the frame's radius at theta: a squircle, not a circle.

Source code in src/geomotif/motifs/knots.py
def spine(self, theta: float) -> float:
    """Return the frame's radius at ``theta``: a squircle, not a circle."""
    power = self.squareness
    # Typed out rather than written as one expression: ``x ** y`` on two
    # floats is Any to mypy, and the annotation is what says otherwise.
    reach: float = abs(math.cos(theta)) ** power + abs(math.sin(theta)) ** power
    shrink: float = reach ** (1.0 / power)
    return self.size / 2.0 / shrink

loop

loop() -> Loop

Return the strand as one closed polyline, before it is broken.

Source code in src/geomotif/motifs/knots.py
def loop(self) -> Loop:
    """Return the strand as one closed polyline, before it is broken."""
    return _wound(
        self.spine,
        self.amplitude,
        self.lobes,
        self.wraps,
        self.resolution * self.lobes,
        self.center,
    )

EndlessKnot dataclass

EndlessKnot(size: float = 220.0, roundness: float = 0.4, gap: float = 0.02, center: Point = (0.0, 0.0))

Bases: Motif

The endless knot: four strands each way, woven, with their ends looped back.

A plain over-and-under weave of four horizontal and four vertical strands, whose sixteen loose ends are joined in pairs round the four corners. The joining is what the name is about -- get it wrong and the figure falls into two closed rings; get it right and it is one strand with no beginning, which is the whole point of the symbol.

Parameters:

Name Type Description Default
size float

Width of the finished figure.

220.0
roundness float

How much the corners are curved, as a fraction of the strand spacing.

0.4
gap float

Length of the break in the under-strand, as a fraction of the size.

0.02
center (float, float)

Middle of the figure.

(0.0, 0.0)

Methods:

Name Description
loop

Return the single closed strand, corners rounded, before it is broken.

loop

loop() -> Loop

Return the single closed strand, corners rounded, before it is broken.

Source code in src/geomotif/motifs/knots.py
def loop(self) -> Loop:
    """Return the single closed strand, corners rounded, before it is broken."""
    far: dict[tuple[str, int], tuple[str, int]] = {}
    for a, b in _ENDLESS_JOINS:
        far[a], far[b] = b, a
    opposite = {"L": "R", "R": "L", "T": "B", "B": "T"}
    across = {(side, i): (opposite[side], i) for side in "LRTB" for i in range(4)}

    start = ("L", 0)
    corners: list[Point] = []
    here = start
    while True:
        other = across[here]
        corners.append(self._end(here))
        corners.append(self._end(other))
        joined = far[other]
        # The elbow that carries one strand's end round to the next: it
        # sits at the corner of their two reaches.
        flat, upright = (other, joined) if other[0] in "LR" else (joined, other)
        corners.append((self._end(flat)[0], self._end(upright)[1]))
        here = joined
        if here == start:
            break
    step = self.size / (2.0 * _ENDLESS_REACH[0])
    return _rounded(tuple(corners), self.roundness * step)

CelticGrid dataclass

CelticGrid(cols: int = 4, rows: int = 3, size: float = 60.0, breaks: tuple[tuple[int, int, str], ...] = (), roundness: float = 0.55, gap: float = 0.14, center: Point = (0.0, 0.0))

Bases: Motif

The plait: strands at 45 degrees, bouncing off the frame and off barriers.

The construction every Celtic panel is built on. Strands set off diagonally across a grid of cols by rows cells and turn wherever they meet the frame; wherever they meet each other they weave. Place a barrier inside and the strands turn there too, which is how one plait becomes a thousand different knots -- the breaks are the design.

Barriers sit on the half-cell grid, whose coordinates run from 0 to 2 * cols across and 0 to 2 * rows up. A barrier must be strictly inside and its two coordinates must add to an odd number, which is where the strands actually go.

Parameters:

Name Type Description Default
cols int

Size of the panel, in cells.

4
rows int

Size of the panel, in cells.

4
size float

Side of one cell.

60.0
breaks tuple

Barriers, each (x, y, "h") to turn a strand back vertically or (x, y, "v") to turn it back horizontally.

()
roundness float

How much the turns are curved, as a fraction of the half-cell.

0.55
gap float

Length of the break in the under-strand, as a fraction of the cell.

0.14
center (float, float)

Middle of the panel.

(0.0, 0.0)

Methods:

Name Description
turns

Return each strand as the grid nodes where it changes direction.

loops

Return the strands as closed polylines, corners rounded.

turns

turns() -> tuple[tuple[tuple[int, int], ...], ...]

Return each strand as the grid nodes where it changes direction.

Only the corners: between two of them the strand runs dead straight, so listing every node it passes through would add points a plotter would draw over anyway -- and would put a crossing exactly on a vertex, where it is far harder to find.

The strands never reach the four corners of the frame, where they could only turn back on themselves: a corner's coordinates add to an even number, and every strand lives on the odd ones.

Source code in src/geomotif/motifs/knots.py
def turns(self) -> tuple[tuple[tuple[int, int], ...], ...]:
    """Return each strand as the grid nodes where it changes direction.

    Only the corners: between two of them the strand runs dead straight,
    so listing every node it passes through would add points a plotter
    would draw over anyway -- and would put a crossing exactly on a vertex,
    where it is far harder to find.

    The strands never reach the four corners of the frame, where they could
    only turn back on themselves: a corner's coordinates add to an even
    number, and every strand lives on the odd ones.
    """
    drawn: set[frozenset[tuple[int, int]]] = set()
    strands: list[tuple[tuple[int, int], ...]] = []
    for x in range(2 * self.cols + 1):
        for y in range(2 * self.rows + 1):
            if (x + y) % 2 == 0:
                continue
            for arriving in ((1, 1), (1, -1), (-1, 1), (-1, -1)):
                node = (x, y)
                heading = self._leaves(node, arriving)
                if frozenset((node, (x + heading[0], y + heading[1]))) in drawn:
                    continue
                corners: list[tuple[int, int]] = []
                while True:
                    step = (node[0] + heading[0], node[1] + heading[1])
                    if frozenset((node, step)) in drawn:
                        break
                    drawn.add(frozenset((node, step)))
                    onward = self._leaves(step, heading)
                    if onward != heading:
                        corners.append(step)
                    node, heading = step, onward
                strands.append(tuple(corners))
    return tuple(strands)

loops

loops() -> tuple[Loop, ...]

Return the strands as closed polylines, corners rounded.

Source code in src/geomotif/motifs/knots.py
def loops(self) -> tuple[Loop, ...]:
    """Return the strands as closed polylines, corners rounded."""
    half = self.size / 2.0
    cx, cy = self.center
    x0 = cx - self.cols * self.size / 2.0
    y0 = cy - self.rows * self.size / 2.0
    return tuple(
        _rounded(
            tuple((x0 + x * half, y0 + y * half) for x, y in corners),
            self.roundness * half,
        )
        for corners in self.turns()
    )