shapes.py

At 6a45318

6a45318History

Preview

Code · 6.4 KiB
"""Smooth, closed cubic B-spline silhouettes.

Every contour has continuous position, tangent and second derivative, including
its closing seam. Analytic profiles define simple forms; sparse control polygons
define branching cutouts. Controls are approximated, never interpolated through
an uneven series of hand-positioned landmarks.
"""
import math
import numpy as np

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 _spline(controls):
    """Exact periodic uniform cubic B-spline converted to cubic Bezier spans."""
    p = np.asarray(controls, dtype=float)
    start = (p[-1] + 4*p[0] + p[1])/6
    path = [start]
    for i in range(len(p)):
        a,b,c = p[i],p[(i+1)%len(p)],p[(i+2)%len(p)]
        path.extend(((4*a+2*b)/6, (2*a+4*b)/6, (a+4*b+c)/6))
    return np.asarray(path)


def _polar(lobes, base, depth, count=192, phase=-math.pi/2):
    t = np.linspace(0,2*math.pi,count,endpoint=False)
    r = base+depth*np.cos(lobes*t)
    return np.column_stack((r*np.cos(t+phase),r*np.sin(t+phase)))


def _controls(name):
    t = np.linspace(0,2*math.pi,128,endpoint=False)
    if name == 'flower':
        return _polar(5,.69,.29)
    if name in ('star','sparkle'):
        n = 5 if name=='star' else 4
        # Alternating controls round the tips and valleys in one continuous curve.
        angles = np.arange(2*n)*math.pi/n-math.pi/2
        radii = np.resize([1.32,.34 if name=='star' else .10],2*n)
        return np.column_stack((radii*np.cos(angles),radii*np.sin(angles)))
    if name == 'sun':
        return _polar(12,.86,.12,288)
    if name == 'pebble':
        return np.column_stack((.95*np.cos(t),.86*np.sin(t)))
    if name == 'pear':
        # Short bent stem, tapered shoulders, and a broad rounded lower belly.
        return [(-.07,-.76),(-.10,-1.04),(.02,-1.20),(.14,-1.16),
                (.04,-1.01),(.03,-.80),(.23,-.73),(.36,-.46),
                (.44,-.16),(.73,.12),(.96,.47),(.90,.84),
                (.56,1.09),(0,1.18),(-.56,1.09),(-.90,.84),
                (-.96,.47),(-.73,.12),(-.44,-.16),(-.32,-.49),(-.22,-.74)]
    if name == 'lemon':
        return np.column_stack((np.cos(t),.69*np.sin(t)*(1-.24*np.cos(t)**2)))
    if name == 'apple':
        return [(0,-.70),(.38,-1.02),(.91,-.80),(1.12,-.15),
                (.85,.65),(.38,1.05),(0,.83),(-.38,1.05),
                (-.85,.65),(-1.12,-.15),(-.91,-.80),(-.38,-1.02)]
    if name == 'leaf':
        # A tapered lens, then a fixed turn; no sampled hand-drawn perimeter.
        x=np.cos(t); y=.70*np.sin(t)*(1-.25*np.cos(t)**2)
        a=-.65
        return np.column_stack((x*np.cos(a)-y*np.sin(a),x*np.sin(a)+y*np.cos(a)))
    if name == 'bean':
        # Plump jelly bean with a broad, soft inward sweep rather than a pinched dent.
        return [(0,-1.02),(.53,-.98),(.84,-.65),(.80,-.32),
                (.55,-.04),(.49,.28),(.62,.64),(.36,.98),
                (-.16,1.08),(-.65,.82),(-.92,.30),(-.89,-.30),(-.57,-.83)]
    if name == 'heart':
        # Inflated, near-circular shoulders and a short rounded lower point.
        return [(0,-.44),(.27,-1.04),(.85,-1.12),(1.25,-.78),
                (1.30,-.20),(1.06,.39),(.59,.80),(0,1.06),
                (-.59,.80),(-1.06,.39),(-1.30,-.20),(-1.25,-.78),
                (-.85,-1.12),(-.27,-1.04)]
    if name == 'crescent':
        # A thick lunar body with a shallow, open inner arc and rounded horns.
        return [(-.44,-1.06),(.30,-1.14),(.97,-.70),(1.14,0),
                (.97,.70),(.30,1.14),(-.44,1.06),(-.08,.73),
                (.12,.36),(.16,0),(.12,-.36),(-.08,-.73)]
    if name == 'coral':
        # Rounded asymmetric lobes, with long clean transitions between branches.
        return [(-.20,1.10),(-.37,.98),(-.29,.40),(-.56,.25),
                (-1.02,.40),(-1.07,.09),(-.64,-.01),(-.45,-.11),
                (-.88,-.51),(-.78,-.83),(-.47,-.66),(-.23,-.32),
                (-.23,-.98),(.02,-1.18),(.25,-.90),(.16,-.30),
                (.58,-.69),(.97,-.58),(.91,-.29),(.39,-.16),
                (.37,.10),(.97,.15),(1.03,.47),(.77,.64),
                (.27,.30),(.25,.79),(.22,1.09)]
    if name == 'seaweed':
        return [(-.14,1.14),(-.37,.99),(-.18,.52),(-.62,.72),
                (-1.02,.54),(-.90,.24),(-.53,.27),(-.22,.34),
                (-.36,-.06),(-.78,-.11),(-.85,-.44),(-.56,-.59),
                (-.21,-.17),(-.15,-.71),(-.06,-1.17),(.24,-1.13),
                (.34,-.82),(.16,-.42),(.57,-.76),(.98,-.63),
                (1.03,-.30),(.61,-.23),(.24,-.24),(.29,.12),
                (.70,-.02),(.97,.20),(.84,.48),(.44,.35),
                (.19,.40),(.29,.93),(.13,1.14)]
    if name == 'bubbles':
        # Smooth low-frequency radial profile: three unequal fused round cells.
        r=.76+.17*np.cos(3*t+.6)+.085*np.cos(t-2.3)
        return np.column_stack((r*np.cos(t),r*np.sin(t)))
    if name == 'cloud':
        # Upright, mirror-symmetric union of circular lobes. No shear or stretch.
        angles=np.linspace(0,2*math.pi,512,endpoint=False)
        directions=np.column_stack((np.cos(angles),np.sin(angles)))
        roots=[]
        for cx,cy,radius in [(0,-.30,.66),(-.59,.10,.47),(.59,.10,.47),(0,.02,.58)]:
            center=np.array([cx,cy]);projection=directions@center
            discriminant=radius*radius-center@center+projection*projection
            roots.append(np.where(discriminant>=0,projection+np.sqrt(np.maximum(discriminant,0)),0))
        radii=np.max(roots,axis=0)
        # A narrow symmetric blend rounds only the transitions between circles.
        offsets=np.arange(-8,9);weights=np.exp(-.5*(offsets/2.0)**2);weights/=weights.sum()
        radii=sum(w*np.roll(radii,int(o)) for w,o in zip(weights,offsets))
        return directions*radii[:,None]
    raise ValueError(f'Unknown shape {name!r}')


def shape(name, center, radius, rotation=0):
    """Return one closed, C2-continuous cubic path in image coordinates."""
    if name not in SHAPES:
        raise ValueError(f"Unknown shape {name!r}; choose from {', '.join(SHAPES)}")
    if not np.isfinite(radius) or radius<=0:
        raise ValueError('radius must be finite and positive')
    points=_spline(_controls(name))
    c,s=math.cos(rotation),math.sin(rotation)
    return [tuple(p) for p in points@np.array([[c,s],[-s,c]])*radius+np.asarray(center)]

Files