Skip to content

geomotif.motifs.illusions

Impossible figures and interference patterns.

Two different tricks share this module because both are about what a drawing can say that an object cannot.

The impossible figures -- :class:PenroseTriangle, :class:PenroseStairs, :class:ImpossibleCube and the honestly ambiguous :class:NeckerCube -- all lean on the same property of a parallel projection: it throws away depth. In an isometric view, going one unit up is drawn exactly like going one unit away along each of the two horizontal axes, so a figure whose ends fail to meet in space by (t, t, t) meets itself perfectly on the page. That is not a fudge in the drawing; it is the whole of why these figures work, and both Penrose constructions here are built on it directly rather than by nudging coordinates until they line up.

:class:CafeWall and :class:MoirePattern are the other kind: nothing about them is impossible, and they still refuse to be seen straight. The cafe wall's mortar lines are exactly parallel and the moire's fringes are not drawn at all -- they are what two regular patterns make between them.

Classes:

Name Description
PenroseTriangle

The tribar: three square beams meeting at three right angles.

PenroseStairs

The endless staircase: four flights, every step up, back where you began.

NeckerCube

A wireframe cube with nothing to say which face is in front.

ImpossibleCube

The same cube, told two contradictory things about which face is nearer.

CafeWall

Parallel mortar lines that refuse to look parallel.

MoirePattern

Two regular patterns laid over each other, and the fringes between them.

PenroseTriangle dataclass

PenroseTriangle(size: float = 240.0, thickness: float = 0.25, center: Point = (0.0, 0.0))

Bases: Motif

The tribar: three square beams meeting at three right angles.

Each beam is drawn as the silhouette of a long cuboid seen isometrically, and the three are the same beam turned by a third of a revolution. Every beam passes in front of the next one round, which is the whole trick: locally each joint is an ordinary right angle, and following them round gets you back underneath where you started.

Parameters:

Name Type Description Default
size float

Width of the finished figure.

240.0
thickness float

Beam width as a fraction of its length. Thin beams give the spidery version, fat ones the chunky Escher version.

0.25
center (float, float)

Middle of the figure.

(0.0, 0.0)

Methods:

Name Description
beams

Return the three beams as their outlines, before any is hidden.

beams

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

Return the three beams as their outlines, before any is hidden.

Source code in src/geomotif/motifs/illusions.py
def beams(self) -> tuple[tuple[Point, ...], ...]:
    """Return the three beams as their outlines, before any is hidden."""
    length, width = 1.0, self.thickness
    beams: list[tuple[Point, ...]] = []
    for k in range(3):
        along, left, right = _AXES[k], _AXES[(k + 1) % 3], _AXES[(k + 2) % 3]
        # Each beam starts where the one before it ends: turning the start
        # by a third of a revolution has to land exactly one length along.
        turn = k * math.tau / 3.0
        cos, sin = math.cos(turn), math.sin(turn)
        base = (-length / _ROOT3 * cos, -length / _ROOT3 * sin)

        def corner(*terms: tuple[float, Point], base: Point = base) -> Point:
            return (
                base[0] + math.fsum(f * v[0] for f, v in terms),
                base[1] + math.fsum(f * v[1] for f, v in terms),
            )

        beams.append(
            (
                corner((-width, along)),
                corner((width, left)),
                corner((length, along), (width, left)),
                corner((length, along)),
                corner((length, along), (width, right)),
                corner((width, right)),
            )
        )
    return tuple(beams)

PenroseStairs dataclass

PenroseStairs(steps: int = 5, rise: float = 0.4, width: float = 1.8, size: float = 280.0, center: Point = (0.0, 0.0))

Bases: Motif

The endless staircase: four flights, every step up, back where you began.

Built in space and then flattened, rather than drawn flat and fudged. Four flights of equal step count run round a rectangle, each step rising by rise; after a full circuit the walk has failed to close by exactly (t, t, t), and an isometric view sends that to nothing. Two opposite flights have to be longer than the other two by four times the rise for the error to come out equal on all three axes -- which is why a real drawing of this staircase is never quite square.

Parameters:

Name Type Description Default
steps int

Steps per flight.

5
rise float

Height of one step, in units of the short flight's tread.

0.4
width float

How deep each tread is, in the same units.

1.8
size float

Width of the finished figure.

280.0
center (float, float)

Middle of the figure.

(0.0, 0.0)

Methods:

Name Description
walk

Return the corners of the stepped band's outer edge, in space.

walk

walk() -> tuple[Spatial, ...]

Return the corners of the stepped band's outer edge, in space.

Three points per step: the foot of the riser, its top, and the far end of the tread.

Source code in src/geomotif/motifs/illusions.py
def walk(self) -> tuple[Spatial, ...]:
    """Return the corners of the stepped band's outer edge, in space.

    Three points per step: the foot of the riser, its top, and the far end
    of the tread.
    """
    far = 1.0 + 4.0 * self.rise
    runs = ((far, 0.0), (0.0, far), (-1.0, 0.0), (0.0, -1.0))
    here: Spatial = (0.0, 0.0, 0.0)
    corners: list[Spatial] = []
    for dx, dy in runs:
        for _ in range(self.steps):
            top = (here[0], here[1], here[2] + self.rise)
            onward = (top[0] + dx, top[1] + dy, top[2])
            corners.extend((here, top, onward))
            here = onward
    return tuple(corners)

