shapes.py

At d794128

d794128History

Preview

Code · 8.4 KiB
"""Closed cubic Bézier silhouettes for the iridescent shape generator.

Every outline uses the same point format as ``geometry.curve_points``: one
start point followed by groups of two control points and one end point.
Coordinates below are local to a unit circle; ``shape`` applies size, turn,
and placement only after constructing the outline.
"""

import math

import numpy as np

from geometry import flower


SHAPES = {
    "flower": "Flower",
    "star": "Rounded star",
    "sparkle": "Sparkle",
    "sun": "Sunburst",
    "coral": "Coral cutout",
    "seaweed": "Seaweed cutout",
    "leaf": "Leaf",
    "bean": "Bean",
    "pebble": "Pebble",
    "bubbles": "Bubble cluster",
    "cloud": "Cloud",
    "pear": "Pear",
    "lemon": "Lemon",
    "apple": "Apple",
    "heart": "Heart",
    "crescent": "Crescent",
}


def _smooth(points, tension=1.0):
    """Interpolate a closed contour with gentle, continuous cubic tangents."""
    p = np.asarray(points, dtype=float)
    n = len(p)
    tension = max(tension, 1.12)
    path = [p[0]]
    for i in range(n):
        a, b = p[i], p[(i + 1) % n]
        tangent_a = (p[(i + 1) % n] - p[(i - 1) % n]) * tension / 6
        tangent_b = (p[(i + 2) % n] - p[i]) * tension / 6
        path.extend((a + tangent_a, b - tangent_b, b))
    return path


def _rounded_polygon(points, rounding):
    """Round selected corners while keeping each connecting side straight."""
    p = np.asarray(points, dtype=float)
    n = len(p)
    if np.isscalar(rounding):
        rounding = [rounding] * n
    before = [p[i] + (p[(i - 1) % n] - p[i]) * rounding[i] for i in range(n)]
    after = [p[i] + (p[(i + 1) % n] - p[i]) * rounding[i] for i in range(n)]
    path = [before[0]]
    for i in range(n):
        # A short cubic rounds the vertex, then an exact cubic line joins it
        # to the next vertex. Even the deepest notches remain separate.
        path.extend((p[i], p[i], after[i]))
        end = before[(i + 1) % n]
        path.extend((after[i] + (end - after[i]) / 3,
                     after[i] + 2 * (end - after[i]) / 3, end))
    return path


def _radial_points(count, inner, outer, phase=-math.pi / 2):
    return [(r * math.cos(phase + i * math.pi / count),
             r * math.sin(phase + i * math.pi / count))
            for i in range(count * 2)
            for r in [outer if i % 2 == 0 else inner]]


def _star():
    return _rounded_polygon(_radial_points(5, .48, 1), .48)


def _sparkle():
    # Four long points and four generously scooped intervals.
    return _rounded_polygon(_radial_points(4, .24, 1, -math.pi / 2),
                            [.15, .28] * 4)


def _sun():
    # A continuous wavy rim gives twelve inflated, round-ended rays.
    angles = np.linspace(0, 2 * math.pi, 144, endpoint=False)
    radii = .85 + .13 * np.cos(12 * angles)
    return _smooth(np.stack([radii * np.cos(angles), radii * np.sin(angles)], 1))


def _coral():
    # A flat-bottomed stem grows into unequal, blunt branches.
    return _smooth([
        (-.22, .94), (-.26, .48), (-.46, .34), (-.76, .38),
        (-.86, .23), (-.76, .10), (-.47, .12), (-.43, -.15),
        (-.70, -.39), (-.67, -.58), (-.51, -.63), (-.26, -.40),
        (-.18, -.80), (-.04, -.98), (.12, -.92), (.18, -.49),
        (.34, -.36), (.60, -.53), (.79, -.45), (.81, -.27),
        (.62, -.17), (.39, -.14), (.38, .13), (.69, .17),
        (.82, .33), (.72, .49), (.43, .43), (.22, .34),
        (.20, .94),
    ], .92)


def _seaweed():
    # A slender upright ribbon with broad opposing lobes.
    return _smooth([
        (-.21, .96), (-.27, .65), (-.55, .65), (-.79, .53),
        (-.78, .37), (-.61, .32), (-.30, .40), (-.32, .08),
        (-.60, -.02), (-.72, -.21), (-.62, -.35), (-.43, -.31),
        (-.27, -.18), (-.16, -.91), (.01, -.98), (.19, -.89),
        (.23, -.49), (.54, -.64), (.76, -.58), (.83, -.40),
        (.68, -.29), (.27, -.27), (.29, .04), (.54, .00),
        (.75, .10), (.75, .25), (.60, .33), (.30, .29),
        (.24, .92), (.06, 1.00),
    ], .92)


def _leaf():
    return _smooth([
        (-.87, .64), (-.70, .28), (-.55, -.10), (-.27, -.52),
        (.10, -.81), (.59, -.94), (.94, -.77), (.75, -.37),
        (.43, .02), (.04, .37), (-.41, .59),
    ], .72)


