shapes.py

At 204b365

204b365History

Preview

Code · 5.3 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':
        return [(0,-1.22),(.36,-1.02),(.43,-.68),(.36,-.31),
                (.64,.03),(.97,.43),(.84,.89),(.40,1.12),
                (-.40,1.12),(-.84,.89),(-.97,.43),(-.64,.03),
                (-.36,-.31),(-.43,-.68),(-.36,-1.02)]
    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=.40*np.sin(t)*(1-.32*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':
        # A smooth limacon has one shallow kidney-shaped indentation.
        r=.75+.62*np.cos(t)
        return np.column_stack((-(r*np.cos(t)-.43),1.12*r*np.sin(t)))
    if name == 'heart':
        return [(0,-.43),(.32,-1.04),(.93,-.94),(1.12,-.36),
                (.81,.25),(.37,.68),(0,1.16),(-.37,.68),
                (-.81,.25),(-1.12,-.36),(-.93,-.94),(-.32,-1.04)]
    if name == 'crescent':
        # Two clean arcs joined through small rounded tips.
        return [(-.28,-1.03),(.32,-.97),(.88,-.51),(1.00,.05),
                (.85,.60),(.30,1.02),(-.28,1.03),(.13,.70),
                (.42,.34),(.45,-.05),(.34,-.44),(.10,-.77)]
    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':
        # Five broad lobes with exact, smooth transitions and a flatter base.
        r=.83+.105*np.cos(5*t)+.045*np.sin(2*t)
        return np.column_stack((1.07*r*np.cos(t),.82*r*np.sin(t)))
    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