NeckerCube dataclass

NeckerCube(size: float = 180.0, depth: float = 0.45, angle: float = pi / 4.0, center: Point = (0.0, 0.0))

Bases: _CubeBase

A wireframe cube with nothing to say which face is in front.

All twelve edges drawn, none broken. Louis Necker noticed in 1832 that the same drawing flips between two solid cubes as you look at it, and it does so because nothing in it is wrong -- the drawing is simply true of both.

Parameters:

Name Type Description Default
size float

Width of the finished figure.

180.0
depth float

How far the far face is offset, as a fraction of the near face's width.

0.45
angle float

Which way it is offset, in radians.

pi / 4.0
center (float, float)

Middle of the figure.

(0.0, 0.0)

ImpossibleCube dataclass

ImpossibleCube(size: float = 180.0, depth: float = 0.45, angle: float = pi / 4.0, center: Point = (0.0, 0.0), gap: float = 0.06)

Bases: _CubeBase

The same cube, told two contradictory things about which face is nearer.

The near and far faces cross each other twice. At one crossing the far edge is broken, which says the near face is in front; at the other the near edge is broken, which says the opposite. Either break alone would be an ordinary solid cube; together they are Escher's.

Parameters:

Name Type Description Default
size float

As :class:NeckerCube.

180.0
depth float

As :class:NeckerCube.

180.0
angle float

As :class:NeckerCube.

180.0
center float

As :class:NeckerCube.

180.0
gap float

Length of the break, as a fraction of the size.

0.06

CafeWall dataclass

CafeWall(cols: int = 8, rows: int = 6, size: float = 40.0, mortar: float = 3.0, shift: float = 0.5, hatch: int = 4, center: Point = (0.0, 0.0))

Bases: Motif

Parallel mortar lines that refuse to look parallel.

Rows of tiles, every other row shifted sideways, with a line of mortar between them. The dark tiles are hatched rather than filled, which is what a plotter can draw -- and the illusion needs only the contrast, not the ink. Every mortar line is exactly horizontal; none of them looks it.

Named for a cafe in Bristol whose tiling did this to passers-by.

Parameters:

Name Type Description Default
cols int

How many tiles across and down.

8
rows int

How many tiles across and down.

8
size float

Side of one tile.

40.0
mortar float

Gap between rows.

3.0
shift float

How far every other row is displaced, as a fraction of a tile. The illusion is strongest around a quarter to a half.

0.5
hatch int

Lines drawn across each dark tile.

4
center (float, float)

Middle of the wall.

(0.0, 0.0)

MoirePattern dataclass

MoirePattern(kind: MoireKind = 'rings', count: int = 34, spacing: float = 6.0, offset: float = 26.0, angle: float = 0.06, center: Point = (0.0, 0.0))

Bases: Motif

Two regular patterns laid over each other, and the fringes between them.

Nothing draws the fringes. They are where the two patterns nearly agree, and they move much faster than either pattern does -- shift one grating by a hair and the bands sweep across the whole figure.

Parameters:

Name Type Description Default
kind str

"rings" for two sets of concentric circles, "lines" for two straight gratings, "radial" for two fans of rays.

'rings'
count int

Lines or circles in each of the two patterns.

34
spacing float

Distance between neighbouring lines or circles.

6.0
offset float

How far apart the two patterns' middles are.

26.0
angle float

How far the second pattern is turned, in radians. A very small angle gives very wide fringes. Concentric circles look the same however far you turn them, so "rings" ignores it and works on offset alone.

0.06
center (float, float)

Middle of the first pattern.

(0.0, 0.0)

Methods:

Name Description
family

Return one of the two patterns, placed and turned.

family

family(at: Point, turn: float) -> tuple[Path, ...]

Return one of the two patterns, placed and turned.

Source code in src/geomotif/motifs/illusions.py
def family(self, at: Point, turn: float) -> tuple[Path, ...]:
    """Return one of the two patterns, placed and turned."""
    reach = self.count * self.spacing
    cos, sin = math.cos(turn), math.sin(turn)

    def place(x: float, y: float) -> Point:
        return (at[0] + x * cos - y * sin, at[1] + x * sin + y * cos)

    match self.kind:
        case "rings":
            return tuple(
                Path(arc_points(at, (i + 1) * self.spacing, 0.0, math.tau)[:-1], closed=True)
                for i in range(self.count)
            )
        case "lines":
            return tuple(
                Path((place((i - self.count / 2.0) * self.spacing, -reach),
                      place((i - self.count / 2.0) * self.spacing, reach)))
                for i in range(self.count)
            )  # fmt: skip
        case _:
            return tuple(
                Path(
                    (
                        place(0.0, 0.0),
                        place(
                            reach * math.cos(math.tau * i / self.count),
                            reach * math.sin(math.tau * i / self.count),
                        ),
                    )
                )
                for i in range(self.count)
            )