def _bean():
    return _smooth([
        (-.91, -.03), (-.76, -.48), (-.38, -.79), (.13, -.83),
        (.58, -.57), (.82, -.17), (.64, .08), (.31, .04),
        (.10, .29), (.18, .64), (-.13, .84), (-.57, .65),
    ])


def _pebble():
    return _smooth([
        (-.91, .15), (-.73, -.48), (-.23, -.83), (.40, -.78),
        (.90, -.35), (.98, .18), (.62, .72), (.10, .90),
        (-.49, .69),
    ], .85)


def _bubbles():
    # Three unequal overlapping circular cells. A short circular smoothing
    # kernel rounds their union saddles into a continuous inflated contour.
    angles = np.linspace(0, 2 * math.pi, 180, endpoint=False)
    directions = np.stack([np.cos(angles), np.sin(angles)], 1)
    intersections = []
    for center, radius in [((-.28, .30), .66), ((.04, -.44), .50), ((.50, .05), .36)]:
        center = np.asarray(center)
        projection = directions @ center
        discriminant = radius ** 2 - center @ center + projection ** 2
        distance = projection + np.sqrt(np.maximum(discriminant, 0))
        intersections.append(np.where(discriminant >= 0, distance, 0))
    radii = np.max(intersections, axis=0)
    offsets = np.arange(-9, 10)
    weights = np.exp(-.5 * (offsets / 3.2) ** 2)
    weights /= weights.sum()
    radii = sum(weight * np.roll(radii, offset) for weight, offset in zip(weights, offsets))
    return _smooth(directions * radii[:, None])


def _cloud():
    return _smooth([
        (-.90, .49), (-.99, .20), (-.87, -.08), (-.56, -.17),
        (-.51, -.49), (-.24, -.66), (.06, -.57), (.28, -.80),
        (.63, -.73), (.80, -.45), (.74, -.18), (.96, -.04),
        (1.00, .27), (.83, .49), (.57, .49), (.42, .72),
        (.12, .83), (-.16, .68), (-.40, .83), (-.69, .73),
    ], .82)


def _pear():
    return _smooth([
        (0, -.97), (.28, -.89), (.41, -.65), (.42, -.32),
        (.54, -.05), (.79, .22), (.82, .58), (.59, .90),
        (.17, 1.00), (-.28, .96), (-.70, .75), (-.83, .41),
        (-.69, .08), (-.45, -.20), (-.40, -.60), (-.23, -.87),
    ], .87)


def _lemon():
    return _smooth([
        (-1.00, .00), (-.82, -.12), (-.65, -.48), (-.27, -.70),
        (.23, -.72), (.66, -.48), (.85, -.12), (1.00, .00),
        (.84, .13), (.63, .48), (.22, .70), (-.27, .72),
        (-.67, .46), (-.85, .12),
    ], .72)


def _apple():
    return _smooth([
        (0, -.73), (.27, -.87), (.52, -.81), (.78, -.51),
        (.90, -.03), (.73, .56), (.40, .88), (.08, .94),
        (0, .82), (-.09, .94), (-.42, .87), (-.74, .55),
        (-.90, -.04), (-.77, -.52), (-.50, -.82), (-.25, -.87),
    ], .80)


def _heart():
    return _smooth([
        (0, -.55), (.28, -.87), (.66, -.85), (.94, -.59),
        (.98, -.21), (.81, .13), (.50, .43), (0, .96),
        (-.50, .43), (-.81, .13), (-.98, -.21),
        (-.94, -.59), (-.66, -.85), (-.28, -.87),
    ], .72)


def _crescent():
    # Both sides of the crescent share the same tips; the inner arc is
    # deliberately offset so the silhouette stays a single simple contour.
    return [
        (-.15, -.99),
        (.40, -.99), (.87, -.55), (.91, .02),
        (.95, .61), (.42, .99), (-.15, .99),
        (.29, .78), (.53, .43), (.53, .02),
        (.53, -.44), (.28, -.78), (-.15, -.99),
    ]


_BUILDERS = {
    "star": _star, "sparkle": _sparkle, "sun": _sun,
    "coral": _coral, "seaweed": _seaweed, "leaf": _leaf,
    "bean": _bean, "pebble": _pebble, "bubbles": _bubbles,
    "cloud": _cloud, "pear": _pear, "lemon": _lemon,
    "apple": _apple, "heart": _heart, "crescent": _crescent,
}


def shape(name, center, radius, rotation=0):
    """Return a closed cubic outline in image coordinates.

    ``rotation`` is in radians, matching ``geometry.flower``. Unknown names
    raise ``ValueError`` so generator options fail clearly at the boundary.
    """
    if name not in SHAPES:
        raise ValueError(f"Unknown shape {name!r}; choose from {', '.join(SHAPES)}")
    if radius <= 0:
        raise ValueError("radius must be positive")
    if name == "flower":
        return flower(center, radius, rotation=rotation)
    points = np.asarray(_BUILDERS[name](), dtype=float)
    c, s = math.cos(rotation), math.sin(rotation)
    turn = np.array([[c, -s], [s, c]])
    transformed = points @ turn.T * radius + np.asarray(center, dtype=float)
    return [tuple(p) for p in transformed]

Files